Files
RainbowBirdandClaude Sonnet 5.5 dfb59461d3 feat(api,stage-ui): sync Live2D and VRM models to private storage (#2869)
## 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>
2026-10-14 16:06:15 +08:00
..