API.md
Rama main · réplica de lectura del repositorio original.
# Sesame account API
This document is the complete closed request and response schema contract for
the Go API. The generated OpenAPI inventories are the source of truth for route
and method enumeration, authentication and CSRF classes, availability, and Go
handler ownership: [project](./openapi/openapi.json) for Sesame's own publishing
deployment and [operator](./openapi/openapi.operator.json) for a self-hosted
operator deployment. Regenerate both with `npm run openapi:generate` from this
directory; server CI compares them byte for byte.
The API is vault-blind. These contracts must never carry a vault file,
encrypted vault blob, vault password, TOTP seed, backup code, recovery note, or
encryption key.
Every website mutation requires the configured `Origin` plus a matching
`X-Sesame-CSRF` value obtained from `GET /v1/auth/csrf`. Browser sessions travel
in an HttpOnly, SameSite=Lax cookie; the separate CSRF cookie uses
SameSite=Strict. Those origin and CSRF checks are what protect unsafe requests.
Only account auth routes accept passwords, and those values are never logged.
JSON decoders reject unknown fields. Every response carries an `X-Request-ID`,
which support may be given, but it must never be combined with secrets or
email-action links. The server keeps an incoming `X-Request-ID` that is 16 to 64
letters, digits, hyphens or underscores and replaces any other value with a
generated one. It writes the ID to the request log line and to the error log
lines of that request.
Credential routes are budgeted twice: first by the client address the request
arrives from, then by the identity it targets. An address-keyed budget on its
own bounds one network peer rather than one account, so a caller spread across
many addresses was not bounded at all. Sign-in, reauthentication, password
change, and account deletion are additionally budgeted per account;
registration, password recovery, email verification, and email change are
additionally budgeted per email address, including the destination address of
an email change. The identity is peppered and hashed before it becomes a
limiter key. Exceeding a budget returns `429 too_many_attempts` with
`Retry-After`, except for password recovery, which keeps answering `202` so the
status code cannot reveal whether an address holds an account or recent
activity.
## Deployment profiles
`SESAME_DEPLOYMENT_PROFILE` selects what the API serves. Unset or `operator` is
the default self-hosted deployment. `project` is only for Sesame's own
publishing deployments. Any other value stops startup instead of guessing.
Under `operator`:
- The release, extension-publication, and plan administration routes, the
owner-ring user action, `POST /v1/release-candidates`, and
`GET /v1/project/activity` and `GET /v1/extensions` are not registered.
Every role, including `super`, receives `404 not_found`.
- `GET /v1/plans` returns `{plans:[]}`.
- `GET /v1/product/status` returns
`{webSignInAvailable,desktopConnectionAvailable,registrationMode,cloudSyncAvailable,updated}`.
It carries no product phase, platform list, account purpose, or download
state.
- `GET /v1/releases/latest`, `GET /v1/desktop/updates`,
`GET /v1/desktop/update-tickets/{ticket}`, `GET /v1/account/downloads`,
`POST /v1/account/download-tickets`, and `GET /v1/downloads/{ticket}` return
`503 release_artifacts_unavailable`. An operator artifact path is not
defined yet.
- `updater_enabled` and `public_download` are absent from
`GET /v1/admin/flags` and the feature flags of
`GET /v1/admin/system/config`, and `PATCH /v1/admin/flags/{key}` rejects
them. The signed capability document reports `downloads` and `updater` false.
- `GET /v1/admin/system/config` reports `deploymentProfile`.
Under `project`, the full release, publication, plan, and download surface is
registered and behaves as described below. The admin console hides Releases,
Product plans, and the owner-ring action outside `project`, and the account
portal hides its download surfaces.
## Signed capability contract
`GET /v1/capabilities` returns an ETag-cacheable envelope that holds a base64url
JSON payload, an Ed25519 signature, and a key ID. The payload is
`{schemaVersion,minimumDesktopVersion,latestDesktopVersion,features,serviceStatus,expiresAt}`.
Website and desktop builds pin the matching public key and verify the raw
payload before enabling a capability. Missing, expired, malformed, or
unverifiable configuration fails closed for desktop linking, downloads,
updater use, and Sync. The API also enforces the linking and download flags.
Unauthenticated requests return `not_authenticated`; an invalid or expired
session cookie returns `session_expired`. Both use `401`. The distinct codes let
the website clear only the stale signed-in state.
## Health
- `GET /livez` returns `200 {status,service,version,commit}`. A lightweight liveness probe
that only confirms the process is running.
- `GET /readyz` returns `200 {status,service,version,commit,accounts}` when the database is
reachable, otherwise `503`. Load balancers and deployment systems use it.
- `GET /healthz` is a deprecated alias for `/readyz`.
## Public metadata
These endpoints are read-only. They reject mutation methods and parse no request
body.
- `GET /v1/plans` returns the free app and the planned Sesame Sync subscription,
including its optional `annualPrice`.
- `GET /v1/product/status` returns current phase, platform, account, sign-in, sync,
and download availability.
- `GET /v1/project/activity` returns
`{generatedAt,windowDays,repositories:[{name,pushedAt,recentCommits}]}` for
the four Sesame repositories, counted on each default branch over the last
30 days. The API asks GitHub on start and every 15 minutes, and a request
only reads that copy. A failed refresh keeps the last good copy. Before the
first count it returns `503 project_activity_unavailable`. Project profile
only.
- `GET /v1/extensions` returns `{extensions:[{store,version?,status,listingUrl?}]}`.
A store whose newest approved, published or withdrawn registry record is
approved or published gets an entry with that version, and a withdrawn
record leaves the store out. The Chrome entry is `published` with a
`listingUrl` whenever the `browser_extension_visible` flag is on and the
server holds `SESAME_CHROME_EXTENSION_ID`, and the server builds the address
from that id. With no Chrome record it then has no `version`. Every other
entry reports `approved` with no address. It never returns admin evidence.
`GET /v1/admin/extension-publications` adds `chrome:{listingUrl,visible,public}`
so the admin can link the listing and show what the website receives.
Project profile only.
- `GET /v1/releases/latest?platform=windows|linux` returns release availability for
the platform. Stays unavailable until the full release set clears the gate:
exact-workflow Sigstore evidence for every package, and a verified Tauri
updater signature on the Windows NSIS artifact alone. `signed` reports only
the updater signature. `artifacts` lists every distributable package with
its file name, format, URL, SHA-256, and updater-signature state. Production
additionally requires verified Authenticode evidence.
- `GET /v1/security/boundaries` returns machine-readable confirmation that the API
accepts and stores no vault data or credentials.
- `GET /v1/support` returns public support availability, a safe-submission
warning, and `receiptEmail`, a boolean stating whether intake receipts are
emailed on this deployment. It never returns a staff address.
## Registration and email
- `GET /v1/auth/registration` returns
`{mode:"closed"|"invite"|"public",enabled,requiresInvite,emailDeliveryAvailable}`.
- `POST /v1/auth/register` with `{email,password,inviteCode?}` returns `202`
with an empty body. The server enforces the registration mode and consumes
eligibility/invites transactionally. An address that already has an account,
an invitation that was already used, and an eligible new address all receive
the same response. Only a new account receives a verification email, and the
outbox result does not change the response; a queued message does not mean the
upstream SMTP relay accepted it. Registration does not create a browser
session.
- `POST /v1/auth/email/verification/request` with no body returns `202`.
- `POST /v1/auth/email/verification/confirm` with `{token}` or
`{token,password}` returns `200 {user,credentialSetupRequired}` and a
replacement browser session. Verification revokes every browser session,
passkey, and desktop connection created while the account was unverified,
cancels its pending desktop-link codes, and deletes its pending email-change
tokens, in the same transaction that marks the address verified.
The mailbox proves the address, not who chose the registration password,
because anyone can register an address with a password they know. A first
verification therefore clears the registration password unless the same
request carries `password` and it matches. A matching password is kept and
`credentialSetupRequired` is false. A `password` that does not match returns
`401 incorrect_password`, changes nothing and leaves the link usable, so the
person can try again or confirm without a password to choose a new one. A
`password` for an account that has none is refused the same way. Without
`password`, `credentialSetupRequired` is true when the account has no
password and no passkey. A password over 1024 bytes returns
`400 invalid_verification`.
- `POST /v1/auth/password/recovery/request` with `{email}` returns `202`. Missing,
malformed, and known emails receive the same response shape.
- `POST /v1/auth/password/recovery/confirm` with `{token,newPassword}` returns
`200 {user,otherSessionsRevoked:true}` and a replacement session. Every
linked desktop token is revoked with the old browser sessions.
- `POST /v1/account/email/change/request` with `{newEmail}` returns `202`. An
account whose email is not verified receives `403 email_verification_required`.
- `POST /v1/account/email/change/confirm` with `{token}` returns
`200 {user,otherSessionsRevoked:true}` and a replacement session.
Every account carries a security generation. Verification of a first email
address, password change, password reset, sign out everywhere, and a completed
email change each advance
it in the same transaction that revokes the account's sessions. An email-change
token and a desktop-link code record the generation they were issued under, and
a token or code from an earlier generation is refused. A request that was
authenticated before one of those actions and reaches the database after it
receives `401 session_expired` instead of issuing a new token or code.
Verification tokens live for 24 hours. Recovery and email-change tokens live
for 30 minutes. Only SHA-256 token hashes are stored. Tokens are single-use;
creating another token for the same purpose invalidates the previous one. A
password change, password reset, sign out everywhere, or a completed email change
invalidates every pending email-change token and every unused desktop-link code,
and a completed email change invalidates every pending recovery token.
The queued action email stores its link sealed with the deployment's admin
encryption key, never in plaintext; a delivered or failed outbox row is
purged seven days after its last update.
Action emails contain links such as `/verify-email#token={token}`. The token
is in the URL fragment so it is never sent to the server in the request line,
never appears in access logs, and is not included in `Referer` headers.
Client-side code must read the fragment and submit the token in the POST body
of the confirmation endpoint.
`user` is `{id,email,emailVerified,betaAccess}`. Confirming an email change or
password recovery revokes every older browser session in the same transaction
that applies the account change. A password reset also revokes every linked
desktop token in that transaction. A completed email change also queues a
security notice to the previous address. Recovery also revokes every passkey and
desktop connection created while the account was unverified, cancels its
pending desktop-link codes, and revokes every desktop connection regardless of
when it was created. Credentials an attacker attached to an unverified account
do not survive verification or recovery. Verification without a `password`
clears the password set at registration, and a `password` that does not match
is refused with `401` and clears nothing. The address owner who did not start
that registration therefore sets a new password right after verifying, with
`POST /v1/account/password/setup` and `{newPassword}`. It returns `204`. It
requires a recently authenticated session, a verified email, and an account
that has no password yet, and it answers `409 password_already_set` otherwise.
The account portal shows this step after verification when
`credentialSetupRequired` is true, and again on the account overview while it
stays true. A passkey registered from the
same session also ends the setup state. A session older than the recent-auth
window receives `403 recent_auth_required`, and the owner then uses password
recovery.
## Recent authentication and browser sessions
Password login, passkey login, verification completion, recovery completion,
and email change completion mark a browser session as recently authenticated.
The default recent-auth window is ten minutes.
- `POST /v1/account/reauthenticate` with `{password}` returns `204`.
- `GET /v1/account/sessions` returns `{sessions:[Session]}`.
- `DELETE /v1/account/sessions` returns `204` and revokes every website session
and every linked desktop token.
- `DELETE /v1/account/sessions/{id}` returns `204`.
`Session` is
`{id,label,createdAt,lastSeenAt,authenticatedAt,expiresAt,current}`. Session
labels are coarse browser/OS names; raw User-Agent strings and IP addresses are
not stored.
Recent authentication is required before password changes, passkey add/remove,
desktop-link creation/cancellation, connected-device revocation, email changes,
and browser-session revocation. A stale request receives
`403 recent_auth_required`; the website should reauthenticate and retry the
original operation once.
`POST /v1/account/password` accepts `{currentPassword,newPassword}`. The new
password, revocation of every old session and every linked desktop token, and
creation of the replacement current session are one database transaction.
## Passkeys
- `POST /v1/account/passkey/register/begin` and `.../finish` register a
WebAuthn passkey for the signed-in account. Registration requires recent
authentication.
- `POST /v1/auth/passkey/login/begin` and `.../finish` sign in with a
discoverable passkey and mark the session recently authenticated.
- `GET /v1/account/passkeys` lists them; `DELETE /v1/account/passkeys?id=<hex>`
removes one.
A passkey authenticates the website account only. It never unlocks,
identifies, or touches a local vault, and no vault material is part of any
ceremony. If an authenticator reports a possible clone after a successful
assertion, the API records a `passkey_clone_warning` security event and
refuses the sign-in instead of creating a session.
## Account state and deletion
- `GET /v1/auth/me` returns the signed-in account id and email only.
- `GET /v1/account/bootstrap` returns the combined first-paint state the website
needs, so a signed-in page load does not fan out into several requests. Its
`security` object includes `credentialSetupRequired`.
- `GET /v1/account/activity` returns `{events:[...]}`, the account's own 50 most
recent security events.
- `GET /v1/account/notifications` returns `{securityMandatory:true,preferences}`.
`PATCH` the same path with `{betaReleases,supportReplies,productAnnouncements}`
returns `204`. `supportReplies` defaults to on for every account and can be
switched off. Security mail cannot be switched off.
- `POST /v1/account/delete` with `{password}` deletes the account. Requires
recent authentication and re-verifies the password.
## Beta access, licences, and verified private-beta downloads
- `GET /v1/account/access` returns
`{betaAccess,emailVerified,downloadsAllowed,licences:[Licence]}`.
- `GET /v1/account/downloads` returns `{releases:[Release]}`.
- `POST /v1/account/download-tickets` with
`{releaseId,platform}` and a random `Idempotency-Key` header returns
`{downloadUrl,expiresAt,releaseId,platform}`. A retry with the same key and
payload refreshes the unredeemed ticket without creating another audit event.
- `GET /v1/downloads/{ticket}` requires the same signed-in account, accepts a
ticket once, then redirects to the selected artifact. It returns `410` for a
used or expired ticket.
`Licence` is `{id,product,status,issuedAt,expiresAt?,graceEndsAt?}`. Its state
is constrained in PostgreSQL: `pending` can become `active` or `revoked`;
`active` can become `grace_period`, `expired`, or `revoked`; and
`grace_period` can recover to `active` or become `expired` or `revoked`.
Every state transition writes an internal entitlement event. A verified account
may download when it has beta access, a live active licence, or an unexpired
grace period. Provider receipt references are stored internally; Sesame never
accepts card data.
`Release` contains one immutable artifact set for a channel, platform,
architecture, and version. Each artifact records its package format,
architecture, SHA-256 digest, byte count, download location, Sigstore evidence,
and updater capability. Windows sets contain NSIS. Linux sets contain AppImage,
DEB, and RPM records. Linux records do not claim updater capability. Only
`published` sets whose complete artifact set has eligible evidence can be
returned. An `early_access` artifact remains Authenticode-unsigned;
`production` also requires verified Authenticode evidence.
Each canonical release manifest also records its architecture, monotonic
revision, rollout percentage, update-enabled state, and kill switch. The
operations control plane changes these fields transactionally and audits the
change. A kill switch excludes the release from account download eligibility
immediately; it does not require a website deployment.
Release controls use `POST` commands for publish, rollout, emergency stop, and
withdrawal. Every command includes the current manifest revision. Stale
commands return `409`; artifact fields are never accepted by these routes.
Release candidates are accepted only through `POST /v1/release-candidates`.
The release pipeline authenticates with its dedicated bearer credential, never
an admin browser session or CSRF token, and submits a schema version 3,
Ed25519-signed release set. Its digest binds every package format,
architecture, artifact hash, byte length, download URL, updater capability,
updater signature when applicable, distribution class, exact Sigstore issuer
and workflow identity, bundle hash, normalized Sigstore evidence digest, and
optional Authenticode evidence. The API recomputes the set digest and verifies
the receipt before writing all artifact records in one transaction. Sigstore
and Authenticode remain separate evidence. Neither substitutes for an updater
signature on an updater-capable package.
The exact candidate signing payload, signing-key ID, and signature are retained
with every immutable artifact in the set. Older artifact rows without that
receipt are not eligible for desktop update delivery.
Replaying the same signed candidate returns the existing release and does not
add another audit row. A request for the same channel, platform, architecture,
and version with different signed evidence returns `409`.
Artifact locations are never included in an account response. Tickets expire
after five minutes, are one-time, bound to the issuing account, release and
platform, and stored only as SHA-256 hashes. Ticket issue and redemption are
recorded in the account activity log without the raw ticket or artifact object key.
## Desktop linking and devices
- `GET /v1/account/desktop-link` returns the latest `{state,linkId?,createdAt?,expiresAt?,deviceId?,device?}`.
- `POST /v1/account/desktop-link` with no body returns
`201 {state:"pending",linkId,code,createdAt,expiresAt}`. Creating another
request cancels the previous unused code.
- `DELETE /v1/account/desktop-link` returns `204` and cancels the pending request.
- `POST /v1/desktop/link` with `{code,deviceName}` is called by the desktop app
and returns its opaque device token.
- `GET /v1/desktop/status` reports the calling device's connection.
- `POST /v1/desktop/heartbeat` with
`{appVersion,platform,architecture,updateChannel,protocolVersion,browserHelperCapable,browserHelperObserved}`
records runtime details for the calling device and returns `{device}`.
- `GET /v1/desktop/config` returns
`{minimumProtocolVersion,syncAvailable,browserHelper:{capable,lastObservedAt}}`.
- `DELETE /v1/desktop/connection` revokes the connection from the desktop.
- `GET /v1/account/devices` returns `{devices:[...]}`.
- `PATCH /v1/account/devices/{deviceId}` with `{deviceName}` returns `204` and
renames a connected desktop.
- `DELETE /v1/account/devices/{deviceId}` returns `204`.
Link states are `none`, `pending`, `connected`, or `expired`. The raw code is
returned once on creation and is never stored in recoverable form. A connected
state remains briefly so the website can show a clear success result. A
password change, a password reset, and revoking every website session delete
every linked desktop token in the same transaction. The desktop receives the
normal authorization failure and must link again.
## Desktop updates
- `GET /v1/desktop/updates?format=tauri¤tVersion=<SemVer>` requires a
linked desktop token. The Tauri dynamic-updater variant returns top-level
`{version,notes,pub_date,url,signature,candidateReceipt}` for a newer release
and `204 No Content` when no update is available. `candidateReceipt` is
`{payload,signingKeyId,signature}` and is verified by the desktop before it
trusts the version label. `url` is an opaque, account- and
device-bound ticket URL, never an artifact location. Release selection uses
the server-side owner ring when the account is enrolled; clients cannot
self-select that ring. The non-Tauri account response retains the
`{available:false}` shape. This route is retained for compatibility with
earlier account-bound desktop builds. Current desktop builds discover
updates from the release workflow's static signed manifest and do not call
this route or send a linked-desktop token for update discovery.
- `GET /v1/desktop/update-tickets/{ticket}` requires the same linked desktop
token. It redirects to the verified artifact and can be redeemed repeatedly
by that device until the 30-minute expiry, allowing HTTP range resumption.
It returns `410 update_ticket_expired` after expiry. The API stores an opaque
artifact object key and returns only a short-lived gateway URL after a ticket
has been redeemed by the linked desktop.
## Support intake
- `GET /v1/support` describes the policy.
- `POST /v1/support/requests` accepts
`{email,subject,message,category?,appVersion?,diagnosticCode?,browserIntegration?,requestId?}` and
returns `202 {requestId,status:"open"}`. `category` is one of `general`
(default), `account`, `import`, `sync`, `browser_helper`, `billing`, or
`bug`; an unrecognized value is rejected with `400 invalid_ticket_category`.
Admin ticket list and detail responses, and the signed-in account's own
ticket list and detail responses, all include the stored `category`. The
admin ticket list additionally accepts a `category` query filter using the
same allowlist.
- `GET /v1/account/support` lists requests owned by the signed-in account,
including unread staff-reply counts and close/reopen eligibility.
- `GET /v1/account/support/{id}` returns that account's user/staff conversation
and marks only that thread read.
- `POST /v1/account/support/{id}/reply` adds a text-only user follow-up.
- `POST /v1/account/support/{id}/close` closes the user's open request.
- `POST /v1/account/support/{id}/reopen` reopens a request closed by the user
within 30 days.
- `POST /v1/account/support/{id}/attach` attaches a guest request to the
signed-in account when the account is verified and its address matches the
request address, then returns the ticket. A closed guest request may still be
attached. Attaching revokes every live guest link in the same transaction.
- `POST /v1/support/access` with `{token}` returns `200 {ticket}` for a live
guest link and `POST /v1/support/access/reply` with `{token,message}` adds a
text-only follow-up and returns `201 {ticket}`. Neither route reads or sets a
session cookie, and both are origin- and CSRF-checked like the intake route.
A staff reply to a guest request queues one email that carries only a link and
a short instruction, never the reply body, subject, or reference. The link
secret is 32 random bytes stored only as a SHA-256 hash; the raw value travels
in the URL fragment and in JSON request bodies. A link is repeatable for 7 days,
issuing a newer link revokes the older ones, and closing or attaching the
request revokes every live link. Redemption resolves only the one request the
token was issued for and requires the request to stay unattached, open, and at
the same address. Unknown, expired, revoked, closed, attached, and
address-mismatched links all return the same `400 support_link_invalid` body.
Redemption is limited to 60 requests per client per minute; replies are limited
to 12 per client per hour and 20 per link per hour.
Account ticket list and detail responses carry `autoClosed`. It is `true` when
the system closed the request after 14 days without activity; the 30-day reopen
window is unchanged, and no email is sent for that close. Scheduled maintenance
deletes a closed request and its linked email outbox rows 90 days after
closure. Open, in-progress, and waiting requests are never deleted by
retention, and a waiting request with recent activity is never closed
automatically.
Guest and signed-in intake returns the reference and, when SMTP is configured,
queues a receipt to the requester that carries only the reference and the
portal link. Intake is capped per client and per recipient address. When
`SESAME_SUPPORT_NOTIFY_EMAIL` is set, a new request and a signed-in follow-up
queue one notice to that address with the reference, category, and admin
console link. Staff replies queue a notice only when the owning account's
support-reply preference is on; that enqueue shares the reply transaction, so a
failed enqueue fails the reply. No notification contains the subject or
message.
The admin ticket detail and reply responses carry a server-computed
`emailDeliveryReason` on each staff message: `delivered`, `pending`, `failed`,
`guest` (the request has no account), `mail-off` (no SMTP sender),
`opted-out` (the account turned reply email off), or `not-queued` (no outbox
row, including a preference lookup failure). A lookup failure never reports
`opted-out`. User messages carry no reason. The ticket detail response also
carries `mail: {deliveryConfigured, staffNotifyConfigured}` so support staff can
see the deployment's mail state without `system:read`. The list response and
account responses are unchanged, and neither field exposes an address or a
secret.
The intake accepts JSON only, rejects attachment fields and multipart bodies,
and refuses secret-shaped content: `key: value` assignments, the prose form of
the same thing where the value looks like a secret rather than like the rest of
a sentence, OTPAuth URLs, PEM private-key headers, long token-like strings,
unpadded base32 runs long enough to be a TOTP secret, and Sesame's own
recovery-kit shape. It does not echo submitted text. This is a guard rail, not
a guarantee: no filter recognises every secret, so the intake copy still asks
people not to send one. It is tuned to let ordinary problem reports through,
including sentences that merely mention a password.
Users should send a diagnostic code or safe request ID generated by the app,
not logs that could contain secrets. Browser-integration state is a short
allowlisted label, never an extension-installation claim.
Account support reads are scoped by both ticket ID and account ID. Public
reference numbers do not grant access. Internal notes, assignment, priority,
admin identities, email-delivery state, and audit data are never returned by
account endpoints. A guest link is a capability for one request only: it is
bound to the requester address stored at issue time and stops working on close,
attach, or a newer link.
## Sync (off until the operator turns it on)
`/v1/sync/*` exists and refuses. Every route returns `403 sync_unavailable`
while the `cloud_sync_available` runtime flag is false, which is its default.
Turning the flag on needs no code change. Sync has not had an independent
security review.
Authentication is the desktop device token (`Authorization: Sesame <token>`),
never a website session, and every route that changes state also needs a
`Sesame-Request-Signature` header from the device behind the token. The
signature covers the method, the request target, the body, the token, a
timestamp and a nonce. The service accepts a timestamp within five minutes and
refuses a nonce it has seen in the last eleven, and a request that passed
signature checks and then failed keeps its nonce spent. There is no cookie path
into any Sync handler.
- `GET /v1/sync/state` returns the vault epoch, the device list head, the
current revision and the state of the calling device.
- `POST /v1/sync/vault` creates the vault from a signed `create` device list
entry.
- `GET /v1/sync/device-list?after=N` returns signed device list entries.
- `GET /v1/sync/devices` returns device states and open pairings.
- `POST /v1/sync/pairings`, `GET /v1/sync/pairings/mine`,
`POST /v1/sync/pairings/{deviceId}/answer`,
`POST /v1/sync/pairings/mine/confirm` and `DELETE /v1/sync/pairings/{deviceId}`
relay the messages of the pairing exchange. The service cannot read them.
- `POST /v1/sync/devices` appends a signed `add` entry and the new device's key
package in one step. The service cannot produce the package.
A device's package key in the entry is either a 32 byte X25519 key or a 1216
byte X-Wing key, in unpadded base64url, and the service checks only the length.
- `POST /v1/sync/devices/remove` appends a signed `remove` entry with a new key
package for every device that stays. A device leaves the same way.
- `POST /v1/sync/devices/key` appends a signed `key` entry in which a device
replaces its own 32 byte package key with a 1216 byte X-Wing key. It changes
neither the epoch nor any key package, and the service refuses it from a device
that already has the larger key.
- `POST /v1/sync/devices/rotate` appends a signed `rotate` entry that raises the
epoch by one without removing a device, with a new key package for every other
device.
- `GET /v1/sync/key-package` and `POST /v1/sync/activate` hand a device its
package and record that it holds the key.
- `POST /v1/sync/snapshots` stores a signed header and one padded ciphertext.
`409 sync_conflict` means another device got there first, and a client never
retries by overwriting.
- `GET /v1/sync/headers?after=N` and `GET /v1/sync/snapshot` return signed
headers with receipts, and the newest ciphertext.
- `POST /v1/sync/checkpoints` stores the newest signed checkpoint of the calling
device, which names the device list head and header head it has seen and the
time. `GET /v1/sync/checkpoints` returns one checkpoint per device. The
service keeps them so devices can compare what each saw, and a client checks
them itself.
- `POST /v1/sync/reset` deletes the vault when the caller names its epoch.
The service verifies signatures and chains so that it does not store nonsense,
and a client verifies all of it again, so a lying service cannot make a device
accept anything. The service stores opaque ciphertext, signed headers, signed
device list entries and sealed device names. It does not decrypt, parse or log
`ciphertext`. It keeps the newest ciphertext that fits a 128 MiB budget per
vault, never the previous head, and at most 10,000 headers.
Receipts are signed with the capability signing key, the same key that signs
`GET /v1/capabilities`.
## Administration
`/v1/admin/*` is called only by the separate admin origin. It uses a distinct
cookie, session table, CSRF token and eight-hour TTL. Password plus TOTP is
required. There is no public admin registration; `cmd/adminctl bootstrap`
creates the first one-time setup link.
Destructive actions also require a fresh credential check: user deletion,
owner release and beta changes, suspension, session and device revocation,
feature-flag changes, release publication, rollout, emergency stop and
withdrawal, extension publication acceptance and transition, plan changes, and
administrator creation, update and deletion. The admin session must have
re-authenticated within the last five minutes. Sign-in and setup count as a
fresh re-authentication. A missing or expired check returns
`403 admin_step_up_required` and commits no change.
- `POST /v1/admin/auth/step-up` renews the re-authentication of the caller's
own admin session. Send exactly one of `password` or `code`; the code is a
current six-digit TOTP value and is rejected if its time step was already
used. Sign-in and step-up share the same counter, so a code used to sign in
cannot be reused for step-up. The pending action still enforces its role
permission separately. A wrong password or code returns
`401 invalid_admin_credentials`. Success
returns the `200` receipt `{"stepUpExpiresAt": "...", "windowSeconds": 300}`.
A successful check writes one `admin.step_up` audit entry naming the method,
and the route is rate limited per administrator and per peer.
The API exposes role-checked routes for account support, feature flags,
release metadata, product plans, administrators, aggregated system status and
the append-only audit log. Every mutation writes its audit entry in the same
database transaction as the change. Requests are decoded with a small body
limit and unknown-field rejection, and vault-shaped fields are rejected before
route decoding.
- `GET /v1/admin/system/health` returns a typed operational snapshot for
`system:read`. It reports deployed version and commit, schema and database
readiness, release pipeline and artifact delivery state, capped email outbox
pending and failed totals, and the last maintenance result. Dependency waits
stop after two seconds and return a safe status rather than config values
or credentials. Each outbox total is capped at 100.
- `GET /v1/admin/system/rate-limits` returns the current limiter counters for
`system:read`.
- `GET /v1/admin/system/config` returns the feature-flag document for
`system:read`.
- `GET /v1/admin/saved-replies` lists the saved replies for `support:read`.
`POST /v1/admin/saved-replies` creates one, and
`PATCH`/`DELETE /v1/admin/saved-replies/{id}` updates or deletes one, for
`support:manage`. A title is capped at 120 characters and a body at 8,000,
both must be non-empty, and both pass the secret-shaped-content guard before
storage. Every mutation writes its audit entry in the same transaction.
Updates and deletes return `404 admin_record_not_found` for an unknown id.