# Deck Sync Design

This document sketches the first deck sync design. It is intentionally
conservative: sync is snapshot upload/download, not merge or reconciliation.
The aim is to reuse Cosimo's existing full-deck bundle and restore machinery
while adding identity, access control, and overwrite safety.

## Goals

- Let a learner upload a deck snapshot to a Cosimo sync server.
- Let the same learner download that snapshot on another machine.
- Let users publish deck content without publishing their review database.
- Use Mastodon-compatible OAuth as the first account mechanism.
- Avoid server-side deck merging. The server stores and serves snapshots.
- Detect, rather than silently resolve, conflicting overwrites.

## Non-Goals

- No card-level merge.
- No review-history merge between devices.
- No server-side scheduling logic.
- No social features beyond ownership, visibility, listing, and download.
- No generic Fediverse guarantee at first. The first target is
  Mastodon-compatible OAuth.

## Identity

The sync server should authenticate users through a Mastodon-compatible OAuth
flow.

Expected flow:

1. The user gives Cosimo a Mastodon instance or full account address.
2. Cosimo asks the sync server to begin an out-of-band login.
3. The sync server registers or reuses its OAuth application for that instance.
4. The user authorises Cosimo with the smallest useful scope, currently
   `read:accounts`, in the user's browser.
5. The Mastodon-compatible instance shows an out-of-band authorisation code.
6. The user pastes that code into Cosimo.
7. Cosimo sends the code to the sync server.
8. The sync server exchanges the code, calls the instance's
   verify-credentials endpoint, and maps the result to a stable owner identity.
9. The sync server revokes the temporary Fediverse access token.
10. The sync server issues Cosimo its own bearer token.

The current implementation maps the verified Mastodon `acct` value to an owner
identity of the form `acct:user@example`. If the instance returns a local-only
`acct`, Cosimo qualifies it with the instance host. It should not rely on
display names. It should not store Fediverse access tokens; after a successful
token exchange it revokes the temporary token before issuing a Cosimo bearer
token.

Deck ownership must come from this authenticated owner identity, not from an
editable deck field or arbitrary client-supplied owner header. The current GUI
login flow stores the Cosimo bearer token locally and owner routes send
`Authorization: Bearer ...`; the server derives the owner from the token and
does not accept owner identity from deck metadata or unauthenticated request
headers. Deck metadata may still show the owner for user feedback, but the
server-side token is authoritative.

Because non-Mastodon Fediverse servers may not expose the same OAuth API, the
first implementation should describe this as Mastodon-compatible login rather
than universal Fediverse login.

## Deck Identity And Visibility

Each syncable deck needs a stable UUID. File names are not enough because users
can rename or copy decks.

Possible authored deck metadata:

```text
deck_uuid=...
visibility=private
```

Visibility values:

- `public`: deck content can be listed and downloaded by anyone.
- `unlisted`: deck content is not listed, but anyone with the high-entropy deck
  identifier or link can download it.
- `private`: deck content and study history can be downloaded only by the
  owner.

Deck metadata is only the user's requested/default visibility. The server's
stored visibility is authoritative for access control.

Downloaded public or unlisted decks retain their original deck UUID. This is
necessary when the user wants to receive later updates for the same deck, but
it means upload must fail if the authenticated owner is not the server-side
owner of that UUID. The GUI therefore offers an explicit independent-copy
action in the deck metadata editor. It generates a new deck UUID, resets the
visibility choice to private, keeps cards, auxiliary files, and local review
history, and clears only the old UUID's local per-deck revision/hash state from
`cosimo.ini`. Global sync configuration, login tokens, and device name are
not changed.

## Device Name

Each Cosimo installation can have an optional user-given device name, such as
`desktop`, `laptop`, or `work machine`. This is distinct from the user account
and from deck UUIDs:

- user identity says who owns the synced data
- deck UUID says which deck is being synced
- device name says, in the user's own terms, which local Cosimo installation
  produced a sync event

```text
Last uploaded from laptop at 2026-05-22 19:10 UTC.
```

Device names should not be security-sensitive, globally addressable, or
published in public deck listings. They are useful in owner views, conflict
warnings, and sync history. If no name is set, Cosimo should show "none" or
"unnamed device", depending on context.

## Snapshot Types

Cosimo already has two useful bundle shapes:

- content bundle: deck file plus same-stem sidecar directory, excluding the
  review database.
- study-history bundle: the same content plus a SQLite review-database
  snapshot.

