## Summary
RevenueCat returns the subscriptions of both store environments,
`production` and `sandbox`. The API granted Capacitor Flux for both.
The API now grants only the subscriptions of its deployment environment.
`REVENUECAT_ENVIRONMENT` is `production` by default.
`REVENUECAT_SANDBOX_USER_IDS` lists dedicated test accounts whose
sandbox subscriptions production also grants. This follows
`APPLE_IAP_SANDBOX_USER_IDS`.
This PR is independent.
## Architecture and behavior
The filter is in `createRevenuecatSubscriberClient`
(`server/apps/api/src/services/adapters/revenuecat-subscriber.ts`). The
Capacitor reconciliation (`fetchEntitlements`) and the account deletion
check (`hasRenewingSubscription`) both use it.
```mermaid
sequenceDiagram
participant Caller as Webhook, sync, or deletion check
participant Reader as revenuecat-subscriber.ts
participant RC as RevenueCat API v2
Caller->>Reader: fetchEntitlements or hasRenewingSubscription
Reader->>RC: GET customers/{id}/subscriptions
RC-->>Reader: subscriptions with environment
alt environment matches REVENUECAT_ENVIRONMENT, or sandbox for a listed user
Reader-->>Caller: the subscription counts
else another environment
Reader-->>Caller: the subscription is skipped
end
```
`GET customers/{id}/active_entitlements` ignores the `environment`
parameter. When a skipped subscription grants the same entitlement, the
access ends at `current_period_ends_at` of the subscription that counts.
Otherwise the active entitlement keeps its end, so a grace period still
counts.
## Boundaries and risks
| Invariant or boundary | Failure mode | Protection | Evidence or gap |
| --- | --- | --- | --- |
| Production grants only production subscriptions | A sandbox
subscription grants Capacitor Flux | `countsFor` in `fetchEntitlements`
| `grants no sandbox subscription to a production user` |
| Test accounts and sandbox deployments still work | The filter blocks
testing | `REVENUECAT_SANDBOX_USER_IDS`,
`REVENUECAT_ENVIRONMENT=sandbox` | `grants a sandbox subscription to a
listed test account and in a sandbox deployment` |
| A skipped subscription cannot extend access | The shared active
entitlement takes the later end | `current_period_ends_at` of the
counted subscription | `ends the access with the paid period when a
sandbox subscription shares the entitlement` |
| A sandbox subscription does not block deletion | A deletion is refused
| `countsFor` in `hasRenewingSubscription` | `is false for a renewing
sandbox subscription of a production user` |
## Verification
```text
pnpm -F @proj-airi/api-server typecheck # passed
pnpm -F @proj-airi/api-server exec vitest run # 82 files passed, 2 skipped. 884 tests passed, 3 skipped
pnpm typecheck # passed before the rebase on main
pnpm lint # 0 errors before the rebase on main
```
The four new tests fail on `main` and pass with this change.
Not verified: a deployment with the new variables.
## Rollout and follow-up
- Production needs no new variable. The default is `production`.
- The `dev` `ts-api` service already has
`REVENUECAT_ENVIRONMENT=sandbox`.
- Merge this PR before the Capacitor product change branch. Both change
`revenuecat-subscriber.ts`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
## Summary
Imported Live2D ZIP and VRM models live only in the browser IndexedDB,
so they do not reach other devices. This PR adds private per-account
model sync: model metadata in Postgres, original files in S3, and device
caches in IndexedDB.
Cloud sync is behind the new local `display-model-sync` feature flag. It
is off by default.
Object storage is now required. The API does not start without
`S3_BUCKET` and `S3_REGION`. **Set them in every deployed environment
before merge.** The local compose stack provides RustFS.
The upload logic is generic (`upload_sessions`, `UploadSessionService`,
`TransferQueue`), so other file types can reuse it. The ADR is
`server/docs/ai/adr/2026-10-08-display-model-cloud-sync.md`. Its status
is still `proposed`. It records that a local file is a device working
copy and an uploaded file is an account asset.
This PR is independent.
## Change map
| Module | Before | After | Description |
| --- | --- | --- | --- |
| `object-store` adapter | Signs PUT with content type and metadata |
Also signs `x-amz-checksum-sha256` and `If-None-Match: *`. Reads the
stored checksum and supports ranged GET | The store rejects wrong bytes
and second writes |
| `upload-sessions` (new) | None | Server-chosen key, idempotent
reservation, size, checksum and signature check | Generic. The business
domain owns meaning and quota |
| `display-models` domain and routes (new) | None | List, reserve,
complete, download, rename, delete under `/api/v1/display-models` |
Revision checks. Delete writes a tombstone |
| Drizzle `0033` | None | `display_models` and `upload_sessions` | |
| `libs/file-transfer` (new) | None | Hash, signed PUT, verified
download, durable account-scoped queue | Resumes after restart. Drops
results after account change |
| Device storage for models | `localforage` in the store |
`database/repos/display-models.repo.ts`, still on `localforage` with the
same keys | No local data moves. The `unstorage` move is a separate pull
request |
| S3 configuration | Optional | Required at startup | Same class of
dependency as Redis |
| Local compose stack | No object storage | RustFS, a bucket, and CORS
for local web origins | `s3.localhost` resolves inside the network and
in a browser |
| `stores/display-models` | Local files only | Cloud directory, sync
status, upload queue, download on demand | Gated by the feature flag |
| `stores/settings/stage-model` | Missing model falls back to default |
A failed cloud download keeps the selection | Local-missing models still
fall back |
| Model selector | Local list | Cloud-only entries, status, upload and
delete actions | |
## Architecture and behavior
```mermaid
sequenceDiagram
participant A as Device A
participant API as API
participant DB as Postgres
participant S3 as Private S3
participant B as Device B
A->>A: Import and save File locally
A->>API: POST /uploads (id, requestId, size, sha256)
API->>DB: Lock owner, check quota, insert session
API-->>A: Signed PUT (checksum, write-once)
A->>S3: PUT bytes
A->>API: POST /uploads/:id/complete
API->>S3: HEAD checksum and size, ranged GET signature
alt mismatch
API->>S3: Delete object, abort session (422)
else match
API->>DB: Publish ready model
end
B->>API: GET /display-models
B->>API: POST /:id/download
B->>S3: GET, verify size and sha256, cache locally
```
## Boundaries and risks
| Invariant or boundary | Failure mode | Protection | Evidence or gap |
| --- | --- | --- | --- |
| Only verified bytes become ready | Client uploads other bytes |
Store-validated SHA-256, size and signature check | Unit tests with a
fake store |
| Ready bytes never change | Reused signed URL overwrites |
`If-None-Match: *` and a per-upload key | Integration test passes
against RustFS: wrong bytes get 400, a second write gets 412. Production
provider unverified |
| Quota holds under races | Parallel reservations exceed it | Advisory
lock per owner in one transaction | Sequential test only. No concurrent
test |
| Accounts stay isolated | Another account reads a model | Every query
filters by owner | Tests for upload, download and list |
| Account switch is safe | Stale upload result lands in new account |
Queue generation check | Queue unit test |
| Deleted models leave devices | Cache stays | Tombstone removes the
cache on next sync | Not tested |
Not in this PR: physical S3 cleanup after delete, Live2D package
validation, card reference check on delete, stage-pocket acceptance.
## Visual changes
The model selector gains sync status and menu items. They show only when
the flag is on, the user is signed in, and the API has S3. I could not
capture before and after images without that backend. This is
unverified.
## Verification
- `display-models.repo.browser.test.ts` runs in CI Chromium. It reads a
4 MiB record in the previous release format, and keeps a model and an
upload across repository instances.
Run after the rebase onto `main`:
- `tsc --noEmit` in `server/apps/api`: no errors.
- `vitest run server/apps/api`: 78 files pass, 2 skipped.
- `vue-tsc --noEmit` in `stage-ui`: no errors.
- `vitest run --project node` for the model repository, model store,
`settings`, `file-transfer`, and feature flags in `stage-ui`: 23 tests
pass.
- `eslint` on changed files: no errors.
Run before the rebase, with no later change to that code:
- `object-store.integration.test.ts` against local RustFS
(`TEST_S3_ENDPOINT`): 2 tests pass.
- CORS preflight from `http://localhost:5173` for a signed PUT: allowed.
Not run:
- `pnpm lint` and the root `pnpm typecheck`.
- `vue-tsc` for `stage-web` and `stage-tamagotchi` after the storage
change.
- The full compose stack. Only RustFS was started.
- Real production S3, browser or Electron end-to-end, and the two-device
flow.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
## Summary
TestFlight purchases use Apple's Sandbox environment. A production-only
verifier rejects them before Flux settlement.
This PR lets the production API verify Sandbox purchases for dedicated
test accounts. `APPLE_IAP_SANDBOX_USER_IDS` defaults to empty. A
nonempty list enables the Sandbox verifier, and both settlement paths
check the account against that list. Ordinary accounts cannot receive
Sandbox Flux.
This is an independent server change based on
`07ed52e39dff4884cb3f30bfcad319bed0836e69`. It does not include the
separate iOS recovery patch.
## Change map
| Boundary | Before | After |
| --- | --- | --- |
| Apple verification | One environment for every trusted app | Optional
Production and Sandbox verifiers, with full SDK signature checks |
| Account authorization | Account-token ownership only | Token ownership
plus an exact user-ID allowlist for Sandbox grants |
| Settlement identity | Raw transaction ID | Sandbox bundle/environment
namespace; existing unique index prevents duplicate grants |
| Notifications | Envelope and transaction verified separately | Both
identities must also match before settlement |
## Architecture and behavior
Before:
```mermaid
flowchart LR
C[Device or Apple notification] --> V[Production verifier]
V -->|Sandbox rejected| E[Environment error]
V -->|Production verified| A[Account token lookup]
A --> P[Payment settlement]
```
After:
```mermaid
flowchart LR
C[Device or Apple notification] --> V[Select app and enabled environment verifier]
V -->|Verify signature and identity| A[Account token lookup]
A -->|Production| P[Payment settlement]
A -->|Sandbox| G[Dedicated user allowlist]
G -->|Listed user| P
G -->|Unlisted user| R[Device 403 or notification acknowledgement without credit]
```
Unverified claims select an SDK verifier only. They cannot authorize
settlement. A production deployment never selects Xcode verification.
Notification envelopes and inner transactions must have the same bundle
and environment.
Both Sandbox delivery paths use `sandbox:<bundleId>:<transactionId>`.
Payment CORE uses the existing `(processor, processor_order_id)` unique
index and `ON CONFLICT DO NOTHING` to credit each purchase once,
including concurrent deliveries. Production order IDs stay unchanged.
There are no schema or migration changes.
## Boundaries and risks
- This is account-level access control, not a separate wallet or
database. Test Flux enters the dedicated account's existing balance and
can consume real provider resources.
- The deployment owner confirmed no historical Sandbox orders and no old
Sandbox writers during rollout. New purchases use the namespaced ID from
the first settlement. Historical order compatibility is outside this
rollout.
- Sandbox-only deployments now also require the account allowlist to
grant Flux.
- A blocked device submission returns 403 for retry. A blocked
notification returns 200 without a grant. Configure the allowlist before
the purchase.
- Removing an ID requires an API restart and does not delete an existing
balance. Payment records retain their Sandbox environment in extras;
reporting must exclude test orders.
- Refund processing and general TestFlight/App Review account
provisioning are outside this PR.
## Verification
- Before implementation, five new route assertions failed: unrestricted
device/notification grants, missing Sandbox order namespace, and two
envelope identity mismatches.
- `pnpm -F @proj-airi/api-server exec vitest run src/routes/apple-iap
src/libs/tests/env.test.ts
src/services/domain/payment/tests/payment.test.ts` — **57 passed**.
- Payment tests cover Sandbox replay, concurrent delivery, distinct
bundles, and separation from Production transaction IDs.
- `pnpm -F @proj-airi/server-sdk-shared build` — passed.
- `pnpm -F @proj-airi/api-server typecheck` — passed.
- `pnpm exec moeru-lint <all changed files>` — passed with pre-existing
comment-length warnings.
- `git diff --check` — passed.
- `pnpm typecheck` — could not run across the monorepo: this server-only
sparse checkout excludes the `packages`, `apps`, and `docs` roots.
- `pnpm lint` — did not pass across the sparse checkout: excluded
package manifests cause unused-catalog errors; unrelated existing
findings are also present. Changed files pass separately.
The real Apple SDK tests reject unsigned JWS input. SDK-boundary doubles
cover successful environment selection and retryable failures. PGlite
tests cover settlement replay and namespace separation. Live Apple
transactions, deployment, and notification delivery are **not
verified**.
## Rollout
1. Merge and deploy while keeping `APPLE_IAP_ENV=production`.
2. Add the dedicated AIRI account's internal ID to
`APPLE_IAP_SANDBOX_USER_IDS`, then restart the API.
3. Confirm the bundle/App Store IDs and ConfigKV `APPLE_FLUX_PACKS`
mappings.
4. Configure the Sandbox Notifications V2 URL and install an iOS build
with the recovery fixes.
5. Check purchase credit once, retry recovery, duplicate notification
delivery, and rejection for an ordinary account.
No database migration is required. The API README contains the
procedure. `server/docs/ai/adr/2026-09-27-apple-iap-sandbox-accounts.md`
records the decision and boundaries. No production configuration was
changed by this PR.