Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Non-2xx responses carry `{ "error": "<code>", "message": "<human text>" }`.
| 401 | `session_revoked` | token predates a device removal; sign in again |
| 402 | `storage_quota` | tier storage quota exceeded (250 MB / 25 GB / 250 GB) |
| 402 | `vault_cap` | free-tier promoted-relic cap reached |
| 403 | `sync_paused` | free plan: this account's free week of sync is over and this is not the device that keeps syncing (see `POST /account/sync-device`) |
| 404 | `not_found` | unknown uid / blob key / no keyparams yet |
| 409 | `keyparams_exists` | `PUT /keyparams` without `?replace=1` when one exists |
| 413 | `too_large` | relic/blob exceeds tier per-item size cap |
Expand Down Expand Up @@ -157,7 +158,8 @@ egress). Resolved within the token's account namespace only.
### `GET /account`
→ `200 { "tier": "free|pro|max", "storage_used": n, "storage_quota": n,
"vault_count": n, "vault_cap": n|null, "history_count": n,
"history_cap": n|null, "evicted_count": n, "devices_cap": n|null }` — for
"history_cap": n|null, "evicted_count": n, "devices_cap": n|null,
"sync_until": n|null, "sync_device": s|null, "sync_paused": bool }` — for
client-side quota display. Never includes key material.

The last four are the history ring. `history_count` is unpromoted relics still
Expand All @@ -167,6 +169,13 @@ come straight back on an upgrade (always `0` when there is no ring). Clients
treat a missing field as `0` or `null`, so an older server never breaks a
newer client.

The last three are the free plan sync rule (below). `sync_until` is when the
account's multi-device sync ends, `sync_device` is the device that keeps
syncing after that, and `sync_paused` says whether the calling device is
paused. The first two are `null` and `sync_paused` is `false` whenever the
rule does not apply (paid tiers, or the rule is off on this server), which is
how a client knows not to show a countdown.

### `DELETE /account`
Full account deletion (R2 objects, D1 rows, Stripe cancel). Irreversible.
Requires a **fresh** Supabase token (`iat` within 10 minutes), else
Expand All @@ -192,6 +201,31 @@ no `iat` and are grandfathered.
only the KV `rev:` guard applies, which needs the client to send
`X-Relic-Device`.

### Free plan sync rule — `POST /account/sync-device`
Off unless the server sets `SYNC_PLAN_GATE=on`; self-hosted servers never do.
A free account syncs every device for its first week (`created_at` + 7 days,
or a later `accounts.sync_until`). After that one device keeps syncing, and
every other device gets `403 sync_paused` on the whole data plane (`/relics`,
`/relic/*`, `/tombstones`, `/waiting`, `/keyparams`, `/ai*`, `/blob*`) and on
`GET /sync/socket`. Reads are included: with reads open, a second device would
still receive everything, which is half of sync. Account, device, billing,
share and pairing routes stay open, so a paused device can still pick itself,
upgrade, or be removed. The 403 body adds `sync_until`, `sync_device` and the
device list. Paid tiers are never paused.

The kept device is the first one to sync after the week ends. That's usually
the computer, which checks in all day. A request with no `X-Relic-Device`
falls back to the most recently seen device. If the kept device is removed,
the next device to sync takes its place. Free accounts with one device are
never paused.

- `POST /account/sync-device` `{ "device_id" }` picks the device that keeps
syncing → `200 { "ok": true, "sync_device" }`. `404` for an unknown or
removed device. A free account may switch once a week (`429
sync_switch_cooldown` with `retry_at`). The first pick after an automatic
one is always allowed, and picking the current device does nothing. Works
with the rule off too, so apps can pick ahead of launch.

### Download link — `/account/send-download-link`
- `POST /account/send-download-link` (no body) — mails the desktop download
link to the signed-in identity's own address, for somebody who signed in on a
Expand Down Expand Up @@ -309,9 +343,10 @@ sends none of these headers and never answers 429.
|---|---|---|---|
| per-item size | 2 MB | 100 MB | 500 MB |
| storage quota | 250 MB | 25 GB | 250 GB |
| stream | prune unpromoted past 500 | unlimited | unlimited |
| stream | prune unpromoted past 500 (100 for accounts created from 2026-10-20) | unlimited | unlimited |
| vault | 100 promoted | unlimited | unlimited |
| devices | 3 | 10 | unlimited |
| devices that sync | all, for the first week; then 1 (when `SYNC_PLAN_GATE=on`) | all | all |

## R2 layout

Expand Down
75 changes: 71 additions & 4 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "Relic sync API",
"version": "1.3.0",
"version": "1.4.0",
"summary": "The HTTP contract between Relic clients and a Relic sync server (Relic Cloud or a self-hosted server).",
"description": "Relic is an open-source, end-to-end encrypted vault for everything you copy. This is the API its apps use to sync. It is written for people building a client or running their own server.\n\nWhat the server can and cannot do:\n\n- Every relic and blob arrives already encrypted on the device. The server stores ciphertext, enforces quotas and ordering, and never holds a key.\n- Every route except `/health`, `/stripe/plans`, `/stripe/webhook`, and the public share views needs a bearer token (see Security). There is no API key you can request; a token comes from signing in to an account or from pairing a device.\n- Because the payloads are ciphertext, this is not a data API a third party can read from. It is the sync contract, published so clients and self-hosters can be built against it.\n\nThe long-form contract with the reasoning behind each route is docs/api.md (https://relic.space/docs/api.md). The envelope and blob byte formats are docs/wire-format.md; the key derivation and sealing are docs/crypto.md. Relic's website has a separate, account-less one-time note API at relic.space/api/note that is not part of this document.\n\nSource: https://github.com/RelicSync/relic (worker/src). This file lives at docs/openapi.json in that repo and is served at https://relic.space/openapi.json.\n\nRate limits: every route except the webhook and the live-sync socket sits behind a fixed-window limiter, per account on signed-in routes and per IP on public ones. Replies from a limited route carry `RateLimit-Policy` and `RateLimit-Limit`; a 429 adds `RateLimit`, `RateLimit-Remaining`, `RateLimit-Reset` and `Retry-After`. The policies are listed under `x-rate-limits` below and in docs/api.md.\n\nVersioning and deprecation: the path carries no version; this document's `info.version` follows semver (patch for wording, minor for additive changes, major for a breaking one). Additive changes ship at any time and clients ignore fields they do not know. A breaking change ships as a new route, and the old one keeps working for at least 180 days, marked `deprecated: true` here and answering with `Deprecation`, `Sunset` and a `successor-version` link until its sunset date, after which it returns 410 `gone`. The full policy is `x-versioning-policy` below and in docs/api.md.",
"termsOfService": "https://relic.space/legal/terms",
Expand Down Expand Up @@ -722,6 +722,32 @@
}
}
},
"/account/sync-device": {
"post": {
"tags": ["account"],
"operationId": "pickSyncDevice",
"summary": "Pick the device that keeps syncing on the free plan",
"description": "Free accounts sync every device for their first week, then one device. This picks that device. A free account can switch once a week, and the first pick after an automatic one is always allowed. Picking the device that already syncs does nothing. Works whether or not the rule is on.",
"parameters": [{ "$ref": "#/components/parameters/XRelicDevice" }],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SyncDevicePick" } } } },
"responses": {
"200": {
"description": "Picked.",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/SyncDevicePicked" } } }
},
"400": { "$ref": "#/components/responses/BadRequest" },
"401": { "$ref": "#/components/responses/Unauthorized" },
"404": {
"description": "Unknown or removed device (`not_found`).",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
},
"429": {
"description": "Switched within the last week (`sync_switch_cooldown`), or too many requests (`rate_limited`).",
"content": { "application/json": { "schema": { "oneOf": [{ "$ref": "#/components/schemas/SyncSwitchCooldown" }, { "$ref": "#/components/schemas/Error" }] } } }
}
}
}
},
"/pair/start": {
"post": {
"tags": ["pairing"],
Expand Down Expand Up @@ -1455,7 +1481,7 @@
},
"Account": {
"type": "object",
"required": ["tier", "storage_used", "storage_quota", "vault_count", "vault_cap", "history_count", "history_cap", "evicted_count", "devices_cap"],
"required": ["tier", "storage_used", "storage_quota", "vault_count", "vault_cap", "history_count", "history_cap", "evicted_count", "devices_cap", "sync_until", "sync_device", "sync_paused"],
"properties": {
"tier": { "type": "string", "enum": ["free", "pro", "max"] },
"storage_used": { "type": "integer", "description": "Bytes." },
Expand All @@ -1465,7 +1491,10 @@
"history_count": { "type": "integer", "description": "Unpromoted items still in view." },
"history_cap": { "type": ["integer", "null"], "description": "The free history ring. Null on paid tiers." },
"evicted_count": { "type": "integer", "description": "Older copies held back by the ring that come straight back on an upgrade. Always 0 on paid tiers." },
"devices_cap": { "type": ["integer", "null"], "description": "Null means unlimited." }
"devices_cap": { "type": ["integer", "null"], "description": "Null means unlimited." },
"sync_until": { "type": ["integer", "null"], "description": "Unix seconds a free account's multi-device sync ends: its first week, or a later date it was given. Null when the free plan sync rule does not apply (paid tiers, or the rule is off on this server)." },
"sync_device": { "type": ["string", "null"], "description": "The device that keeps syncing after the free week. Null until the rule first applies, and whenever it does not apply." },
"sync_paused": { "type": "boolean", "description": "True when the calling device is paused by the free plan sync rule. Always false when the rule does not apply." }
},
"examples": [
{
Expand All @@ -1477,7 +1506,10 @@
"history_count": 500,
"history_cap": 500,
"evicted_count": 37,
"devices_cap": 3
"devices_cap": 3,
"sync_until": 1793664000,
"sync_device": null,
"sync_paused": false
}
]
},
Expand Down Expand Up @@ -1539,6 +1571,41 @@
}
]
},
"SyncPaused": {
"description": "403 `sync_paused`. The free plan syncs every device for an account's first week, then one device. Any data-plane route and `GET /sync/socket` can answer this to the other devices. The body carries what a client needs to offer \"sync this device instead\" or an upgrade.",
"allOf": [
{ "$ref": "#/components/schemas/Error" },
{
"type": "object",
"required": ["sync_until", "sync_device", "devices"],
"properties": {
"sync_until": { "type": "integer", "description": "Unix seconds the free week ended." },
"sync_device": { "type": ["string", "null"], "description": "The device that keeps syncing." },
"devices": { "type": "array", "items": { "$ref": "#/components/schemas/Device" } }
}
}
]
},
"SyncDevicePick": {
"type": "object",
"required": ["device_id"],
"properties": { "device_id": { "type": "string", "maxLength": 64 } }
},
"SyncDevicePicked": {
"type": "object",
"required": ["ok", "sync_device"],
"properties": { "ok": { "type": "boolean", "const": true }, "sync_device": { "type": "string" } }
},
"SyncSwitchCooldown": {
"allOf": [
{ "$ref": "#/components/schemas/Error" },
{
"type": "object",
"required": ["retry_at"],
"properties": { "retry_at": { "type": "integer", "description": "Unix seconds the next switch is allowed." } }
}
]
},
"PairingStart": {
"type": "object",
"required": ["pairing_id"],
Expand Down
1 change: 1 addition & 0 deletions selfhost/src/adapters/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
// billing is inert (the worker already early-returns without the keys).
// - No RL_* limiters -> rateLimit() fails open (proceeds).
// - VERIFY_GATE unset -> the verify-to-sync gate is off.
// - SYNC_PLAN_GATE unset -> the free plan sync rule is off.
// The result: a pure, account-less, zero-knowledge sync data plane. Enrolled
// tokens carry tier "max", whose caps (250 GB storage, 500 MB/item) are
// effectively unlimited for a personal vault; lifting them further is a one-line
Expand Down
17 changes: 17 additions & 0 deletions selfhost/src/schema-upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,23 @@ export const UPGRADES: ColumnUpgrade[] = [
column: "vault_email_at",
add: "ALTER TABLE account_usage ADD COLUMN vault_email_at INTEGER",
},
// worker/migrations/0014_sync_plan.sql. Self-host never turns the free plan
// sync rule on, but POST /account/sync-device reads these columns either way.
{
table: "accounts",
column: "sync_until",
add: "ALTER TABLE accounts ADD COLUMN sync_until INTEGER",
},
{
table: "accounts",
column: "sync_device",
add: "ALTER TABLE accounts ADD COLUMN sync_device TEXT",
},
{
table: "accounts",
column: "sync_device_at",
add: "ALTER TABLE accounts ADD COLUMN sync_device_at INTEGER",
},
];

/// Apply every upgrade this database is missing. Returns the columns it added,
Expand Down
20 changes: 20 additions & 0 deletions worker/migrations/0014_sync_plan.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- The free plan's sync rule: one device after a free first week.
--
-- Free accounts sync on every device for their first week. After that one
-- device keeps syncing and the others get 403 sync_paused (src/syncplan.ts,
-- behind SYNC_PLAN_GATE). Nothing reads these columns while the gate is off.
--
-- sync_until unix seconds free multi-device sync ends. NULL means
-- created_at + 7 days. Set by hand for accounts given extra
-- time when the rule launched.
-- sync_device the device that keeps syncing after the week. Filled
-- automatically (most recently seen device) the first time
-- the rule applies, or by POST /account/sync-device.
-- sync_device_at unix seconds the user last picked the device by hand, for
-- the once-a-week switch limit. NULL = never picked by hand.
--
-- Plain nullable columns, so this is a metadata-only ALTER with no backfill.

ALTER TABLE accounts ADD COLUMN sync_until INTEGER;
ALTER TABLE accounts ADD COLUMN sync_device TEXT;
ALTER TABLE accounts ADD COLUMN sync_device_at INTEGER;
9 changes: 8 additions & 1 deletion worker/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,14 @@ CREATE TABLE IF NOT EXISTS accounts (
-- authenticate() then rejects any access token issued before it, which
-- closes the hour an already-minted token would otherwise keep working.
-- Written only when the IdP call succeeded. 0 = never revoked.
min_valid_iat INTEGER NOT NULL DEFAULT 0
min_valid_iat INTEGER NOT NULL DEFAULT 0,
-- Free plan sync rule (migration 0014, src/syncplan.ts). sync_until
-- overrides the sign-up week (NULL = created_at + 7 days); sync_device is
-- the one device that keeps syncing after it; sync_device_at is when the
-- user last picked it by hand (NULL = never, or picked automatically).
sync_until INTEGER,
sync_device TEXT,
sync_device_at INTEGER
);

-- A Supabase identity bound to a pre-existing account (vaults that predate
Expand Down
3 changes: 3 additions & 0 deletions worker/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export interface Env {
// an unverified email; unset/"off" disables it. Reads/deletes stay open and
// legacy device-token auth is grandfathered (see syncWriteGate in index.ts).
VERIFY_GATE?: string; // var, "on" | "off"
// Free plan sync rule: "on" pauses sync on every device but one once a free
// account's first week is over (see src/syncplan.ts). Unset/"off" disables it.
SYNC_PLAN_GATE?: string; // var, "on" | "off"
}

// The minimal, idempotent envelope we put on the billing queue (and also build
Expand Down
28 changes: 28 additions & 0 deletions worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
import { sweepAbandonedMpus, sweepOrphanBlobs, sweepTombstones } from "./sweep";
import { claimAi, listAi, putAi, releaseAi } from "./ai";
import { pokeSync, SyncSocket } from "./notify";
import { isSyncPaused, pickSyncDevice, readSyncPlan, syncPausedResponse } from "./syncplan";

// Re-exported so Wrangler can bind the Durable Object class (it must be exported
// from the Worker's main module).
Expand Down Expand Up @@ -535,6 +536,19 @@ async function route(req: Request, env: Env, ctx?: ExecutionContext): Promise<Re
if (limited) return limited;
}

// Free plan sync rule (optional; off unless SYNC_PLAN_GATE=on). After an
// account's free week only one device syncs, so every other device is
// refused the whole data plane and the doorbell, reads included: with reads
// open the phone would still receive the computer's items, which is half of
// sync. Sits after the limiter so a paused client that keeps retrying is
// throttled before it costs a D1 read. See src/syncplan.ts.
if (isDataPlane(path) || path === "/sync/socket") {
const plan = await readSyncPlan(env, auth);
if (isSyncPaused(plan, auth.device)) {
return syncPausedResponse(plan, await listDevices(env, auth.account, auth.device));
}
}

// --- live-sync doorbell: authed WebSocket, forwarded to the account's DO ---
// The DO reads X-Relic-Device off this same request to skip echoing a write
// back to its originator. Absent binding (self-host) -> 501, client polls.
Expand Down Expand Up @@ -827,10 +841,17 @@ async function route(req: Request, env: Env, ctx?: ExecutionContext): Promise<Re
return await sendDownloadLink(env, auth);
}

// Pick the device that keeps syncing on the free plan (src/syncplan.ts).
if (path === "/account/sync-device" && req.method === "POST") {
const limited = await rateLimit(req, env.RL_DEVICE, "RL_DEVICE", auth.account);
return limited ?? await pickSyncDevice(req, env, auth);
}

// --- account ---
if (path === "/account" && req.method === "GET") {
const caps = TIERS[auth.tier];
const usage = await readUsage(env, auth.account);
const plan = await readSyncPlan(env, auth);
const ring = caps.ring === null
? null
: ringCap(auth.tier, await accountCreatedAt(env.DB, auth.account));
Expand All @@ -848,6 +869,13 @@ async function route(req: Request, env: Env, ctx?: ExecutionContext): Promise<Re
history_cap: ring,
evicted_count: ring === null ? 0 : usage.evicted,
devices_cap: caps.devices,
// Free plan sync rule. `sync_until` and `sync_device` are null when it
// does not apply (paid, gate off), which is how a client knows not to
// show a countdown. `sync_paused` is for the calling device and is
// false whenever the rule does not apply. Older clients ignore all three.
sync_until: plan.syncUntil,
sync_device: plan.syncDevice,
sync_paused: isSyncPaused(plan, auth.device),
});
}
// Irreversible: cancels billing + wipes all R2/D1/KV state for the account.
Expand Down
Loading
Loading