For owner sync, the server can store one canonical private study-history
snapshot and derive a content-only download on demand when visibility permits.
This avoids keeping two stored artefacts for the same revision. For
sharing-only use, the client may upload only a content bundle; storing less
private data is always acceptable, but then owner download will not restore
study history from that server snapshot.

The server must never expose the review database through public or unlisted
downloads. Deriving a content-only bundle should therefore be a server-side
operation with tests that prove `.reviews.sqlite3` is excluded from public and
unlisted responses.

The current implementation wires this boundary into the sync server: owner
uploads can store only the study-history zip, and public/unlisted downloads
derive a content-only zip on demand by preserving deck and sidecar files while
omitting the root review database snapshot. Tests cover the storage layout and
verify that derived shared downloads do not include review data.

An optional implementation detail is a cache of derived content-only bundles
keyed by deck UUID and revision hash. This is a cache, not the authoritative
state.

## Server Metadata

The server should store metadata separately from the uploaded bundle:

- deck UUID
- owner identity
- visibility
- current revision identifier
- current bundle hash
- deck title and root deck file name when available
- card count
- audio file count
- generated versus manual/untracked audio counts, inferred from the audio
  manifest when present
- audio formats
- future descriptive fields such as description, tags, language fields, and
  licence when available
- created and updated timestamps
- whether the stored snapshot includes a review database
- last upload device name, if provided

The revision identifier can be a monotonically increasing server revision, a
content hash, or both. A server revision is easier for conditional updates; a
content hash is useful for integrity checks.

## Deployment Model

The durable server design should support CGI-style deployment as the baseline.
That means the sync logic must not depend on long-lived process memory. A valid
server request should be able to:

1. read request metadata and body
2. open the configured storage root
3. run one SQLite transaction or read operation
4. read or write bundle files below that root
5. write the response and exit

This model is less efficient than a daemon behind a reverse proxy, but it is
much easier for users who can place a binary in ordinary web space without root
access or web-server configuration. A standalone HTTP server can still exist for
development, local testing, or reverse-proxy deployments, but it should call the
same request/storage core rather than owning server state itself.

The storage root must be private. It should not be placed under a public static
document directory where bundle files can be downloaded directly. Public and
unlisted access must go through Cosimo's server code so review databases are
never served accidentally.

The first durable storage implementation uses:

```text
cosimo-sync.sqlite3
bundles/<deck_uuid>/content-r<revision>.bundle          # content-only upload
bundles/<deck_uuid>/study-history-r<revision>.bundle    # owner history upload
```

SQLite stores deck UUIDs, owners, visibility, revisions, bundle hashes, and
last-upload device diagnostics. Bundle bytes are stored as files rather than
SQLite BLOBs so large deck archives do not bloat ordinary metadata queries. The
revision number is monotonically increasing per deck. For owner study-history
uploads, the server stores only the study-history bundle and derives
content-only shared downloads on demand, avoiding duplicate storage for audio
decks. Bundle filenames include the revision, so replacing a deck never
overwrites the file a concurrent download may still be reading. After a
successful replacement upload, the SQLite backend makes a best-effort pass to
delete older bundle files for the same deck. A concurrent download which had
already read old metadata may fail and need retry, but storage growth should
not be unbounded.

Remote deck deletion is an owner-only server operation. The GUI only exposes it
for decks returned by the owner listing, and the server still derives the owner
from the authenticated bearer token before deleting anything. Deletion removes
the deck metadata row and the whole `bundles/<deck_uuid>/` directory, including
any old bundle files that may have survived earlier pruning. It deliberately
does not affect local deck files, local sidecar audio, or local review
databases.

Uploads use an SQLite transaction to check owner identity and expected revision.
The server writes the new revision's bundle files before committing the
metadata row. If the process exits before the commit, the new files are ignored
because no metadata points at them. If a second uploader races with the first,
the revision check still prevents a blind overwrite.

## Future Content-Addressed Storage

If sync usage grows enough for storage efficiency to matter, the server could
move from per-revision zip files to content-addressed storage. In that model,
each deck file, sidecar audio file, manifest, and review database snapshot
would be stored once under a hash-derived name. Per-revision metadata would then
store a manifest mapping logical bundle paths to content hashes. Two users
sharing the same deck, or two partly overlapping decks, could reuse identical
objects instead of storing the same audio or deck text twice.

This is deliberately a later scalability option rather than the current design.
It would complicate uploads, bundle reconstruction, pruning, integrity checks,
and privacy boundaries. In particular, pruning would need reference counting or
a mark-and-sweep pass, and public/unlisted downloads would still need a
manifest-level guarantee that review database objects are excluded. The current
single-bundle-per-revision approach is simpler and sufficient while sync is
experimental.

