diff --git a/docs/api.md b/docs/api.md index f124a42..3081844 100644 --- a/docs/api.md +++ b/docs/api.md @@ -40,6 +40,7 @@ Non-2xx responses carry `{ "error": "", "message": "" }`. | 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 | @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/docs/openapi.json b/docs/openapi.json index 94b81c2..31c0196 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -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", @@ -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"], @@ -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." }, @@ -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": [ { @@ -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 } ] }, @@ -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"], diff --git a/selfhost/src/adapters/env.ts b/selfhost/src/adapters/env.ts index a5a2a91..75e4165 100644 --- a/selfhost/src/adapters/env.ts +++ b/selfhost/src/adapters/env.ts @@ -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 diff --git a/selfhost/src/schema-upgrade.ts b/selfhost/src/schema-upgrade.ts index 67da4a2..0fb4e24 100644 --- a/selfhost/src/schema-upgrade.ts +++ b/selfhost/src/schema-upgrade.ts @@ -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, diff --git a/worker/migrations/0014_sync_plan.sql b/worker/migrations/0014_sync_plan.sql new file mode 100644 index 0000000..a4ec310 --- /dev/null +++ b/worker/migrations/0014_sync_plan.sql @@ -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; diff --git a/worker/schema.sql b/worker/schema.sql index c997de2..adefb9f 100644 --- a/worker/schema.sql +++ b/worker/schema.sql @@ -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 diff --git a/worker/src/env.ts b/worker/src/env.ts index 3c18f5b..9a23814 100644 --- a/worker/src/env.ts +++ b/worker/src/env.ts @@ -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 diff --git a/worker/src/index.ts b/worker/src/index.ts index 11e6c0a..129432d 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -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). @@ -535,6 +536,19 @@ async function route(req: Request, env: Env, ctx?: ExecutionContext): Promise 501, client polls. @@ -827,10 +841,17 @@ async function route(req: Request, env: Env, ctx?: ExecutionContext): Promise env.SYNC_PLAN_GATE === "on"; + +// One D1 read per data-plane request would be a lot for a rule that changes a +// few times in an account's life, so warm isolates keep the plan for a minute. +// A pick through POST /account/sync-device clears this isolate's copy; another +// isolate can be up to CACHE_S behind, which only delays a switch. +const CACHE_S = 60; +const cache = new Map(); + +export function forgetSyncPlan(account: string) { + cache.delete(account); +} + +/** The account's sync plan. Paid tiers, a disabled gate and an account with no + * row all come back as "no limit". */ +export async function readSyncPlan(env: Env, auth: Auth): Promise { + if (!syncPlanOn(env) || auth.tier !== "free") return NO_LIMIT; + const now = Math.floor(Date.now() / 1000); + const hit = cache.get(auth.account); + if (hit && hit.at > now - CACHE_S) return hit.plan; + + const row = await env.DB.prepare( + `SELECT a.created_at, a.sync_until, a.sync_device, a.sync_device_at, + (SELECT COUNT(*) FROM devices d + WHERE d.account_id = a.account_id AND d.revoked_at IS NULL) AS n, + (SELECT 1 FROM devices d + WHERE d.account_id = a.account_id AND d.device_id = a.sync_device + AND d.revoked_at IS NULL) AS kept, + (SELECT 1 FROM devices d + WHERE d.account_id = a.account_id AND d.device_id = ?2 + AND d.revoked_at IS NULL) AS mine, + (SELECT d.device_id FROM devices d + WHERE d.account_id = a.account_id AND d.revoked_at IS NULL + ORDER BY d.last_seen_at DESC LIMIT 1) AS latest + FROM accounts a WHERE a.account_id = ?1`, + ).bind(auth.account, auth.device ?? null).first<{ + created_at: number; + sync_until: number | null; + sync_device: string | null; + sync_device_at: number | null; + n: number; + kept: number | null; + mine: number | null; + latest: string | null; + }>(); + if (!row) return NO_LIMIT; // legacy token with no account row: grandfathered + + const until = row.sync_until ?? row.created_at + SYNC_WEEK; + const plan: SyncPlan = { + syncUntil: until, + syncDevice: row.kept ? row.sync_device : null, + syncDeviceAt: row.kept ? row.sync_device_at : null, + devices: Number(row.n) || 0, + }; + + // Past the week with no live pick: settle one now and write it down, so the + // choice never drifts after this. The first device to sync after the week + // keeps syncing. That is usually the computer, which checks in all day while + // a phone only does when it is opened. "Most recently seen" cannot be used + // here: touchDevice() stamps the calling device just before this runs, so it + // would name the caller anyway, only less predictably. A request with no + // device id falls back to the most recently seen device. The user can change + // the pick once for free. The WHERE keeps a concurrent pick from being + // overwritten; the re-read takes whichever pick won. + const pick = row.mine ? auth.device! : row.latest; + if (now >= until && plan.syncDevice === null && pick) { + await env.DB.prepare( + `UPDATE accounts SET sync_device = ?2, sync_device_at = NULL + WHERE account_id = ?1 AND (sync_device IS NULL OR sync_device = ?3)`, + ).bind(auth.account, pick, row.sync_device).run(); + const won = await env.DB.prepare("SELECT sync_device FROM accounts WHERE account_id = ?1") + .bind(auth.account).first<{ sync_device: string | null }>(); + plan.syncDevice = won?.sync_device ?? pick; + plan.syncDeviceAt = null; + } + + cache.set(auth.account, { at: now, plan }); + return plan; +} + +/** True when this request's device may not sync under the plan. */ +export function isSyncPaused(plan: SyncPlan, device: string | undefined, now = Math.floor(Date.now() / 1000)): boolean { + if (plan.syncUntil === null || now < plan.syncUntil) return false; + if (plan.devices <= 1) return false; // one device is the free plan + // A request that does not say which device it is cannot be the kept one. + return !device || device !== plan.syncDevice; +} + +/** The 403 a paused device gets. Carries what the app needs to draw its + * "pick a device or upgrade" screen without another round trip. */ +export function syncPausedResponse(plan: SyncPlan, devices: unknown[]): Response { + return json( + { + error: "sync_paused", + message: "Free plans sync one device. Sync is paused on this one.", + sync_until: plan.syncUntil, + sync_device: plan.syncDevice, + devices, + }, + 403, + ); +} + +/** POST /account/sync-device { device_id }: pick the device that keeps + * syncing. Free accounts may switch once per SYNC_SWITCH_COOLDOWN; the first + * pick after an automatic one is always allowed. */ +export async function pickSyncDevice(req: Request, env: Env, auth: Auth): Promise { + let b: { device_id?: unknown }; + try { + b = await req.json(); + } catch { + return err(400, "bad_request", "not JSON"); + } + const id = typeof b.device_id === "string" ? b.device_id : ""; + if (!id || id.length > 64) return err(400, "bad_request", "bad device_id"); + + const row = await env.DB.prepare( + `SELECT a.sync_device, a.sync_device_at, + (SELECT 1 FROM devices d WHERE d.account_id = a.account_id + AND d.device_id = ?2 AND d.revoked_at IS NULL) AS known + FROM accounts a WHERE a.account_id = ?1`, + ).bind(auth.account, id).first<{ + sync_device: string | null; + sync_device_at: number | null; + known: number | null; + }>(); + if (!row) return err(404, "not_found", "no account"); + if (!row.known) return err(404, "not_found", "unknown or removed device"); + if (row.sync_device === id) return json({ ok: true, sync_device: id }); + + const now = Math.floor(Date.now() / 1000); + if (auth.tier === "free" && row.sync_device_at !== null && now - row.sync_device_at < SYNC_SWITCH_COOLDOWN) { + const retryAt = row.sync_device_at + SYNC_SWITCH_COOLDOWN; + return json( + { + error: "sync_switch_cooldown", + message: "You can change the syncing device once a week.", + retry_at: retryAt, + }, + 429, + ); + } + await env.DB.prepare( + "UPDATE accounts SET sync_device = ?2, sync_device_at = ?3 WHERE account_id = ?1", + ).bind(auth.account, id, now).run(); + forgetSyncPlan(auth.account); + return json({ ok: true, sync_device: id }); +} diff --git a/worker/test/account.test.ts b/worker/test/account.test.ts index 017397a..b4a3c66 100644 --- a/worker/test/account.test.ts +++ b/worker/test/account.test.ts @@ -257,6 +257,10 @@ describe("GET /account", () => { history_cap: TIERS.free.ring, evicted_count: 312, devices_cap: TIERS.free.devices, + // Free plan sync rule: gate off in the test env, so it reports nothing. + sync_until: null, + sync_device: null, + sync_paused: false, }); }); diff --git a/worker/test/helpers.ts b/worker/test/helpers.ts index b4b606e..d900c9e 100644 --- a/worker/test/helpers.ts +++ b/worker/test/helpers.ts @@ -10,7 +10,8 @@ const DDL = [ `CREATE TABLE IF NOT EXISTS accounts ( account_id TEXT PRIMARY KEY, email TEXT, tier TEXT NOT NULL DEFAULT 'free', created_at INTEGER NOT NULL DEFAULT (unixepoch()), - min_valid_iat INTEGER NOT NULL DEFAULT 0)`, + min_valid_iat INTEGER NOT NULL DEFAULT 0, + sync_until INTEGER, sync_device TEXT, sync_device_at INTEGER)`, `CREATE TABLE IF NOT EXISTS subscriptions ( account_id TEXT PRIMARY KEY, stripe_customer_id TEXT, stripe_subscription_id TEXT, tier TEXT NOT NULL DEFAULT 'free', status TEXT NOT NULL DEFAULT 'none', diff --git a/worker/test/syncplan.test.ts b/worker/test/syncplan.test.ts new file mode 100644 index 0000000..80a5f5c --- /dev/null +++ b/worker/test/syncplan.test.ts @@ -0,0 +1,239 @@ +// The free plan sync rule (src/syncplan.ts): one device keeps syncing once a +// free account's first week is over; the rest get 403 sync_paused. +import { env } from "cloudflare:test"; +import { beforeEach, describe, expect, it } from "vitest"; + +import worker from "../src/index"; +import { SYNC_SWITCH_COOLDOWN, SYNC_WEEK } from "../src/syncplan"; +import { setupSchema, sha256Hex } from "./helpers"; + +// deno-lint-ignore no-explicit-any +const E = env as any; +const ON = { ...E, SYNC_PLAN_GATE: "on" }; +const now = () => Math.floor(Date.now() / 1000); +const DAY = 86400; + +// Each test uses its own account id: the plan is cached per account for a +// minute inside the isolate, and a fresh id keeps tests from seeing each +// other's cache. +let seq = 0; +async function account(opts: { + tier?: string; + age?: number; // seconds since sign-up + syncUntil?: number | null; + devices?: Array<[id: string, lastSeenAgo: number]>; +} = {}) { + const acct = `acct-sp-${++seq}`; + const token = `tok-sp-${seq}`; + const tier = opts.tier ?? "free"; + await E.DB.prepare("INSERT INTO tokens (token_hash, account_id, tier) VALUES (?1,?2,?3)") + .bind(await sha256Hex(token), acct, tier).run(); + await E.DB.prepare( + "INSERT INTO accounts (account_id, tier, created_at, sync_until) VALUES (?1,?2,?3,?4)", + ).bind(acct, tier, now() - (opts.age ?? 8 * DAY), opts.syncUntil ?? null).run(); + for (const [id, ago] of opts.devices ?? []) { + await E.DB.prepare( + "INSERT INTO devices (account_id, device_id, platform, last_seen_at) VALUES (?1,?2,'windows',?3)", + ).bind(acct, id, now() - ago).run(); + } + return { acct, token }; +} + +function call( + token: string, + method: string, + path: string, + opts: { device?: string; body?: unknown; env?: Record; headers?: Record } = {}, +) { + const headers: Record = { Authorization: `Bearer ${token}`, ...opts.headers }; + if (opts.device) headers["X-Relic-Device"] = opts.device; + if (opts.body !== undefined) headers["Content-Type"] = "application/json"; + return worker.fetch( + new Request(`https://x${path}`, { + method, + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }), + opts.env ?? ON, + ); +} + +const envelope = (uid: string) => ({ + v: 1, uid, created_at: 1000, updated_at: 1000, byte_size: 10, promoted: false, n: "n", ct: "c", +}); + +async function syncDevice(acct: string) { + return (await E.DB.prepare("SELECT sync_device, sync_device_at FROM accounts WHERE account_id = ?1") + .bind(acct).first()) as { sync_device: string | null; sync_device_at: number | null }; +} + +beforeEach(async () => { + await setupSchema(E.DB); +}); + +describe("free plan sync rule", () => { + it("is off by default: a second device still syncs after the week", async () => { + const { token } = await account({ devices: [["pc", 10], ["phone", 600]] }); + const r = await call(token, "GET", "/relics", { device: "phone", env: E }); + expect(r.status).toBe(200); + }); + + it("lets every device sync during the free week", async () => { + const { token } = await account({ age: 2 * DAY, devices: [["pc", 10], ["phone", 600]] }); + for (const dev of ["pc", "phone"]) { + expect((await call(token, "GET", "/relics", { device: dev })).status, dev).toBe(200); + } + }); + + it("keeps a lone device syncing after the week", async () => { + const { acct, token } = await account({ devices: [["pc", 10]] }); + expect((await call(token, "GET", "/relics", { device: "pc" })).status).toBe(200); + // ...and settles it as the kept device, so a phone added later is the + // one that pauses. + expect((await syncDevice(acct)).sync_device).toBe("pc"); + }); + + it("after the week, keeps the first device to sync and pauses the rest", async () => { + const { acct, token } = await account({ devices: [["pc", 10], ["phone", 3 * DAY]] }); + expect((await call(token, "GET", "/relics", { device: "pc" })).status).toBe(200); + + const r = await call(token, "GET", "/relics", { device: "phone" }); + expect(r.status).toBe(403); + const body = await r.json<{ error: string; sync_device: string; sync_until: number; devices: unknown[] }>(); + expect(body.error).toBe("sync_paused"); + expect(body.sync_device).toBe("pc"); + expect(body.devices).toHaveLength(2); + + // The automatic pick is written down, and not as a by-hand pick. + expect(await syncDevice(acct)).toEqual({ sync_device: "pc", sync_device_at: null }); + }); + + it("keeps the first caller even when another device was seen more recently", async () => { + const { acct, token } = await account({ devices: [["pc", 10], ["phone", 3 * DAY]] }); + expect((await call(token, "GET", "/relics", { device: "phone" })).status).toBe(200); + expect((await syncDevice(acct)).sync_device).toBe("phone"); + expect((await call(token, "GET", "/relics", { device: "pc" })).status).toBe(403); + }); + + it("falls back to the most recently seen device for a request with no device id", async () => { + const { acct, token } = await account({ devices: [["pc", 10], ["phone", 3 * DAY]] }); + expect((await call(token, "GET", "/relics")).status).toBe(403); + expect((await syncDevice(acct)).sync_device).toBe("pc"); + }); + + it("covers writes, blobs and the doorbell as well as reads", async () => { + const { token } = await account({ devices: [["pc", 10], ["phone", 3 * DAY]] }); + await call(token, "GET", "/relics", { device: "pc" }); // settle the pick + const cases: Array<[string, string, unknown?, Record?]> = [ + ["PUT", "/relic/aaa", envelope("aaa")], + ["POST", "/blob?id=blob0001"], + ["GET", "/tombstones?since=0"], + ["GET", "/sync/socket", undefined, { Upgrade: "websocket" }], + ]; + for (const [method, path, body, headers] of cases) { + const r = await call(token, method, path, { device: "phone", body, headers }); + expect(r.status, `${method} ${path}`).toBe(403); + expect((await r.json<{ error: string }>()).error).toBe("sync_paused"); + } + }); + + it("leaves account routes open on a paused device", async () => { + const { token } = await account({ devices: [["pc", 10], ["phone", 3 * DAY]] }); + await call(token, "GET", "/relics", { device: "pc" }); // settle the pick + expect((await call(token, "GET", "/account/devices", { device: "phone" })).status).toBe(200); + const a = await call(token, "GET", "/account", { device: "phone" }); + expect(a.status).toBe(200); + expect(await a.json()).toMatchObject({ sync_device: "pc", sync_paused: true }); + }); + + it("never applies to paid accounts", async () => { + for (const tier of ["pro", "max"]) { + const { token } = await account({ tier, devices: [["pc", 10], ["phone", 3 * DAY]] }); + expect((await call(token, "GET", "/relics", { device: "phone" })).status, tier).toBe(200); + const a = await (await call(token, "GET", "/account", { device: "phone" })).json(); + expect(a, tier).toMatchObject({ sync_until: null, sync_device: null, sync_paused: false }); + } + }); + + it("honours a later sync_until (accounts given extra time at launch)", async () => { + const { token } = await account({ + age: 60 * DAY, + syncUntil: now() + 20 * DAY, + devices: [["pc", 10], ["phone", 3 * DAY]], + }); + expect((await call(token, "GET", "/relics", { device: "phone" })).status).toBe(200); + const a = await (await call(token, "GET", "/account", { device: "phone" })).json<{ sync_until: number }>(); + expect(a.sync_until).toBeGreaterThan(now() + 19 * DAY); + }); + + it("reports the countdown on GET /account during the week", async () => { + const { token } = await account({ age: 2 * DAY, devices: [["pc", 10]] }); + const a = await (await call(token, "GET", "/account", { device: "pc" })).json<{ + sync_until: number; sync_device: string | null; sync_paused: boolean; + }>(); + expect(a.sync_until).toBeGreaterThan(now() + 4 * DAY); + expect(a.sync_until).toBeLessThanOrEqual(now() - 2 * DAY + SYNC_WEEK); + expect(a.sync_device).toBeNull(); + expect(a.sync_paused).toBe(false); + }); + + it("re-picks when the kept device has been removed", async () => { + const { acct, token } = await account({ devices: [["pc", 10], ["phone", 3 * DAY], ["old", 9 * DAY]] }); await E.DB.prepare("UPDATE accounts SET sync_device = 'old' WHERE account_id = ?1").bind(acct).run(); + await E.DB.prepare("UPDATE devices SET revoked_at = 1 WHERE account_id = ?1 AND device_id = 'old'") + .bind(acct).run(); + expect((await call(token, "GET", "/relics", { device: "pc" })).status).toBe(200); + expect((await syncDevice(acct)).sync_device).toBe("pc"); + }); +}); + +describe("POST /account/sync-device", () => { + it("moves sync to the chosen device; the first by-hand pick is free", async () => { + const { acct, token } = await account({ devices: [["pc", 10], ["phone", 3 * DAY]] }); + await call(token, "GET", "/relics", { device: "pc" }); // automatic pick: pc + + const r = await call(token, "POST", "/account/sync-device", { device: "phone", body: { device_id: "phone" } }); + expect(r.status).toBe(200); + expect(await r.json()).toEqual({ ok: true, sync_device: "phone" }); + expect((await syncDevice(acct)).sync_device_at).toBeGreaterThan(now() - 60); + + // This isolate's cached plan was dropped, so the switch is immediate here. + expect((await call(token, "GET", "/relics", { device: "phone" })).status).toBe(200); + expect((await call(token, "GET", "/relics", { device: "pc" })).status).toBe(403); + }); + + it("allows one by-hand switch a week on free", async () => { + const { acct, token } = await account({ devices: [["pc", 10], ["phone", 3 * DAY]] }); + await E.DB.prepare("UPDATE accounts SET sync_device = 'pc', sync_device_at = ?2 WHERE account_id = ?1") + .bind(acct, now() - DAY).run(); + + const r = await call(token, "POST", "/account/sync-device", { device: "phone", body: { device_id: "phone" } }); + expect(r.status).toBe(429); + const body = await r.json<{ error: string; retry_at: number }>(); + expect(body.error).toBe("sync_switch_cooldown"); + expect(body.retry_at).toBe(now() - DAY + SYNC_SWITCH_COOLDOWN); + + // Picking the device that already syncs is a no-op, not a refusal. + expect((await call(token, "POST", "/account/sync-device", { body: { device_id: "pc" } })).status).toBe(200); + + await E.DB.prepare("UPDATE accounts SET sync_device_at = ?2 WHERE account_id = ?1") + .bind(acct, now() - SYNC_SWITCH_COOLDOWN - 1).run(); + expect((await call(token, "POST", "/account/sync-device", { body: { device_id: "phone" } })).status).toBe(200); + }); + + it("refuses an unknown or removed device", async () => { + const { acct, token } = await account({ devices: [["pc", 10], ["gone", DAY]] }); + await E.DB.prepare("UPDATE devices SET revoked_at = 1 WHERE account_id = ?1 AND device_id = 'gone'") + .bind(acct).run(); + for (const id of ["nope", "gone"]) { + expect((await call(token, "POST", "/account/sync-device", { body: { device_id: id } })).status, id).toBe(404); + } + expect((await call(token, "POST", "/account/sync-device", { body: {} })).status).toBe(400); + }); + + it("works with the gate off, so apps can pick ahead of launch", async () => { + const { acct, token } = await account({ devices: [["pc", 10], ["phone", DAY]] }); + const r = await call(token, "POST", "/account/sync-device", { body: { device_id: "phone" }, env: E }); + expect(r.status).toBe(200); + expect((await syncDevice(acct)).sync_device).toBe("phone"); + }); +});