## Conflict Policy

The server should not accept blind overwrites.

Uploads should include the revision the client last saw:

```text
expected_revision = ...
```

If the server's current revision differs, the server returns a conflict. Cosimo
then offers choices such as:

- download remote first
- overwrite remote explicitly
- cancel

No automatic merge should happen in the first version.

This is the essential distinction between a dumb storage layer and an unsafe
storage layer: the server does not reconcile content, but it must still prevent
unintentional lost updates.

The implementation keeps a local in-memory server model for this rule. It
stores opaque bundle payloads and metadata, rejects upload attempts whose
expected revision does not match the current remote revision, rejects owner
mismatches, and exposes owner-facing device metadata from the last upload. It
is not a network server; it is a protocol boundary used to keep conflict and
access rules testable without sockets. Durable behaviour is now covered by the
SQLite-backed
sync server used by the standalone and CGI HTTP entry points.

Above those protocol tests, the implementation also has a client workflow using
the existing full-deck bundle writer, content-only derivation, staged restore
path, and `cosimo.ini` sync-state records. The GUI workflow uses the configured
sync server URL through the HTTP client. Deck metadata editing exposes
visibility, Tools > Options exposes the sync server URL and local device name,
and Deck > Sync exposes login, sync status, remote deck listing, and owner
study-history upload/download actions. The remote deck list can also copy a
selected deck UUID and find public or unlisted decks from a pasted UUID or API
URL, which gives unlisted sharing a usable first GUI path without adding a
second storage representation. The server caches deck summary fields at upload
time, including title, deck name, card count, audio counts, and audio formats,
so the remote list/details dialogue can be informative without downloading the
entire bundle. Sync status reads local configuration and checks remote deck
metadata when the configured target allows it. This gives a fresh client enough
information to see that a remote revision already exists before trying to
upload over it.

Upload and download can perform slow disk, compression, restore, and network
work, especially for decks with audio. The GUI therefore launches those
operations on worker threads and shows a small modal progress dialog whose
updates are sent back to wx through `wxdragon::call_after`; the dialog is
phase-based rather than byte-accurate because the current HTTP client does not
surface transfer progress callbacks.

## Upload

Initial upload flow:

1. Ensure deck has a UUID, creating and saving one if missing.
2. Build either a study-history bundle for owner sync or a content-only bundle
   for sharing-only upload. Owner sync uploads only the study-history bundle;
   the server derives content-only public and unlisted downloads when needed.
3. Calculate local bundle hashes.
4. Send metadata, visibility, expected remote revision, device name, and
   bundle.
5. Server stores the bundle and returns the new revision/hash.
6. Cosimo stores local client sync state.

The upload should be blocked or require explicit confirmation if:

- the deck has no UUID and cannot be saved safely
- the review database fails integrity checks
- the server reports a revision conflict
- the user is trying to publish content marked private on the server
- the bundle is unexpectedly large

If a fresh client has the deck UUID and owner identity but no remembered last
seen remote revision, an upload to an existing remote deck is a conflict. The
expected recovery is to run `Download with Study History` first; that adopts
the current remote revision into local client sync state, after which later
uploads are conditional on that revision.

The UI should make the payload distinction explicit. Visibility controls who
can download deck content; it should not silently imply that the review
database is being uploaded. The first GUI action is deliberately named as an
upload with study history. A later publishing action can upload content only.

## Download And Restore

Downloads should reuse the restore safety model:

1. Ask the server for metadata.
2. Download the relevant bundle.
3. Preview deck name, number of cards, audio-file count, total file count, and
   whether study history is present.
4. Warn before overwriting local files.
5. Write a local full backup of the current target deck before replacing it.
6. Restore through the existing staged restore path.
7. Record the downloaded revision as the last seen remote revision.

The local restore target is selected by deck UUID, not just by the filename
inside the bundle. A download of the currently open deck restores to the
currently open path, even if the remote bundle was created from a differently
named local file. A download of a different remote deck first looks for a known
local deck with the same UUID and otherwise restores to a separate deck file.
This prevents a different synced deck from overwriting the current open deck
only because both copies happen to share a filename. If the remote deck UUID is
not known locally, Cosimo must choose a restore target whose full restore
footprint is unused; the first download of an unknown UUID must not overwrite
an existing deck file, review database, or sidecar file.

Public and unlisted downloads must receive content-only bundles. Owner
downloads may receive the study-history snapshot.

Unlisted discovery is intentionally client-driven: the public list omits
unlisted decks, but a user with the deck UUID can use Remote Decks > Find by ID
to inspect the shared metadata and then download the content-only bundle. Copy
ID currently copies the UUID because there is no stable browser-facing share
route yet; a later user-facing URL can be added without changing the access
rule.

The server/core tests cover this distinction: shared downloads for public and
unlisted decks return the content-only payload, private decks reject shared
downloads, and owner downloads may receive the study-history payload when one
was uploaded.

## Local Client Sync State

Local client sync state should not live in the deck file if it is
account-specific. Possible state in `cosimo.ini` or a separate sync-state file:

- optional local device name
- deck UUID
- sync server URL
- owner/account identity
- Cosimo bearer token for the sync server
- last seen remote revision
- last uploaded local bundle hash
- last downloaded remote bundle hash

The current implementation stores this in `cosimo.ini`. Device-wide local
identity uses a single `[sync]` section:

```ini
[sync]
sync_server_url=https://sync.example.invalid
device_name=laptop
sync_owner_identity=acct:modulus@example.invalid
sync_bearer_token=...
```

`sync_server_url` is an application setting rather than deck metadata. It says
which server this Cosimo installation should use by default; shareable deck
metadata still only describes the deck itself. `sync_owner_identity` and
`sync_bearer_token` are local authentication state. They are cleared when the
configured sync server URL changes, or when the user chooses Deck > Sync >
Log Out. The bearer token is currently stored in the same local ini file as
other Cosimo settings, so the local configuration file should be treated as
private.

`device_name` is an optional user-provided pet name for this portable Cosimo
installation. It is not ownership or authentication; it is only shown in sync
diagnostics such as the "last uploaded from" field. If a user copies the whole
Cosimo folder to another computer or USB drive, they can change this name in
Tools > Options to distinguish future uploads from the copied installations.

Per-deck remote state is stored as repeated `[sync.deck]` sections:

```ini
[sync.deck]
deck_uuid=650e8400-e29b-41d4-a716-446655440000
sync_server_url=https://sync.example.invalid
owner_identity=acct:modulus@example.invalid
last_seen_remote_revision=42
last_uploaded_local_bundle_hash=sha256:...
last_downloaded_remote_bundle_hash=sha256:...
```

Incomplete or malformed deck-sync records are ignored when loading local
configuration. They should not prevent ordinary offline use of a deck.

The deck file can store only shareable metadata: UUID, visibility preference,
title, author, description, URL, tags, licence, voice metadata, and deck format.

## API Sketch

The first server-facing core should expose the same operations that the HTTP
API will later wrap. OAuth is deliberately outside this core: tests pass a
normalised owner identity directly, as if an authentication layer had already
verified the request.

Core operations:

- upload a deck snapshot for an authenticated owner, with `expected_revision`
- download owner study history for the authenticated owner
- download shared content by deck UUID when visibility permits
- inspect owner metadata for one deck
- inspect shared metadata for one public or unlisted deck
- list the authenticated owner's decks
- list public decks

The public/shared metadata type must not contain owner-only device details or
review-database hashes. This keeps the privacy boundary enforced by the type
system rather than by a convention at each HTTP handler. Owner metadata may
include the last upload device and whether study history is present.

Possible minimal HTTP API over that core:

```text
GET  /api/v1/decks
GET  /api/v1/decks/{deck_uuid}
GET  /api/v1/decks/{deck_uuid}/download?kind=shared
GET  /api/v1/decks/{deck_uuid}/download?kind=owner-study-history
POST /api/v1/decks/{deck_uuid}/upload
DELETE /api/v1/decks/{deck_uuid}
GET  /api/v1/me/decks
GET  /api/v1/quota
POST /api/v1/auth/fedi/start
POST /api/v1/auth/fedi/complete
POST /api/v1/auth/logout
```

The implementation includes `cosimo-sync-server`, a standalone HTTP server for
local testing and reverse-proxy deployments. Without `--storage-root`, it uses
the in-memory sync core and loses all synced decks on restart. This mode exists
for protocol tests and temporary local experiments, not for real deployment.

With `--storage-root PATH`, the same binary uses the SQLite/file storage layout
above. This mode is durable across process restarts and uses the same request
routing, OAuth authentication, bearer-token owner routes, quota checks, bundle
validation, and storage backend as the CGI entry point. Its resource limits can
be configured with the same `COSIMO_SYNC_MAX_...` environment variables used by
CGI deployments.

The implementation also includes `cosimo-sync-cgi`, which uses the same request routing
and storage code as the standalone server. It can be invoked directly, or used
as the interpreter for a small Fossil-style CGI config script. It can take
`--storage-root PATH`, read `storage-root:` from the config script, use
`COSIMO_SYNC_STORAGE_ROOT`, or default to a `cosimo-sync-storage` directory
beside the CGI executable. The storage root must remain private; the default is
meant for simple testing on hosts where the CGI directory is not served as
static content.

The implementation also includes a first `HttpSyncClient` transport. Plain `http://`
URLs are accepted only for local loopback development addresses such as
`127.0.0.1`, `[::1]`, or `localhost`; non-local sync servers must use
`https://`. The plain HTTP path uses the standard-library socket path, which
keeps local development server tests small and deterministic. `https://` URLs
use the existing `reqwest`/rustls client dependency that Cosimo already needs
for OAuth. Both paths exercise the same typed upload, download, listing, and
revision conflict model as the server core. The client can complete the
Mastodon-compatible OOB OAuth flow, receive a Cosimo bearer session, and attach
`Authorization: Bearer ...` to owner sync operations.

The first authentication slice stores server-side bearer-token sessions for a
verified owner. SQLite-backed sync servers persist SHA-256 verifier hashes of
those sessions in the sync server database, so login survives CGI process
boundaries and server restarts without storing the client bearer token
directly. Existing plaintext token rows from early branch builds are converted
to verifier hashes when the server opens the database. Owner-only routes accept
`Authorization: Bearer ...` and derive the owner from the token. Earlier
development shortcuts that accepted client-supplied owner claims have been
removed because they would be unsafe on a public deployment.

The implementation also includes Mastodon-compatible out-of-band login endpoints:

```text
POST /api/v1/auth/fedi/start
X-Cosimo-Fedi-Instance: mastodon.example

POST /api/v1/auth/fedi/complete
X-Cosimo-Login-Token: ...
X-Cosimo-Authorization-Code: ...
```

The server registers an OAuth application with `redirect_uris` set to
`urn:ietf:wg:oauth:2.0:oob`, asks for the `read:accounts` scope, exchanges the
user-pasted authorisation code, verifies the account with
`/api/v1/accounts/verify_credentials`, revokes the temporary Fediverse access
token with `/oauth/revoke`, and returns a Cosimo bearer token only if
revocation succeeds. If credential verification fails after token exchange, the
server still attempts to revoke the temporary Fediverse token before returning
the verification error.
Pending login state is persisted in SQLite so CGI deployments do not rely on
process memory between the start and complete requests.
The GUI stores the resulting Cosimo bearer token locally and can clear that
local token with Deck > Sync > Log Out. When the configured server is reachable,
logout also asks the server to revoke the bearer token. If revocation fails,
Cosimo still clears the local token and reports the revocation failure.

### Server Storage Boundary

The SQLite sync backend treats the configured storage root as a private server
directory. It creates and opens only:

- `cosimo-sync.sqlite3` directly inside the storage root.
- bundle files below `bundles/<deck-uuid>/`, with server-generated revision
  file names.

The backend validates that storage file names are single path components,
rejects symlinked storage roots, rejects symlinked database or bundle files, and
rejects symlinked bundle directories before writing. Bundle writes use a
newly-created temporary file before replacement, so a pre-existing temporary
path is not followed.

This is intended to prevent a remote sync user from causing writes outside the
configured storage root and to fail closed if the storage tree has been locally
tampered with. It is not a substitute for filesystem permissions: the CGI
storage root should be writable only by the account running the sync server and
should not be inside a shared writable directory.

## Deployment Notes

The intended low-friction deployment model is CGI behind an existing HTTPS web
server. A small deployment can put a tiny executable config script in the CGI
directory while keeping the real `cosimo-sync-cgi` binary and the storage root
outside the web-readable tree. The storage root must be readable and writable
by the CGI process but not directly web-readable. It contains the SQLite sync
database, bearer-token sessions, pending OAuth logins, and versioned bundle
files. It should be backed up by the server operator like any other application
data directory.

A Fossil-style CGI config script looks like this:

```text
#!/home/david/bin/cosimo-sync-cgi
storage-root: /home/david/cosimo-sync-storage
max-storage-bytes-total: 1073741824
max-storage-bytes-per-owner: 104857600
max-decks-per-owner: 20
```

The script can be named according to the web host's CGI convention, for example
`cosimo-sync.cgi`, and marked executable. Blank lines and lines beginning with
`#` are ignored. Unknown, duplicated, malformed, or empty settings are rejected
so deployment mistakes fail closed.

Cosimo clients should use the externally reachable CGI URL as the sync server
URL. For example, if the CGI executable is reachable as:

```text
https://example.invalid/cgi-bin/cosimo-sync-cgi
```

then `Tools -> Options -> Sync server URL` should use that same URL. The client
appends API paths below it, so `/api/v1/health` is requested through the same
CGI endpoint. Local-only development runs may use `http://127.0.0.1:44791`, but
deployed sync should use `https://`.

If the CGI URL is opened directly in a browser, the sync server returns a small
HTML status page with the storage backend, health endpoint, and a link back to
the Cosimo project wiki. This is only a deployment sanity check; Cosimo still
uses the `/api/v1/...` endpoints for actual sync operations.

A long-running deployment can instead run `cosimo-sync-server` behind a reverse
proxy. CGI remains the preferred and better-tested public deployment model for
now, but the reverse-proxy shape is reasonable for VPS, dedicated-server, home
server, or VPN deployments where a supervised process is available. Bind the
sync server to loopback or another private interface, keep the storage root
outside the web-readable tree, and let the reverse proxy provide the public
HTTPS endpoint. The HTTP request/storage core is shared with CGI, so behaviour
should remain the same apart from process lifetime and web-server integration
details.

The standalone server has a deliberately small HTTP implementation: one request
per TCP connection, `Content-Length` request bodies, and no built-in TLS. A
public deployment should therefore place a normal HTTPS reverse proxy in front
of it and bind the server itself to a private address. If OAuth rate limiting
should use the real client IP rather than the proxy's loopback address, start
the server with `--trusted-client-ip-header HEADER` and configure the local
proxy to strip any incoming copy of that header before adding its own value.
The standalone server only honours that trusted header from loopback peers, so
accidentally exposing the backend port does not let remote clients spoof their
rate-limit identity through that option.

CGI hosts vary in how they expose headers. Owner routes require:

```text
Authorization: Bearer ...
```

to reach the CGI process as `HTTP_AUTHORIZATION`; if the web server strips this
header, login can succeed but owner listing, upload, and download will fail with
an authentication error. On Apache-style setups this may require enabling
authorization-header forwarding for CGI, for example through the host's
documented equivalent of `CGIPassAuth On` or a rewrite rule that preserves the
header. The exact mechanism is deployment-specific and should be tested before
trusting the server with real decks.

The CGI executable resolves storage in this order:

1. `--storage-root PATH`
2. `storage-root:` in a Fossil-style config script
3. `COSIMO_SYNC_STORAGE_ROOT`
4. a `cosimo-sync-storage` directory beside the CGI executable

The fourth option is convenient for testing but may be unsafe if the CGI
directory is also served as static content. Prefer an explicit private storage
root outside the web-readable tree when the host allows it.

The first upload format keeps bundle payloads opaque and avoids multipart
parsing dependencies. The request body contains the content-only bundle bytes,
the study-history bundle bytes, or both in that order. Owner sync normally sends
only the study-history bundle. Sharing-only upload may send only a content
bundle. The split points and hashes are carried in headers:

```text
X-Cosimo-Visibility: private
X-Cosimo-Expected-Revision: 42
X-Cosimo-Content-Hash: sha256:...
X-Cosimo-Content-Length: ...
X-Cosimo-Study-History-Hash: sha256:...
X-Cosimo-Study-History-Length: ...
```

Each supplied bundle must include both its hash and length headers. At least one
bundle must be present. Bundle hashes are SHA-256 hashes of the uploaded bytes;
the server verifies each declared hash before accepting the upload and stores
only verified/computed hash values. Public and unlisted downloads still return
only the content bundle.

`GET /api/v1/decks` lists only public shared metadata. `GET /api/v1/me/decks`
requires authentication and lists the caller's owner metadata. `GET
/api/v1/decks/{deck_uuid}` returns shared metadata for public or unlisted decks
and rejects private decks unless the future implementation chooses to route
owner inspection through the same path after authentication.

The upload endpoint should accept `expected_revision`; if the expected revision
does not match the current server revision, it returns a conflict rather than
overwriting. Public and unlisted download should return a content-only bundle.
Owner download can request study history if the stored snapshot contains it.

The server should reject paths, filenames, or bundle structures that would be
rejected by Cosimo's local restore validator.

## Server Limits And Abuse Safeguards

Public CGI deployment must assume hostile clients. The server therefore needs
resource limits that are enforced before uploaded data becomes authoritative
state:

- maximum uploaded bundle size
- maximum expanded zip size
- maximum number of entries in a bundle
- maximum root deck-file size
- optional maximum number of decks per owner
- optional maximum stored bytes per owner
- optional maximum stored bytes for the whole server
- maximum OAuth login starts per client address per hour
- maximum OAuth login starts per instance per hour
- maximum pending OAuth logins per instance

The current implementation has a `SyncServerLimits` configuration used by the SQLite
sync backend. The CGI executable reads these optional environment variables:

```text
COSIMO_SYNC_MAX_BUNDLE_BYTES
COSIMO_SYNC_MAX_BUNDLE_EXPANDED_BYTES
COSIMO_SYNC_MAX_BUNDLE_ENTRIES
COSIMO_SYNC_MAX_DECK_FILE_BYTES
COSIMO_SYNC_MAX_DECKS_PER_OWNER
COSIMO_SYNC_MAX_STORAGE_BYTES_PER_OWNER
COSIMO_SYNC_MAX_STORAGE_BYTES_TOTAL
COSIMO_SYNC_MAX_OAUTH_STARTS_PER_CLIENT_PER_HOUR
COSIMO_SYNC_MAX_OAUTH_STARTS_PER_INSTANCE_PER_HOUR
COSIMO_SYNC_MAX_PENDING_OAUTH_LOGINS_PER_INSTANCE
```

The same limits can be set in the Fossil-style config script with lower-case
hyphenated keys. Script values override environment values:

```text
max-bundle-bytes: 52428800
max-bundle-expanded-bytes: 52428800
max-bundle-entries: 2000
max-deck-file-bytes: 52428800
max-decks-per-owner: 20
max-storage-bytes-per-owner: 104857600
max-storage-bytes-total: 1073741824
max-oauth-starts-per-client-per-hour: 10
max-oauth-starts-per-instance-per-hour: 100
max-pending-oauth-logins-per-instance: 20
```

The default bundle limits are deliberately finite even when no quota variables
are configured. The root deck file is also capped separately at 50 MB and read
through a bounded reader, so a zip entry cannot force Cosimo to read an
unbounded deck text before parsing it. Deck-count and storage quotas default to
no explicit quota because suitable values depend on the deployment. If storage
quotas are set, the SQLite backend checks them while holding its write
transaction. Replacement uploads prune obsolete bundle files for the same deck
after the new revision is stored, so repeated syncs should not grow storage
without bound.

`GET /api/v1/quota` exposes the configured deck-count and storage limits. An
anonymous request receives only general server limits. If the request has a
valid Cosimo bearer token, the response also includes the authenticated
owner's current deck count and stored bundle bytes. The client uses this from
Deck -> Sync -> Sync Status so the user can see quota usage without attempting
an upload first.

Uploaded bundles are no longer treated as arbitrary opaque bytes at the durable
server boundary. Before writing a bundle, the SQLite backend verifies that it is
a plausible Cosimo deck archive:

- exactly one root `.cosimo-deck` file
- a parseable deck file
- no absolute paths, parent-directory paths, backslash paths, duplicate paths,
  or unsupported path components
- a root review database only in study-history bundles
- sidecar entries only below the deck-stem directory
- sidecar files limited to Opus/WAV audio files and `audio-manifest.tsv`

This is intentionally a cheap synchronous validation, not a full semantic audit
of every stored file. More expensive checks can be added later as a maintenance
or audit task.

OAuth login has initial abuse controls so the sync server is less useful as an
unauthenticated amplifier against Mastodon-compatible instances. The durable
SQLite backend caches OAuth application credentials per normalised instance, so
repeated login starts for the same instance reuse the existing application
instead of registering a new one. Login-start attempts are recorded before the
remote registration call and are rate-limited by client address and by instance
within a one-hour window. Pending login tokens are also capped per instance and
old pending tokens expire after a short interval.

For CGI deployments, `REMOTE_ADDR` is copied into an internal
`X-Cosimo-Client-Address` request header for this rate limiting. The standalone
server fills the same header from the TCP peer address, or from an explicitly
trusted loopback proxy header when configured. This value is a coarse
abuse-control key, not an authentication factor.

The OAuth HTTP client uses a fixed request timeout, refuses redirects, resolves
the instance host before making a request, rejects any resolved private,
loopback, link-local, multicast, documentation, or reserved address, and pins
the checked socket addresses into the request client. Requests still use the
original HTTPS hostname, so TLS hostname verification is preserved while DNS
rebinding after the check is avoided. Instance URL validation also rejects
non-HTTPS URLs, non-root paths, localhost names, and unsafe literal IP
addresses before resolution. Conservative deployments may still want an
allowlist of accepted Mastodon-compatible instances.

## Privacy And Safety

Highest-risk failure: publishing a review database. Tests should cover every
public/unlisted path that creates or serves a bundle.

Rules:

- public and unlisted downloads exclude `.reviews.sqlite3`
- private owner downloads may include `.reviews.sqlite3`
- server-side listing exposes only public metadata
- unlisted decks require a high-entropy identifier or token in addition to not
  being listed
- non-local uploads and downloads require HTTPS; plain HTTP is only for
  loopback development endpoints
- restore keeps the local backup-first policy

Server-side storage of review history is still sensitive even if it is never
published. Users who want only sharing should be able to upload content without
study history.

## Implementation Phases

1. Design and documentation.
2. Add deck UUID and visibility metadata parsing/formatting.
3. Add optional local device-name settings, with tests. A separate local
   application UUID was tried and removed; the device name is a user-facing
   diagnostic label, not an authentication factor.
4. Add local client sync-state types and tests.
5. Add bundle derivation tests: study-history snapshot in, content-only bundle
   out. Done as a local bundle primitive.
6. Add a mock server/client boundary or local transport for testing upload and
   download without network. Done for opaque payloads,
   revisions, ownership, visibility, and device diagnostics.
7. Add a minimal GUI surface for HTTP upload/download. Done for
   owner study-history sync only.
8. Add server-core access boundaries: public/shared deck metadata, owner deck
   metadata, public listing, owner listing, owner inspection, and shared
   inspection. Done for the in-memory server core.
9. Implement the HTTP server. Done as a std-only standalone binary that shares
   request routing, OAuth authentication, bearer-token owner routes, limits,
   and storage with the CGI entry point.
10. Add durable server storage. Done as SQLite metadata plus
    versioned bundle files under a private storage root.
11. Add a CGI entry point over the durable storage core. Done as
    `cosimo-sync-cgi`, sharing the same HTTP routing code as
    `cosimo-sync-server`.
12. Add HTTP client transport. Done as a std-only loopback-only
    `http://` path for local development and protocol tests plus a
    `reqwest`/rustls `https://` path for deployed sync servers.
13. Wire the GUI sync workflow to the HTTP client transport. Done
    for owner status, listing, upload, and download using the configured sync
    server URL.
14. Add Mastodon-compatible OOB OAuth endpoints and client methods. Done for
    server start/complete routes, pending-login persistence, temporary
    Fediverse token revocation, bearer-token issuance, and HTTP client methods.
15. Store Cosimo bearer tokens locally and wire the GUI login flow. Done in
    the GUI: Deck > Sync > Log In starts the OOB flow, opens the
    authorisation URL, stores the returned owner/token in `cosimo.ini`,
    Deck > Sync > Log Out revokes and clears the token when possible, and
    owner status/list/upload/download calls use bearer-token ownership when
    present.
16. Add sync manual QA checklist entries. Done in `docs/qa-checklist.md`.
17. Add initial public-deployment hardening. Done for upload
    quotas, bundle validation, bounded deck-file reads, OAuth application
    caching, login-start rate limiting, pending-login limits, and obvious
    unsafe OAuth instance URL rejection.
18. Pin checked OAuth DNS results. Done: the OAuth client resolves
    the instance host, rejects unsafe resolved addresses, disables redirects,
    and uses `resolve_to_addrs` so the request uses the checked addresses while
    preserving TLS hostname verification.
19. Verify sync bundle hashes. Done: bundle hashes are SHA-256
    values over the actual bundle bytes, upload handling rejects mismatched
    declared hashes, and stored/downloaded metadata uses the verified hash.
20. Store server bearer sessions as verifier hashes. Done: the
    client still receives a bearer token, but the SQLite sync database stores a
    SHA-256 verifier and migrates old plaintext token rows on open.

Sync should remain unreleased until overwrite safety, privacy tests, and the
Windows manual sync checklist have all been exercised successfully.

## References

- Mastodon OAuth overview:
  <https://docs.joinmastodon.org/spec/oauth/>
- Mastodon OAuth scopes:
  <https://docs.joinmastodon.org/api/oauth-scopes/>
- Mastodon token types:
  <https://docs.joinmastodon.org/api/oauth-tokens/>
