diff --git a/.gitignore b/.gitignore index 0c72982..8840c15 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ lerna-debug.log* node_modules dist +dist-firefox dist.zip dist-ssr coverage diff --git a/CLAUDE.md b/CLAUDE.md index 3ac5424..2ceb070 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,8 @@ A Manifest V3 browser extension (Chrome, Edge, Firefox) for proxy profile manage ```bash npm run build # Type-check (vue-tsc) + build for Chrome/Edge npm run build:firefox # Type-check + build for Firefox (transforms manifest) +npm run build:preview # Chrome build against the preview server (northernlight.byte.vet), fixed dev extension ID +npm run build:preview:firefox # The same for Firefox, into dist-firefox/ (so it doesn't replace the Chrome dist/) npm run dev # Vite dev server npm test # Run vitest in watch mode npm run coverage # Single run with coverage report @@ -20,6 +22,8 @@ npm run test:e2e # Build into .e2e-build/, then run all e2e tests npm run test:e2e:chromium # Chromium suite only (Playwright) npm run test:e2e:firefox # Firefox smoke suite only (Selenium + geckodriver) npm run test:e2e:typecheck # Type-check e2e/ (not covered by vue-tsc) +npm run test:e2e:live # Live sync smoke vs northernlight.byte.vet: headed, you sign in twice; never in CI +npm run sync:api-types # Regenerate src/services/sync/api.gen.ts from docs/sync-api.openapi.yaml E2E_SKIP_BUILD=1 npx playwright test -c e2e --project=chromium e2e/specs/pac.e2e.ts # Reuse the last build, one spec ``` @@ -64,7 +68,7 @@ The core complexity lives here: ### Profile system (`src/services/profile.ts`) -Profiles are stored in `chrome.storage.local` under key `"profiles"`. Types: `ProfileSimple` (proxy/pac), `ProfilePreset` (system/direct), `ProfileAutoSwitch` (rule-based routing). System profiles `DIRECT` and `SYSTEM` have fixed IDs `"direct"` and `"system"`. `onProfileUpdate(cb)` fires in every extension context with the new and old profiles. +Profiles are stored in `chrome.storage.local` under key `"profiles"`. Write them only through `saveProfile`/`saveManyProfiles`/`deleteProfile`/`updateProfiles`: they hold a Web Lock (`withLock` in `utils.ts`) across extension contexts, so the config page, PAC refresh and sync don't overwrite each other. Deleting the active profile (here or by sync) switches the proxy to Direct (`refreshProxy`). Types: `ProfileSimple` (proxy/pac), `ProfilePreset` (system/direct), `ProfileAutoSwitch` (rule-based routing). System profiles `DIRECT` and `SYSTEM` have fixed IDs `"direct"` and `"system"`. `onProfileUpdate(cb)` fires in every extension context with the new and old profiles. UI pickers share one reactive profile list (`src/components/configs/useProfileOptions.ts`) and the `ProfileOptions` dropdown items. @@ -74,6 +78,15 @@ UI pickers share one reactive profile list (`src/components/configs/useProfileOp - **`proxyverse/`** -- Our format (`version: "2025-01"`), validated with `zod/mini`. Unknown keys are stripped (so runtime fields like `pacScript.lastFetched` never reach a file), and each profile is validated on its own: an invalid one, a reserved ID (`direct`/`system`) or a repeated ID is skipped with a warning. - **`switchyomega/`** -- SwitchyOmega / ZeroOmega backups (`schemaVersion` 2, `+` profiles, maybe base64). Profile IDs are UUIDv5 of the name, so re-importing overwrites. An enabled attached rule list (`__ruleListOf_`) is merged into its switch profile; rule lists become static auto profiles. Conditions map to our rules only where the match is exact (`conditions.ts`): URL regexes to `regex` rules, host regexes to `domain` wildcards via `regex.ts` (only those with an exact wildcard equivalent). Anything else (time, weekday, host levels, other regexes) becomes a `disabled` rule holding the condition text, plus a warning. Wildcards never carry `+()[]{}^$|\`: Firefox's PAC `shExpMatch` treats them as regex syntax. +### Cloud sync (`src/services/sync/`) + +Profiles and three preferences (`theme.darkmode`, `ui.language`, `ui.actionBehavior`) sync through byte.vet. Protocol: `docs/sync-protocol.md`; wire contract: `docs/sync-api.openapi.yaml`, vendored from the byte.vet webapp repo (which owns it), with types generated into `api.gen.ts` (a unit test fails when they drift). +- **`buildEnv.ts`** -- `PROXYVERSE_ENV` at build time (`build/env.ts`): `production` (byte.vet, client `proxyverse-extension`), `northernlight` (preview server, client `proxyverse-extension-dev`), `e2e` (origin overridable at runtime via `sync.devOrigin`). Chrome dev/e2e builds carry `build/dev-extension-key.pub` as `manifest.key`, fixing the extension ID and so the OAuth redirect URI (`build/README.md`); store builds never do. `identity` is a required permission everywhere: browsers show no warning for it. +- **`auth.ts`** -- OAuth 2.1 code + PKCE via `Host.launchWebAuthFlow`, run from the Preference page (sign-in can outlive an idle service worker). Refresh tokens are single use and reuse revokes the sign-in, so refreshes hold the `proxyverse-sync-auth` lock and store the new refresh token first. +- **`engine.ts`** -- `SyncEngine.run()`: per-record last-writer-wins by HLC (`hlc.ts`). `state.local` (this device's version) vs `state.base` (last agreed with the server) decides what's dirty; there's no outbox, so pending changes survive service worker restarts. Remote changes are written storage first, then state. `records.ts` normalizes profiles with the export schema (no PAC script for URL profiles, upper-case color) and hashes canonical JSON. The engine takes a `SyncDevice` (storage) and an API factory, so unit tests run two devices against the mock store. +- **`index.ts` / `background.ts`** -- the real engine and `sync:*` messages (Sync now, first-sync choice, snapshots); the background syncs at start, after sign-in, 2 s after a local change and on the `sync:poll` alarm (15 min). +- State lives in `sync.auth`, `sync.state`, `sync.settings` (local storage, never synced). "This device only" profiles are `sync.settings.localOnly`, not a profile field. UI: `src/components/sync/` (Preference section) and the switch in `ProfileConfig.vue`. + ## Critical Gotcha: `deepClone()` must use JSON round-trip `deepClone()` in `src/services/utils.ts` uses `JSON.parse(JSON.stringify(obj))`. **Do not replace with `structuredClone()`** -- Vue's reactive Proxy objects throw `DataCloneError` under `structuredClone()`. The JSON round-trip serializes through Vue's Proxy traps and produces plain objects safe for `chrome.storage`. @@ -89,6 +102,7 @@ UI pickers share one reactive profile list (`src/components/configs/useProfileOp - Use `data-testid` selectors; Arco dropdowns, selects and toasts render in `` (helpers in `e2e/pages/arco.ts`). - `sw.restart()` stops the service worker via CDP `Target.closeTarget`. PAC refresh alarms are fired with `sw.fireAlarm()`. - Use `freshHost()` for site hosts in tests that inspect the request log (late requests of an earlier test can land in the shared log), and `freshProxyHost()` for proxy hostnames (Chrome caches proxy credentials per proxy host for the browser's lifetime). +- **Sync** runs against a fake byte.vet (`e2e/servers/syncStore.ts` logic, `syncApi.ts` HTTP), on loopback so it's never proxied: `servers.sync` (seed records, `failNext`, `issueTokens`, request log) and `servers.syncOrigin`. Helpers in `e2e/pages/sync.ts`: `useFakeByteVet` before a UI sign-in (Chromium's auth window works headless against it), `signInDirectly` to skip it. The same store is the fake server of the engine's unit tests. - Firefox smoke (`e2e/firefox/`): temporary add-ons don't get private-browsing access (required by `proxy.settings`), and WebDriver can't open `moz-extension://` URLs. The driver grants the permission and opens pages from Marionette's chrome context, which needs geckodriver ≥ 0.36 with `--allow-system-access`. ## Firefox build differences @@ -98,6 +112,8 @@ The `vite.config.ts` `TRANSFORMER_CONFIG` rewrites `manifest.json` at build time - Removes `version_name` - Adds `browser_specific_settings.gecko` (minimum version 115, for `storage.session`) +The manifest transform lives in `build/manifest.ts` (pure, unit-tested in `tests/build/`). + ## Path alias `@/` maps to `src/` (configured in both `vite.config.ts` and `tsconfig.json`). diff --git a/build/README.md b/build/README.md new file mode 100644 index 0000000..5f927a8 --- /dev/null +++ b/build/README.md @@ -0,0 +1,37 @@ +# build/ + +Build-time helpers imported by `vite.config.ts`: + +- `env.ts` -- the build environments chosen by `PROXYVERSE_ENV` + (`production`, the default; `northernlight`; `e2e`) and the sync settings + each one compiles in. +- `manifest.ts` -- turns the root `manifest.json` into a build's manifest. + +## Dev extension key + +`dev-extension-key.pub` is the public half of an RSA key (base64 DER +SubjectPublicKeyInfo). Chrome builds with `PROXYVERSE_ENV=northernlight` or +`e2e` put it in `manifest.key`, so that unpacked builds always get the same +extension ID, and so the same OAuth redirect URI, registered on the +`proxyverse-extension-dev` client. **Dev only**: production builds never +carry a key (the stores assign the ID), and the private key is not kept +anywhere -- Chrome doesn't need it for unpacked extensions. + +| | | +|---|---| +| Chrome/Edge dev extension ID | `ohbdfegnjlfoohcaiedlpbmcacohacmg` | +| Chrome/Edge dev redirect URI | `https://ohbdfegnjlfoohcaiedlpbmcacohacmg.chromiumapp.org/oauth` | +| Firefox redirect URI | `https://1e603ec5914c06cbcc280eb4c99adc6034a2cdff.extensions.allizom.org/oauth` | + +The Chrome ID is the first 32 hex digits of SHA-256 of the DER key, with +`0-f` mapped to `a-p`. Firefox derives its redirect URL from the add-on ID +(`proxyverse@byte.vet`, the same in every build): the hex SHA-1 of the ID, +under `extensions.allizom.org` (`identity.getRedirectURL()`, see +`toolkit/components/extensions/child/ext-identity.js`). + +To recompute them: + +```sh +base64 -d -i build/dev-extension-key.pub | shasum -a 256 | cut -c1-32 | tr 0-9a-f a-p +printf %s proxyverse@byte.vet | shasum -a 1 +``` diff --git a/build/dev-extension-key.pub b/build/dev-extension-key.pub new file mode 100644 index 0000000..b1b891e --- /dev/null +++ b/build/dev-extension-key.pub @@ -0,0 +1 @@ +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtCGr3XRCvuGwix6pIJHWaB6w7bJDoQ5PgNkg84XTEwOSExGa82atNHKCvfES2zWy+k6nmj9oHnsmcknqcb0HeDr7P9lKTArhVwlV8zD0OlJAdZ6epZz8TqNgpv/rYOSkBcuXhGeOvHM2KsWFrp2ayn8xsDDUPvu+eqAq5aetG5WfX2lPfGP7kO4GOW4+sf8QsDAi5RMiVCNOnJllJfceKPeBUTrAhZM/uoJFkEISbK8TQb42FUS2JDyuNu5wQ1Do+xYoyhEwi5QFGP+vuaNZFksT6StYrbkLbQjQQCaUGhCL8riwp7nyPdUQjo0jumsr7KXuKGY/1SwerdEDJzIaVwIDAQAB diff --git a/build/env.ts b/build/env.ts new file mode 100644 index 0000000..39dffa1 --- /dev/null +++ b/build/env.ts @@ -0,0 +1,55 @@ +// Build environments, chosen by PROXYVERSE_ENV when the extension is built. +// They set the byte.vet origin and OAuth client the sync feature talks to +// (read at runtime by src/services/sync/buildEnv.ts) and a few manifest +// details (build/manifest.ts). Store builds are always "production". + +export type BuildEnv = "production" | "northernlight" | "e2e"; + +/** The sync server and OAuth client a build talks to. */ +export type SyncTarget = { + /** The byte.vet origin serving OAuth and the sync API, no trailing slash. */ + origin: string; + clientID: string; + /** Whether the origin may be overridden at runtime (e2e only). */ + originOverride: boolean; +}; + +export const BUILD_ENVS: Record = { + production: { + origin: "https://byte.vet", + clientID: "proxyverse-extension", + originOverride: false, + }, + northernlight: { + origin: "https://northernlight.byte.vet", + clientID: "proxyverse-extension-dev", + originOverride: false, + }, + e2e: { + // placeholder: the e2e suite sets the real origin at runtime + origin: "http://127.0.0.1", + clientID: "proxyverse-extension-dev", + originOverride: true, + }, +}; + +/** Parse PROXYVERSE_ENV; unset or empty means "production". */ +export function parseBuildEnv(value: string | undefined): BuildEnv { + if (!value) return "production"; + if (Object.hasOwn(BUILD_ENVS, value)) return value as BuildEnv; + throw new Error( + `Unknown PROXYVERSE_ENV "${value}"; expected one of: ${Object.keys( + BUILD_ENVS + ).join(", ")}` + ); +} + +/** Vite `define` entries for src/services/sync/buildEnv.ts. */ +export function syncDefines(env: BuildEnv): Record { + const { origin, clientID, originOverride } = BUILD_ENVS[env]; + return { + __SYNC_ORIGIN__: JSON.stringify(origin), + __SYNC_CLIENT_ID__: JSON.stringify(clientID), + __SYNC_ORIGIN_OVERRIDE__: JSON.stringify(originOverride), + }; +} diff --git a/build/manifest.ts b/build/manifest.ts new file mode 100644 index 0000000..6e8ae3e --- /dev/null +++ b/build/manifest.ts @@ -0,0 +1,93 @@ +// The manifest.json of a build, from the one at the repo root. Pure, so it +// can be unit-tested (tests/build/manifest.test.ts). +// +// Rolldown (Vite 8) panics on nested member expressions such as +// `manifest.background.service_worker` while transforming the config and +// the modules it imports, so this module only reads one level deep at a +// time, hoisting nested objects into locals. + +import type { BuildEnv } from "./env.ts"; + +export type Manifest = Record; + +export type BuildManifestOptions = { + mode: "chrome" | "firefox"; + env: BuildEnv; + /** The CRX version, e.g. "1.2.3" or "1.2.3-beta" (see `crxVersion`). */ + version: string; + /** The built background script's file name. */ + serviceWorker: string; + /** + * The dev public key (base64 DER), giving unpacked Chrome builds a fixed + * extension ID. Required for Chrome northernlight and e2e builds. + */ + devKey?: string; +}; + +const GECKO_ID = "proxyverse@byte.vet"; + +/** The CRX version from CRX_VER (e.g. a "v1.2.3" tag). */ +export function crxVersion(crxVer: string | undefined): string { + if (!crxVer) return "0.0.0-dev"; + const ver = crxVer.startsWith("v") ? crxVer.slice(1) : crxVer; + return ver.slice(0, 14); +} + +/** Envs whose Chrome builds carry the dev key. */ +export function needsDevKey(mode: "chrome" | "firefox", env: BuildEnv) { + return mode === "chrome" && env !== "production"; +} + +const strings = (v: unknown): string[] => + Array.isArray(v) ? v.map(String) : []; + +export function buildManifest( + base: Manifest, + opts: BuildManifestOptions +): Manifest { + const { mode, env, version, serviceWorker, devKey } = opts; + const manifest: Manifest = JSON.parse(JSON.stringify(base)); + const background = { ...(manifest.background as Record) }; + let permissions = strings(manifest.permissions); + + manifest.version = version.split("-", 1)[0]; + manifest.version_name = version; + // the hashed file name avoids cache issues + if (mode === "firefox") background.scripts = [serviceWorker]; + else background.service_worker = serviceWorker; + + if (mode === "firefox") { + delete background.service_worker; + + delete manifest.version_name; + + // Firefox has a sidebar instead of Chrome's side panel + delete manifest.side_panel; + permissions = permissions.filter((p) => p !== "sidePanel"); + manifest.sidebar_action = { + default_panel: "index.html#/sidebar", + default_icon: "mini-logo.png", + open_at_install: false, + }; + + manifest.browser_specific_settings = { + gecko: { + id: GECKO_ID, + strict_min_version: "115.0", + }, + }; + } + + delete manifest.key; + if (needsDevKey(mode, env)) { + if (!devKey) { + throw new Error(`A ${env} Chrome build needs the dev key (devKey)`); + } + manifest.key = devKey; + } + + manifest.background = background; + manifest.permissions = permissions; + + return manifest; +} diff --git a/components.d.ts b/components.d.ts index 22e81b5..cad447e 100644 --- a/components.d.ts +++ b/components.d.ts @@ -36,6 +36,9 @@ declare module 'vue' { ALayoutHeader: typeof import('@arco-design/web-vue')['LayoutHeader'] ALayoutSider: typeof import('@arco-design/web-vue')['LayoutSider'] ALink: typeof import('@arco-design/web-vue')['Link'] + AList: typeof import('@arco-design/web-vue')['List'] + AListItem: typeof import('@arco-design/web-vue')['ListItem'] + AListItemMeta: typeof import('@arco-design/web-vue')['ListItemMeta'] AMenu: typeof import('@arco-design/web-vue')['Menu'] AMenuItem: typeof import('@arco-design/web-vue')['MenuItem'] AModal: typeof import('@arco-design/web-vue')['Modal'] @@ -46,6 +49,7 @@ declare module 'vue' { ARadioGroup: typeof import('@arco-design/web-vue')['RadioGroup'] ASelect: typeof import('@arco-design/web-vue')['Select'] ASpace: typeof import('@arco-design/web-vue')['Space'] + ASwitch: typeof import('@arco-design/web-vue')['Switch'] ATable: typeof import('@arco-design/web-vue')['Table'] ATag: typeof import('@arco-design/web-vue')['Tag'] ATextarea: typeof import('@arco-design/web-vue')['Textarea'] @@ -66,6 +70,7 @@ declare module 'vue' { RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] ScriptInput: typeof import('./src/components/configs/ScriptInput.vue')['default'] + SyncSection: typeof import('./src/components/sync/SyncSection.vue')['default'] ThemeSwitcher: typeof import('./src/components/controls/ThemeSwitcher.vue')['default'] } } diff --git a/docs/sync-api.openapi.yaml b/docs/sync-api.openapi.yaml new file mode 100644 index 0000000..c802828 --- /dev/null +++ b/docs/sync-api.openapi.yaml @@ -0,0 +1,431 @@ +openapi: 3.1.0 +info: + title: Proxyverse Sync API + version: "1" + description: | + Settings sync for the Proxyverse browser extension, hosted by byte.vet. + Normative behavior: `proxyverse/docs/sync-protocol.md` (the "spec"). + Wire types on the server: `src/lib/proxyverse/types.ts`. + + Every request carries `Authorization: Bearer ` (scope + `sync`), `X-Proxyverse-Device` and `X-Proxyverse-Version`. + + **Devices.** An unknown `X-Proxyverse-Device` is registered implicitly on + its first authenticated call; `PUT /devices/{deviceID}` only adds + metadata. A device removed with `DELETE /devices/{deviceID}` is revoked: + every later request from it gets `403 device_revoked`. + + **Errors** are RFC 9457 problem details (`application/problem+json`) with + a stable `code` (spec §4.5). `429 rate_limited` and `503 unavailable` + carry `Retry-After`; `426 client_too_old` means the extension version is + below the server's minimum. +servers: + - url: https://byte.vet/api/proxyverse/v1 + - url: https://northernlight.byte.vet/api/proxyverse/v1 + description: Preview +security: + - oauth2: [sync] + +paths: + /devices: + get: + operationId: listDevices + summary: List the account's devices + parameters: + - $ref: "#/components/parameters/Device" + - $ref: "#/components/parameters/Version" + responses: + "200": + description: Devices, including the caller. + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/Device" } + default: { $ref: "#/components/responses/Problem" } + + /devices/{deviceID}: + parameters: + - $ref: "#/components/parameters/DeviceID" + - $ref: "#/components/parameters/Device" + - $ref: "#/components/parameters/Version" + put: + operationId: putDevice + summary: Register or update a device (spec §4.1) + description: Idempotent upsert; also updates `lastSeen`. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/DeviceInput" } + responses: + "200": + description: The stored device. + content: + application/json: + schema: { $ref: "#/components/schemas/Device" } + default: { $ref: "#/components/responses/Problem" } + delete: + operationId: revokeDevice + summary: Remove (revoke) a device + description: The device's future requests get `403 device_revoked`. + responses: + "204": { description: Revoked. } + default: { $ref: "#/components/responses/Problem" } + + /changes: + get: + operationId: getChanges + summary: Pull changes (spec §4.2) + description: | + Every record (including tombstones) with `rev > since`, ordered by + `rev`. `since=0` returns the full current state. A `since` older than + the tombstone horizon gets `410 cursor_expired`; do a full resync. + parameters: + - $ref: "#/components/parameters/Device" + - $ref: "#/components/parameters/Version" + - name: since + in: query + required: true + schema: { type: integer, minimum: 0 } + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 1000, default: 500 } + responses: + "200": + description: A page of changes. + content: + application/json: + schema: { $ref: "#/components/schemas/ChangesResult" } + default: { $ref: "#/components/responses/Problem" } + + /records: + post: + operationId: pushRecords + summary: Push records (spec §4.3) + description: | + Each record is processed on its own, in order (no batch atomicity). + Conflicts are last-writer-wins on `hlc`; a losing record is rejected + as `stale` with the stored record in `current`. Per-record failures + never fail the request. + + A record that would push the account over its quota (2,000 records / + 8 MiB) is rejected on its own as `quota_exceeded` (spec §4.5) instead + of the whole request failing. + + The request body is limited to 2 MiB. + parameters: + - $ref: "#/components/parameters/Device" + - $ref: "#/components/parameters/Version" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [records] + properties: + records: + type: array + minItems: 1 + maxItems: 100 + items: { $ref: "#/components/schemas/PushRecord" } + responses: + "200": + description: Per-record outcome. + content: + application/json: + schema: { $ref: "#/components/schemas/PushResult" } + default: { $ref: "#/components/responses/Problem" } + + /snapshots: + parameters: + - $ref: "#/components/parameters/Device" + - $ref: "#/components/parameters/Version" + get: + operationId: listSnapshots + summary: List snapshots (spec §4.4) + responses: + "200": + description: Snapshots, newest first. + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/SnapshotMeta" } + default: { $ref: "#/components/responses/Problem" } + post: + operationId: createSnapshot + summary: Create a snapshot now + description: >- + Snapshots the current live records. Send + `{ "reason": "before-overwrite" }` before "Upload this device" on first + sign-in (spec §5.7); with no body, or any other reason, the snapshot is + recorded as `manual`. + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + reason: + type: string + enum: [manual, before-overwrite] + responses: + "201": + description: The new snapshot (`reason` = `before-overwrite` or `manual`). + content: + application/json: + schema: { $ref: "#/components/schemas/SnapshotMeta" } + default: { $ref: "#/components/responses/Problem" } + + /snapshots/{id}: + parameters: + - $ref: "#/components/parameters/SnapshotID" + - $ref: "#/components/parameters/Device" + - $ref: "#/components/parameters/Version" + get: + operationId: getSnapshot + summary: Get a snapshot's records (for previewing a restore) + responses: + "200": + description: Snapshot contents. + content: + application/json: + schema: + type: object + required: [records] + properties: + records: + type: array + items: { $ref: "#/components/schemas/SyncRecord" } + default: { $ref: "#/components/responses/Problem" } + + /snapshots/{id}/restore: + parameters: + - $ref: "#/components/parameters/SnapshotID" + - $ref: "#/components/parameters/Device" + - $ref: "#/components/parameters/Version" + post: + operationId: restoreSnapshot + summary: Restore a snapshot (spec §6.3) + description: | + Takes a `before-restore` snapshot, then writes every record of the + snapshot and a tombstone for every live record absent from it, as new + revisions so every device pulls them as ordinary changes. All of them + carry the HLC `max(stored T + 1, now):0000:server` and the restoring + device's `deviceID`. Restore never rewinds `rev`. + responses: + "200": + description: Restore result. + content: + application/json: + schema: + type: object + required: [rev, written] + properties: + rev: + { + type: integer, + description: Account rev after the restore., + } + written: { type: integer, description: Records written. } + default: { $ref: "#/components/responses/Problem" } + +components: + securitySchemes: + oauth2: + type: oauth2 + description: Authorization code with PKCE (S256), public client. + flows: + authorizationCode: + authorizationUrl: https://byte.vet/api/auth/oauth2/authorize + tokenUrl: https://byte.vet/api/auth/oauth2/token + refreshUrl: https://byte.vet/api/auth/oauth2/token + scopes: + sync: Read and write Proxyverse sync data. + + parameters: + Device: + name: X-Proxyverse-Device + in: header + required: true + description: This device's id. Unknown ids are registered implicitly. + schema: { type: string, format: uuid } + Version: + name: X-Proxyverse-Version + in: header + required: true + description: Extension version, e.g. `2.3.0`. Below the server minimum → `426 client_too_old`. + schema: { type: string } + DeviceID: + name: deviceID + in: path + required: true + schema: { type: string, format: uuid } + SnapshotID: + name: id + in: path + required: true + schema: { type: string } + + responses: + Problem: + description: | + Error (spec §4.5). 400 `invalid_request`, 401 `invalid_token`, + 403 `device_revoked`, 404 `not_found`, 410 `cursor_expired`, + 413 `quota_exceeded`, 426 `client_too_old`, 429 `rate_limited`, + 503 `unavailable`. + headers: + Retry-After: + description: Seconds to wait; sent with 429 and 503. + schema: { type: integer } + content: + application/problem+json: + schema: { $ref: "#/components/schemas/Problem" } + + schemas: + RecordID: + type: string + description: | + `profile:` or `pref:` (`theme.darkmode`, + `ui.language`, `ui.actionBehavior`). At most 200 UTF-8 bytes. + `profile:direct` and `profile:system` are reserved. + pattern: "^(profile:.+|pref:.+)$" + HLC: + type: string + description: "Hybrid logical clock `TTTTTTTTTTTTTTT:CCCC:DEVICEID` (spec §3.3); compares lexicographically." + pattern: "^\\d{15}:\\d{4}:[A-Za-z0-9-]{1,64}$" + SyncRecord: + type: object + required: [id, rev, hlc, deviceID, deleted, schema, encoding, data] + properties: + id: { $ref: "#/components/schemas/RecordID" } + rev: { type: integer, minimum: 1, description: Server-assigned. } + hlc: { $ref: "#/components/schemas/HLC" } + deviceID: { type: string } + deleted: { type: boolean } + schema: { type: string, examples: ["2025-01"] } + encoding: { type: string, examples: ["json"] } + data: + description: Payload; `null` for tombstones. Opaque when `encoding` is not `json`. + PushRecord: + type: object + required: [id, hlc, deleted, schema, encoding, data] + properties: + id: { $ref: "#/components/schemas/RecordID" } + baseRev: + type: integer + minimum: 0 + description: Informational; the conflict decision uses only `hlc`. + hlc: { $ref: "#/components/schemas/HLC" } + deleted: { type: boolean } + schema: + type: string + description: Payload schema version; unknown versions are rejected (`unknown_schema`) unless `encoding` is not `json`. + encoding: + type: string + description: "`json` is validated; other encodings are stored opaquely (spec §3.4)." + data: + description: Payload (≤ 256 KiB as JSON); must be `null` when `deleted`. + RejectReason: + type: string + enum: + [ + stale, + invalid_record, + reserved_id, + too_large, + clock_skew, + unknown_schema, + quota_exceeded, + ] + PushResult: + type: object + required: [accepted, rejected] + properties: + accepted: + type: array + items: + type: object + required: [id, rev] + properties: + id: { type: string } + rev: { type: integer } + rejected: + type: array + items: + type: object + required: [id, reason] + properties: + id: { type: string } + reason: { $ref: "#/components/schemas/RejectReason" } + current: + $ref: "#/components/schemas/SyncRecord" + description: The stored record; present for `stale`. + ChangesResult: + type: object + required: [records, cursor, hasMore] + properties: + records: + type: array + items: { $ref: "#/components/schemas/SyncRecord" } + cursor: { type: integer, description: Pass as `since` next time. } + hasMore: { type: boolean } + Device: + type: object + required: [id, name, browser, extVersion, createdAt, lastSeen] + properties: + id: { type: string } + name: { type: [string, "null"] } + browser: { type: [string, "null"] } + extVersion: { type: [string, "null"] } + createdAt: { type: integer, description: Unix ms. } + lastSeen: { type: integer, description: Unix ms. } + DeviceInput: + type: object + properties: + name: { type: string, examples: ["Chrome on macOS"] } + browser: { type: string, examples: ["chrome"] } + extVersion: { type: string, examples: ["2.3.0"] } + SnapshotMeta: + type: object + required: [id, createdAt, reason, rev, recordCount, createdBy] + properties: + id: { type: string } + createdAt: { type: integer, description: Unix ms. } + reason: + type: string + enum: [daily, manual, before-restore, before-overwrite] + rev: { type: integer } + recordCount: { type: integer } + createdBy: + type: string + description: Device id, or `server` for automatic snapshots. + Problem: + type: object + description: RFC 9457 problem details; extra members may appear. + required: [type, title, status, code] + properties: + type: + type: string + format: uri + examples: ["https://byte.vet/docs/proxyverse-sync#cursor_expired"] + title: { type: string } + status: { type: integer } + code: + type: string + enum: + [ + invalid_request, + invalid_token, + device_revoked, + not_found, + cursor_expired, + quota_exceeded, + client_too_old, + rate_limited, + unavailable, + ] + detail: { type: string } diff --git a/docs/sync-protocol.md b/docs/sync-protocol.md new file mode 100644 index 0000000..7cd5b43 --- /dev/null +++ b/docs/sync-protocol.md @@ -0,0 +1,496 @@ +# Proxyverse Sync Protocol — v1 (draft) + +Status: design only, nothing implemented. Server: `https://byte.vet`. Client: the +Proxyverse extension (Chrome, Edge, Firefox). + +## 0. Decisions (from the design interview) + +| Topic | Decision | +|---|---| +| What syncs | User profiles and preferences. The active profile does **not** sync. | +| Per-device opt-out | A profile can be marked "this device only"; preferences have a per-device toggle. | +| Secrets | Proxy credentials sync in the payload. Protected by TLS + server-side encryption at rest. byte.vet holds the keys (not end-to-end) and uses the data only for sync. | +| Conflicts | Per-record last-writer-wins (LWW) ordered by a hybrid logical clock (HLC), with tombstones. | +| Trigger | Push on local change (debounced) + pull on start, sign-in and a periodic alarm. No server push channel. | +| Auth | byte.vet is the OAuth 2.1 authorization server. Authorization Code + PKCE through `identity.launchWebAuthFlow`. | +| PAC URL profiles | Sync `sourceURL` and interval; each device fetches the script itself. | +| History | Server keeps account snapshots for restore. | +| First sign-in with data on both sides | Ask: merge, keep cloud, or upload local. | + +Non-goals for v1: real-time push, field-level merge inside a profile, end-to-end +encryption (the wire format reserves room for it, see §3.4), sharing profiles +between accounts. + +## 1. Concepts + +- **Account** — a byte.vet user. Owns one *record store*. +- **Device** — one extension install. Identified by `deviceID`, a random UUIDv4 + created on first sign-in and kept in `storage.local` (never synced). +- **Record** — the unit of sync and of conflict resolution. One profile or one + preference key is one record. +- **rev** — a server-assigned, account-wide, strictly increasing integer. Every + accepted write gets the next `rev`. A client's **cursor** is the highest `rev` + it has applied. +- **HLC** — the record's logical edit time, assigned by the device that made the + edit. Decides which write wins. +- **Tombstone** — a record with `deleted: true` and no data. + +## 2. Authentication + +### 2.1 Discovery + +`GET https://byte.vet/.well-known/oauth-authorization-server` (RFC 8414) returns +`authorization_endpoint`, `token_endpoint`, `revocation_endpoint`, +`userinfo_endpoint` and `scopes_supported`. The issuer is +`https://byte.vet/api/auth`, so the endpoints are +`/api/auth/oauth2/{authorize,token,revoke,userinfo}`. The client must not +hard-code endpoint paths other than the discovery URL. + +### 2.2 Client registration + +The extension is a **public client** (no secret, `token_endpoint_auth_method +none`, PKCE S256 required). Production uses `client_id` `proxyverse-extension`; +the preview server (`northernlight.byte.vet`) uses `proxyverse-extension-dev`. +Redirect URIs registered: + +| Build | Redirect URI | +|---|---| +| Chrome Web Store | `https://.chromiumapp.org/oauth` | +| Edge Add-ons | `https://.chromiumapp.org/oauth` | +| Firefox AMO | `https://.extensions.allizom.org/oauth` (from `browser.identity.getRedirectURL()`) | +| Dev builds | A fixed ID from a pinned dev key, registered on `proxyverse-extension-dev` | + +The server must match redirect URIs exactly. + +### 2.3 Sign-in flow + +1. Generate `code_verifier` (43–128 chars), `code_challenge = BASE64URL(SHA256(verifier))`, and a random `state`. +2. `identity.launchWebAuthFlow({ url: authorize?response_type=code&client_id&redirect_uri&scope=openid email offline_access sync&code_challenge&code_challenge_method=S256&state, interactive: true })`. It runs from the extension page, not the service worker: signing in can take minutes, longer than an idle service worker lives. +3. Verify `state`; `POST token_endpoint` with `grant_type=authorization_code`, `code`, `redirect_uri`, `client_id`, `code_verifier`. +4. Response: `access_token` (opaque, 1 h), `refresh_token` (rotating, single + use, each rotation gives a fresh 90 days), `expires_in`, `scope`. No + `id_token` is issued to the public client. +5. `GET userinfo_endpoint` with the access token gives the account `email` for the UI. + +Scopes: `sync` (read + write the caller's own record store and snapshots), +`offline_access` (without it there is no refresh token), `openid email` (for +userinfo). + +### 2.4 Token handling + +- Tokens are stored in `storage.local` under `sync.auth`. Only the background + service worker calls the sync API; UI pages ask it via messages. +- Before each request, refresh if the access token expires within 60 s. +- Refresh tokens rotate: persist the new one before using the new access token. + Reusing a spent refresh token (outside a short replay window) revokes the + whole token family, so refreshes are serialized across extension contexts + (Web Lock `sync-auth`). If a refresh returns `invalid_grant`, enter the + *signed-out (expired)* state and ask the user to sign in again. Local data + is kept. +- Sign-out: `POST revocation_endpoint` form-encoded + `token=&token_type_hint=refresh_token&client_id=` (best + effort; it also revokes that token's access tokens), then delete + `sync.auth` and `sync.state`. Local profiles stay. + +### 2.5 Permissions + +`identity` is new, a required permission on every browser. It carries no +permission warning, so it shows nothing at install, and an update adding it +shouldn't disable the extension for re-approval (browsers do that only for +permissions with a warning). Requested as an optional permission at sign-in +instead, Chrome would show a vague "wants to access your content" prompt. API calls to +`https://byte.vet/*` are already covered by `host_permissions: `. + +## 3. Data model + +### 3.1 Record + +```jsonc +{ + "id": "profile:3f2a…", // ":", see 3.2 + "rev": 1842, // server-assigned, read-only for clients + "hlc": "001730023456789:0003:7b1e…", // see 3.3 + "deviceID": "7b1e…", // device that made this edit + "deleted": false, + "schema": "2025-01", // payload schema version, see 3.5 + "encoding": "json", // reserved for future E2E, see 3.4 + "data": { … } // payload; null when deleted +} +``` + +### 3.2 Record kinds + +| `id` | Source in `storage.local` | Payload | +|---|---|---| +| `profile:` | `profiles[profileID]` | One profile in the export format (§3.6). | +| `pref:theme.darkmode` | `theme.darkmode` | `{ "value": }` | +| `pref:ui.language` | `ui.language` | `{ "value": "auto" \| }` | +| `pref:ui.actionBehavior` | `ui.actionBehavior` | `{ "value": "popup" \| "sidebar" }` | + +Preference payloads: `theme.darkmode` `{value: 0|1|2}`, `ui.language` +`{value: "auto" | /^[a-z]{2,3}(_[A-Z]{2})?$/}` (lenient, so a new locale needs +no schema bump), `ui.actionBehavior` `{value: "popup"|"sidebar"}`. Any other +`pref:` key is `invalid_record`. Tombstones must have `data: null`. + +Never synced: `active-profile`, `tabstats:*`, `sync.*`, and the reserved +profiles `direct` / `system` (server rejects them). New preference keys are +added to this table explicitly; nothing syncs by default. + +A record id is at most 200 bytes. `profileID` is used as-is, so profiles +imported from SwitchyOmega (UUIDv5 of the name) converge across devices. + +### 3.3 Hybrid logical clock + +Format: `TTTTTTTTTTTTTTT:CCCC:DEVICEID` + +- `T` — milliseconds since epoch, zero-padded to 15 digits. +- `C` — counter, zero-padded to 4 decimal digits. +- `DEVICEID` — the editing device's id (tie-breaker). + +Strings compare lexicographically, so `a > b` is a plain string compare. + +Device rules (persist the last HLC in `sync.state.clock`): + +- **Local edit:** `T = max(now, last.T)`; `C = (T == last.T) ? last.C + 1 : 0`. +- **Receiving a record:** `last = max(last, received.hlc)` before the next + local edit, so a device never issues an HLC lower than one it has seen. + +Server rule: the HLC must match `^\d{15}:\d{4}:[A-Za-z0-9-]{1,64}$`; reject a +write whose `T` is more than **5 min ahead** of server time (`clock_skew`). This keeps one device with a broken clock from winning +every conflict forever. The client then shows "check your system clock". + +Why HLC rather than arrival order: a device that edits offline for a week and +then reconnects would otherwise overwrite newer edits made elsewhere. + +### 3.4 Encryption + +Payloads travel in plaintext JSON over TLS 1.2+. byte.vet encrypts records +and snapshots at rest with a per-account data key wrapped by a KMS key +(envelope encryption), and must never log `data`. Since byte.vet holds the +keys, this is not end-to-end encryption: the data is decrypted only to serve +sync requests. The sign-in screen says profiles sync with their credentials, +stored encrypted and used only for sync. + +`encoding` is always `"json"` in v1. A future `"e2e-v1"` would put ciphertext in +`data`; servers must store unknown encodings opaquely and skip validation, and +v1 clients treat records with an unknown encoding as read-only (§3.5). + +### 3.5 Schema versioning + +`schema` is the Proxyverse config format version (`FORMAT_VERSION`, today +`"2025-01"`). Any change to the profile shape, even an additive field, bumps +it, because the client's zod schema strips unknown keys and an older client +would silently drop new fields on save. + +A client that receives a record with a schema or encoding it doesn't know: + +- keeps the raw record in `sync.state` but does not apply it locally; +- never pushes over it (its local copy is stale by definition); +- shows "a newer Proxyverse version synced data this version can't read". + +The server validates `data` against the schema version it names (a vendored +copy of the client's zod `ProxyProfile`; stored data is the parsed result, so +unknown keys are stripped as on the client), checks that `data.profileID` +equals the id suffix, and rejects +invalid payloads (`invalid_record`) and schema versions it doesn't know +(`unknown_schema`). So the server must be upgraded before a client that +writes a new schema ships. + +### 3.6 Profile payload normalization + +Before hashing or pushing, a profile goes through the same zod `ProxyProfile` +schema as export (`src/services/config/proxyverse/schema.ts`), which drops +runtime fields such as `pacScript.lastFetched` and `pacScript.lastError`. Then: + +- If `pacScript.sourceURL` is set, drop `pacScript.data`. The receiving device + keeps its own fetched `data` when `sourceURL` is unchanged, and fetches + immediately otherwise (`pacFetcher.ts`). +- Inline PAC scripts (no `sourceURL`) keep `data`. +- Colors are upper-cased (matches the profile editor's normalization). + +The content hash is SHA-256 of the canonical JSON (sorted keys, no whitespace) +of the normalized payload. It is used to detect real local changes and to +de-duplicate identical records. + +### 3.7 Limits + +| Limit | Value | +|---|---| +| Record payload | 256 KiB | +| Records per account (incl. tombstones) | 2,000 | +| Account total | 8 MiB | +| Records per push request | 100 | +| Request body | 2 MiB | + +## 4. Sync API + +Base: `https://byte.vet/api/proxyverse/v1` (all paths below are relative to it, e.g. `GET /api/proxyverse/v1/changes`). JSON in and out. Every request sends +`Authorization: Bearer `, `X-Proxyverse-Device: ` and +`X-Proxyverse-Version: `; both headers are required and +`deviceID` must be a UUID. An unknown device is registered implicitly on its +first authenticated call. Errors use RFC 9457 problem details +(`application/problem+json`, members `type`, `title`, `status`, `code`, +`detail`) with a stable `code` field. + +The machine-readable contract is `docs/sync-api.openapi.yaml`, vendored from +the byte.vet webapp repo, which owns it. + +### 4.1 Register device — `PUT /devices/{deviceID}` + +Body `{ "name": "Chrome on macOS", "browser": "chrome", "extVersion": "2.3.0" }` +(each optional, ≤ 200 chars; omitted fields keep their stored value). +Idempotent; also updates `lastSeen`. Optional, since any call registers the +device, but it gives the device a name. `GET /devices` lists them; `DELETE +/devices/{id}` removes one (its future requests get `device_revoked`). + +### 4.2 Pull changes — `GET /changes?since=&limit=` + +Returns every record (including tombstones) with `rev > since`, ordered by +`rev`. `limit` defaults to 500, maximum 1000. With no records, `cursor` is +`max(since, current rev)`. + +```jsonc +{ "records": [ /* Record */ ], "cursor": 1850, "hasMore": false } +``` + +`since=0` returns the full current state. If `since` is older than the +tombstone horizon (§6), the server answers `410 cursor_expired`; the client +then does a full resync (§5.5). + +### 4.3 Push — `POST /records` + +```jsonc +{ "records": [ + { "id": "profile:3f2a…", "baseRev": 1842, "hlc": "…", "deleted": false, + "schema": "2025-01", "encoding": "json", "data": { … } } +] } +``` + +Each record is processed on its own (no batch atomicity), in order: + +1. Validate (§3.5, §3.7, reserved ids, `clock_skew`). Failure → `rejected` with that reason. +2. If no stored record, or `incoming.hlc > stored.hlc` → store, assign next `rev` → `accepted`. +3. If `incoming.hlc == stored.hlc` → already applied (retry) → `accepted` with the stored `rev`. +4. Otherwise → `rejected` with reason `stale` and the stored record in `current`. + +`baseRev` is informational (diagnostics, "edited a stale copy" telemetry); the +LWW decision uses only the HLC, so the server decides the same way no matter +which device pushes first. + +```jsonc +{ "accepted": [ { "id": "profile:3f2a…", "rev": 1851 } ], + "rejected": [ { "id": "pref:ui.language", "reason": "stale", "current": { /* Record */ } } ] } +``` + +### 4.4 Snapshots + +- `GET /snapshots` → a bare array `[{ "id", "createdAt", "reason", "rev", "recordCount", "createdBy" }]`, newest first. +- `GET /snapshots/{id}` → `{ "records": [ /* Record */ ] }` (for previewing a restore) +- `POST /snapshots` → `201`, create one now. Optional body `{ "reason": "before-overwrite" }`; any other body records `manual`. +- `POST /snapshots/{id}/restore` → `{ "rev", "written" }`, see §6. + +### 4.5 Errors + +| HTTP | `code` | Client action | +|---|---|---| +| 400 | `invalid_request` | Bug; log, don't retry. | +| 401 | `invalid_token` | Refresh once, retry; then signed-out (expired). | +| 403 | `invalid_token` | Token lacks scope `sync` or belongs to another client. Refreshing won't help: signed-out (expired). | +| 403 | `device_revoked` | Sign out locally. | +| 410 | `cursor_expired` | Full resync (§5.5). | +| 413 | `quota_exceeded` | Stop pushing; show quota message. | +| 426 | `client_too_old` | Stop syncing; ask the user to update. | +| 404 | `not_found` | Unknown snapshot or device; show an error. | +| 429 / 503 | `rate_limited` / `unavailable` | Honor `Retry-After`, else exponential backoff (30 s → 30 min, with jitter). Limit: 60 requests/min per account and per device. | + +Per-record rejection reasons: `stale`, `invalid_record`, `reserved_id`, +`too_large`, `clock_skew`, `unknown_schema`, and `quota_exceeded` (a record +that would take the account over quota is rejected on its own; the rest of the +batch still applies). + +## 5. Client sync engine + +Runs only in the background service worker. All runs are serialized with +`navigator.locks.request("proxyverse-sync", …)`, so a restart or two +overlapping triggers can't interleave. + +### 5.1 Local state (`storage.local`, never synced) + +```jsonc +"sync.auth": { "accessToken", "refreshToken", "expiresAt", "account": { "sub", "email" }, "expired"? }, +"sync.state": { + "deviceID": "7b1e…", + "cursor": 1850, + "clock": "", + "base": { "": { "rev": 1842, "hlc": "…", "hash": "…" } }, // last state agreed with server + "local": { "": { "hlc": "…", "hash": "…" } }, // current local version + "held": { "": { /* Record with unknown schema/encoding */ } }, + "needsFirstChoice": true, // the first sync waits for the user (§5.7) + "synced": true, // the first sync (§5.7) is done + "lastSync": { "at": 0, "error"?: { "code", "message"? } }, + "failed": { "": { "reason", "hash" } }, // refused for good; not re-pushed until edited + "backoff": { "until": 0, "attempts": 1 } // after 429/503/network errors +}, +"sync.settings": { "syncPrefs": true, "localOnly": [""] } +``` + +Being signed in (`sync.auth` present and not `expired`) is what turns sync on; +there is no separate on/off setting. Signing out removes the keys. + +A record is **dirty** when `local[id].hash != base[id].hash`. There is no +separate outbox: after a service worker restart the dirty set is recomputed by +comparing local storage with `local`/`base`. + +### 5.2 Detecting local changes + +On a storage change to `profiles` or a synced pref key (and on each run, to +catch changes made while the worker was asleep): + +1. Normalize and hash each synced profile/pref. +2. If the hash differs from `local[id].hash`, issue a new HLC and update `local[id]`. +3. A profile missing from local storage that has a `local` entry becomes a + tombstone with a new HLC. +4. Schedule a push 2 s later (debounced). + +Writes made by the engine itself (§5.4) set `local` and `base` to the applied +hash before writing storage, so the resulting storage event finds no change and +nothing echoes back. + +### 5.3 A sync run + +Triggers: browser start, sign-in, debounced local change, alarm `sync:poll` +every 15 min, "Sync now" button. + +1. **Push** dirty records, 100 per request. For each `accepted`: `base[id] = { rev, hlc, hash }`. For each `stale`: apply `current` as a remote record (§5.4). +2. **Pull** `GET /changes?since=cursor` until `hasMore` is false. Apply each record (§5.4). Save `cursor` after each page. +3. Record `lastSync`. + +Push before pull means a change made just before a poll goes out immediately; +correctness doesn't depend on the order, because both the server and the client +apply the same HLC rule. + +### 5.4 Applying a remote record + +``` +clock = max(clock, r.hlc) +if r.id is local-only (5.6) or prefs sync is off for a pref: skip +if r.schema/encoding unknown: held[r.id] = r; skip +if local[r.id] exists and local[r.id].hlc > r.hlc: + keep local (it is dirty and will win on push); base[r.id] = r; skip +apply: write/delete the profile or pref; local[r.id] = base[r.id] = { r.hlc, hash(r.data) } +``` + +All profile changes from one run are written in a **single** `profiles` +storage write. The existing `refreshProxyOnProfileUpdate` listener then +re-applies the proxy if the active profile or anything it routes to changed; +the sync engine needs no proxy logic of its own. + +If a remote tombstone deletes the **active** profile, the device switches to +`direct` and shows a notice, the same as a local delete. Applying +`pref:ui.language` reloads open pages, as a local change does. + +### 5.5 Full resync + +Used after `cursor_expired`, and as the core of first sign-in. Pull +`GET /changes?since=0` into memory, then for each record id in the union of +remote and local: + +- same hash on both sides → mark in sync, no write; +- only local, with a `base` entry, not dirty → it was synced before and has + since been deleted elsewhere (tombstone already purged) → delete locally; +- only local otherwise (never synced, or edited since) → push it; +- otherwise resolve by HLC (§5.4). A local profile with no `local` entry yet + gets a fresh HLC, so it counts as the newest edit. + +### 5.6 Per-device opt-out + +- `sync.settings.localOnly` lists profile ids this device neither pushes nor + applies. The list itself never syncs. +- Marking a synced profile local-only *detaches* it: the cloud copy and other + devices are untouched. The dialog offers "also remove it from the cloud", + which pushes a tombstone and keeps the local copy. +- Unmarking it treats the profile as a new local edit (fresh HLC), so this + device's version wins. +- References: an auto-switch profile that syncs may point (rule or default) at + a local-only profile. Other devices don't have it; the proxy engine already + skips missing profiles with a warning (`profile2config.ts`). The UI warns when + marking a profile local-only that a synced profile references, and shows + "missing profile" for such rules on other devices. +- `syncPrefs: false` turns off all `pref:*` records on this device, both ways. + +### 5.7 First sign-in + +After token exchange and device registration, pull the full state. If the +account has no live records, or the device has no user profiles and default +prefs, merge silently. Otherwise ask: + +| Choice | Effect | +|---|---| +| **Merge** (default) | Full resync (§5.5). Local edits win same-id conflicts. | +| **Use cloud data** | The client offers a local backup (export-format JSON download), then replaces local profiles and synced prefs with the cloud state. | +| **Upload this device** | Client calls `POST /snapshots` (`reason: "before-overwrite"`), then pushes all local records with fresh HLCs and tombstones every cloud record absent locally. | + +## 6. Server behavior + +### 6.1 Storage + +Per account: the record table (`id → Record`), a `rev` sequence, a snapshot +list and a device list. Writes to one account are serialized (one row lock or +one Durable Object per account), which makes the `rev` sequence gap-free and +monotonic. + +### 6.2 Tombstones + +Kept for **90 days** after deletion, then purged. `changes?since=X` returns +`410 cursor_expired` when `X` is lower than the highest purged `rev`. A device +offline for longer than that does a full resync, which removes profiles it had +synced before but that are gone from the cloud (§5.5). Only a profile that was +*edited* on that offline device comes back; that is the intended LWW outcome. + +### 6.3 Snapshots + +A snapshot is a copy of all live records at a `rev`. The server creates one: + +- at most once per day, before the first write of the day; +- before a restore, and when a client asks (`POST /snapshots`). + +The daily snapshot is taken before the first write of a UTC day, and only if +live records exist. Retention: all snapshots from the last 30 days, at least the +10 most recent. + +**Restore** first takes a `before-restore` snapshot, then writes every record +of the snapshot, and a tombstone for every live record absent from it, as new +writes with server-issued HLC `max(max stored T + 1, now) : 0000 : server` +and the restoring device's `deviceID`. They get new `rev`s, so every +device picks them up through the normal pull. Restore never rewinds `rev`. + +### 6.4 Security + +- TLS 1.2+ only; HSTS on `byte.vet`. +- Envelope encryption at rest per account; `data` is never logged or included + in error reports. +- Access tokens are bound to `client_id` and scope `sync`; a token only reaches + its own account's records. +- Rate limit per account and per device (suggested: 60 requests/min). +- Account deletion deletes records, snapshots and devices. + +## 7. Extension integration points + +| Area | Change | +|---|---| +| `src/adapters/` | `launchWebAuthFlow`, `getRedirectURL`, optional permission request. | +| `src/services/sync/` (new) | HLC, normalization + hash, API client, engine, state. | +| `src/background.ts` | Start engine; storage listeners; `sync:poll` alarm; message handlers for the UI. | +| Preference page | Sign in/out, status, "Sync now", sync prefs toggle, snapshots and restore. | +| Profile editor | "This device only" switch, with the reference warning. | +| `manifest.json` | `identity` in `permissions`. | + +## 8. Open questions + +1. Account deletion and data export from byte.vet: is that in the website's + scope or does the extension need a button? +2. Should restore preview diff against the device's local state, or just list + profile names? +3. Do we need a kill switch (server flag in `/devices` response) to pause sync + for all clients during an incident? +4. Quotas in §3.7 are guesses; check against real user exports. diff --git a/e2e/firefox/smoke.e2e.ts b/e2e/firefox/smoke.e2e.ts index ce243dc..580d199 100644 --- a/e2e/firefox/smoke.e2e.ts +++ b/e2e/firefox/smoke.e2e.ts @@ -11,6 +11,7 @@ import { standardProfiles, } from "../data/profiles"; import { launchFirefox, type Firefox } from "./driver"; +import { signedInStorage } from "../pages/sync"; // hosts reached without a proxy resolve to the direct server const DIRECT_HOSTS = ["a.test", "b.test", "direct.test", "auth.test", "fallback.test"]; @@ -160,4 +161,36 @@ test.describe("firefox smoke", () => { await setLanguage("auto"); }); + // last: it leaves the extension signed in to sync + test("syncs profiles with the fake byte.vet", async () => { + const { proxyA, proxyB } = standardProfiles(servers); + const local = (items: Record) => + ff.driver.executeAsyncScript( + `const [items, done] = arguments; + browser.storage.local.set(items).then(() => done(), (e) => done(String(e)));`, + items + ); + + await ff.openExtensionPage("/preference"); + await ff.setProfiles([proxyA]); + // a completed sign-in: the Firefox auth window can't be driven here + await local(signedInStorage(servers)); + await expect + .poll(() => servers.sync.get("profile:proxyA")?.data, { timeout: 15_000 }) + .toMatchObject({ profileID: "proxyA" }); + + servers.sync.seed([{ id: "profile:proxyB", data: proxyB, deviceID: "other-device" }]); + await ff.driver.navigate().refresh(); + await (await ff.byTestId("pref-sync-now")).click(); + await expect + .poll( + () => + ff.driver.executeAsyncScript( + `const [done] = arguments; + browser.storage.local.get("profiles").then((r) => done(Object.keys(r.profiles ?? {}).sort()));` + ), + { timeout: 15_000 } + ) + .toEqual(["proxyA", "proxyB"]); + }); }); diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index 008c824..9f5849e 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -2,7 +2,10 @@ import { execSync } from "node:child_process"; import path from "node:path"; const root = path.resolve(import.meta.dirname, ".."); -const run = (cmd: string) => execSync(cmd, { cwd: root, stdio: "inherit" }); +// e2e builds: sync origin overridable at runtime +const env = { ...process.env, PROXYVERSE_ENV: "e2e" }; +const run = (cmd: string) => + execSync(cmd, { cwd: root, stdio: "inherit", env }); /** * Build the extension before the suite runs: for both browsers, or only the diff --git a/e2e/live/global-setup.ts b/e2e/live/global-setup.ts new file mode 100644 index 0000000..c89d742 --- /dev/null +++ b/e2e/live/global-setup.ts @@ -0,0 +1,17 @@ +import { execSync } from "node:child_process"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../.."); + +/** A Chrome build for northernlight, with the dev key's fixed extension ID. */ +export default function globalSetup() { + if (process.env.E2E_SKIP_BUILD) return; + execSync( + "npx vite build --outDir .e2e-build/live-chrome --emptyOutDir --logLevel warn", + { + cwd: root, + stdio: "inherit", + env: { ...process.env, PROXYVERSE_ENV: "northernlight" }, + } + ); +} diff --git a/e2e/live/playwright.config.ts b/e2e/live/playwright.config.ts new file mode 100644 index 0000000..a4824e1 --- /dev/null +++ b/e2e/live/playwright.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "@playwright/test"; + +// The live sync smoke against northernlight.byte.vet, run by hand: +// `npm run test:e2e:live`. It needs a person to sign in, so it is never part +// of `npm run test:e2e` or CI. +export default defineConfig({ + testMatch: "**/*.live.ts", + globalSetup: "./global-setup.ts", + outputDir: "../../test-results/live", + // signing in by hand, twice, takes a while + timeout: 15 * 60_000, + expect: { timeout: 30_000 }, + workers: 1, + reporter: [["list"]], +}); diff --git a/e2e/live/sync.live.ts b/e2e/live/sync.live.ts new file mode 100644 index 0000000..4bd18df --- /dev/null +++ b/e2e/live/sync.live.ts @@ -0,0 +1,114 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; +import { chromium, expect, test, type BrowserContext, type Page } from "@playwright/test"; +import type { ProfilesStorage, ProxyProfile } from "@/services/profile"; +import { ServiceWorker } from "../fixtures/sw"; +import { SyncSection } from "../pages/sync"; + +// Two browsers with separate profiles act as two devices syncing through +// northernlight.byte.vet. Each opens the Preference page and clicks "Sign +// in"; you finish the sign-in (GitHub/Google, consent) in its window. + +const BUILD = path.resolve(import.meta.dirname, "../../.e2e-build/live-chrome"); +// the dev key's extension ID (build/README.md) +const EXTENSION_ID = "ohbdfegnjlfoohcaiedlpbmcacohacmg"; +const ORIGIN = `chrome-extension://${EXTENSION_ID}`; +const SIGN_IN_TIMEOUT = 5 * 60_000; + +type Device = { + name: string; + context: BrowserContext; + dir: string; + page: Page; + sw: ServiceWorker; +}; + +async function launch(name: string): Promise { + const dir = mkdtempSync(path.join(os.tmpdir(), `proxyverse-live-${name}-`)); + const context = await chromium.launchPersistentContext(dir, { + channel: "chromium", + headless: false, + args: [`--disable-extensions-except=${BUILD}`, `--load-extension=${BUILD}`], + }); + const page = await context.newPage(); + await page.goto(`${ORIGIN}/index.html#/preference`); + return { name, context, dir, page, sw: new ServiceWorker(context, EXTENSION_ID) }; +} + +const profilesOf = (device: Device) => device.sw.getProfiles(); +const setProfiles = (device: Device, profiles: ProfilesStorage) => + device.sw.setProfiles(profiles); + +async function signIn(device: Device) { + const { page } = device; + await page.getByTestId("pref-sync-signin").click(); + console.log(`[${device.name}] Finish signing in to northernlight in the window that opened.`); + await expect(page.getByTestId("pref-sync-account")).toBeVisible({ timeout: SIGN_IN_TIMEOUT }); + + // an account with data from an earlier run asks how to combine: merge + const choice = page.getByTestId("pref-sync-choice-merge"); + await page.waitForTimeout(3000); + if (await choice.isVisible()) await choice.click(); + await expect(page.getByTestId("pref-sync-status")).toContainText("Last synced", { + timeout: 60_000, + }); +} + +/** Sync `device` until `check` holds for its profiles. */ +async function syncUntil(device: Device, check: (p: ProfilesStorage) => boolean) { + await expect + .poll( + async () => { + await device.page.getByTestId("pref-sync-now").click(); + await device.page.waitForTimeout(1500); + return check(await profilesOf(device)); + }, + { timeout: 60_000, intervals: [1000, 3000, 5000] } + ) + .toBe(true); +} + +test("two devices sync through northernlight", async () => { + const a = await launch("A"); + const b = await launch("B"); + try { + await signIn(a); + await signIn(b); + + const id = `live-${Date.now()}`; + const profile: ProxyProfile = { + profileID: id, + profileName: "Live smoke", + color: "#3370FF", + proxyType: "proxy", + proxyRules: { + default: { scheme: "http", host: "10.0.0.1", port: 3128 }, + bypassList: [], + }, + }; + + // created on A, shows up on B + await setProfiles(a, { ...(await profilesOf(a)), [id]: profile }); + await syncUntil(b, (p) => p[id]?.profileName === "Live smoke"); + + // renamed on B, shows up on A + const onB = await profilesOf(b); + await setProfiles(b, { ...onB, [id]: { ...onB[id], profileName: "Live smoke, renamed" } }); + await syncUntil(a, (p) => p[id]?.profileName === "Live smoke, renamed"); + + // deleted on A, gone from B + const onA = await profilesOf(a); + delete onA[id]; + await setProfiles(a, onA); + await syncUntil(b, (p) => !(id in p)); + } finally { + for (const d of [a, b]) { + // sign out: revokes the tokens of this run + await new SyncSection(d.page).confirm("pref-sync-signout").catch(() => undefined); + await d.page.waitForTimeout(1000); + await d.context.close(); + rmSync(d.dir, { recursive: true, force: true }); + } + } +}); diff --git a/e2e/pages/arco.ts b/e2e/pages/arco.ts index cd9cb6c..83c4013 100644 --- a/e2e/pages/arco.ts +++ b/e2e/pages/arco.ts @@ -57,6 +57,11 @@ export function toast(page: Page, text: string | RegExp) { .first(); } +/** The confirm button of the open popconfirm (rendered in ``). */ +export function popconfirmOK(page: Page) { + return page.locator(".arco-popconfirm:visible .arco-btn-primary"); +} + /** Fill an Arco input (the testid sits on the wrapper). */ export async function fillInput(wrapper: Locator, value: string) { const input = wrapper.locator("input, textarea").first(); diff --git a/e2e/pages/preference.ts b/e2e/pages/preference.ts index 0e17189..7b3207c 100644 --- a/e2e/pages/preference.ts +++ b/e2e/pages/preference.ts @@ -1,5 +1,6 @@ import { expect, type Page } from "@playwright/test"; import type { FilePayload } from "../data/configFiles"; +import { popconfirmOK } from "./arco"; /** The Preference page (import / export) of the config page. */ export class Preference { @@ -20,7 +21,7 @@ export class Preference { async import(file: FilePayload) { await this.page.getByTestId("import-profiles").click(); const chooser = this.page.waitForEvent("filechooser"); - await this.page.locator(".arco-popconfirm:visible .arco-btn-primary").click(); + await popconfirmOK(this.page).click(); await (await chooser).setFiles(file); } diff --git a/e2e/pages/sync.ts b/e2e/pages/sync.ts new file mode 100644 index 0000000..6b60721 --- /dev/null +++ b/e2e/pages/sync.ts @@ -0,0 +1,83 @@ +import { expect, type Page } from "@playwright/test"; +import type { ProxyProfile } from "@/services/profile"; +import type { SyncState } from "@/services/sync/state"; +import type { Servers } from "../servers"; +import type { ServiceWorker } from "../fixtures/sw"; +import { popconfirmOK } from "./arco"; + +/** The OAuth client e2e builds sign in as. */ +export const CLIENT_ID = "proxyverse-extension-dev"; + +/** + * Storage items of a completed sign-in with the fake byte.vet: its origin + * and fresh tokens. For drivers that write storage themselves. + */ +export function signedInStorage(servers: Servers) { + const tokens = servers.sync.issueTokens(undefined, CLIENT_ID); + return { + "sync.devOrigin": servers.syncOrigin, + "sync.auth": { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: Date.now() + tokens.expires_in * 1000, + account: servers.sync.user, + }, + }; +} + +/** Point the extension at the fake byte.vet (e2e builds only). */ +export async function useFakeByteVet(sw: ServiceWorker, servers: Servers) { + await sw.eval( + (origin) => chrome.storage.local.set({ "sync.devOrigin": origin }), + servers.syncOrigin + ); +} + +/** + * Sign in without the browser's auth window: store tokens the fake server + * issued, as a completed sign-in does. The background starts syncing. + */ +export async function signInDirectly(sw: ServiceWorker, servers: Servers) { + await sw.eval((items) => chrome.storage.local.set(items), signedInStorage(servers)); +} + +/** Wait until this device has finished a sync run without error. */ +export async function expectSynced(sw: ServiceWorker, since = 0) { + await expect + .poll(async () => { + const state = (await sw.getLocal("sync.state")) as SyncState | undefined; + return state?.lastSync && state.lastSync.at > since && !state.lastSync.error + ? "synced" + : JSON.stringify(state?.lastSync ?? (state?.needsFirstChoice ? "needs first choice" : "not yet")); + }, { timeout: 15_000 }) + .toBe("synced"); +} + +/** A record of `profile` as another device would have pushed it. */ +export function remoteProfile(profile: ProxyProfile, deleted = false) { + return { + id: `profile:${profile.profileID}`, + deleted, + data: deleted ? null : profile, + deviceID: "other-device", + }; +} + +/** The Sync section of the Preference page. */ +export class SyncSection { + constructor(readonly page: Page) {} + + byId(id: string) { + return this.page.getByTestId(id); + } + + async syncNow() { + await this.byId("pref-sync-now").click(); + } + + /** Click a button guarded by a popconfirm, and confirm. */ + async confirm(testID: string) { + await this.byId(testID).click(); + await popconfirmOK(this.page).click(); + } +} diff --git a/e2e/servers/index.ts b/e2e/servers/index.ts index 42938d2..2e8ebcc 100644 --- a/e2e/servers/index.ts +++ b/e2e/servers/index.ts @@ -4,9 +4,11 @@ import os from "node:os"; import { createHttpProxy } from "./httpProxy"; import { ContentStore, RequestLog, respond } from "./routeServer"; import { createSocks5Server } from "./socks5"; +import { createSyncServer } from "./syncApi"; export { ContentStore, RequestLog } from "./routeServer"; export type { LogEntry, Route } from "./routeServer"; +export { SyncStore } from "./syncStore"; export const AUTH = { username: "e2e-user", password: "e2e-pass" }; @@ -40,6 +42,8 @@ export type Servers = Awaited>; * - `proxyA`, `proxyB`: HTTP proxies * - `auth`: HTTP proxy requiring Basic auth (`AUTH`) * - `socks`: SOCKS5 proxy + * - `sync`: fake byte.vet (OAuth + sync API), on loopback so Chrome never + * proxies it, whatever profile a test activates */ export async function startServers() { const log = new RequestLog(); @@ -52,7 +56,8 @@ export async function startServers() { const proxyB = createHttpProxy("proxyB", log, store); const auth = createHttpProxy("auth", log, store, AUTH); const socks = createSocks5Server("socks", log, store); - const all: net.Server[] = [direct, proxyA, proxyB, auth, socks]; + const sync = createSyncServer(); + const all: net.Server[] = [direct, proxyA, proxyB, auth, socks, sync.server]; const ports = { // on all interfaces: fake sites (LAN IP) and loopback URLs @@ -62,6 +67,7 @@ export async function startServers() { auth: await listen(auth), socks: await listen(socks), closed: await closedPort(), + sync: await listen(sync.server), }; return { @@ -72,9 +78,14 @@ export async function startServers() { url: (host: string, path = "/") => `http://${host}:${ports.direct}${path}`, /** URL served by the `direct` server on loopback, never proxied. */ localURL: (path: string) => `http://127.0.0.1:${ports.direct}${path}`, + /** The fake byte.vet's state and test controls. */ + sync: sync.store, + /** Origin of the fake byte.vet, e.g. for its discovery URL. */ + syncOrigin: `http://127.0.0.1:${ports.sync}`, reset() { log.clear(); store.clear(); + sync.store.reset(); }, async close() { all.forEach((s) => { diff --git a/e2e/servers/syncApi.ts b/e2e/servers/syncApi.ts new file mode 100644 index 0000000..b61a16f --- /dev/null +++ b/e2e/servers/syncApi.ts @@ -0,0 +1,44 @@ +import http from "node:http"; +import { SyncStore, type SyncStoreOptions } from "./syncStore"; + +const CORS_HEADERS = { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS", + "access-control-allow-headers": + "Authorization, Content-Type, X-Proxyverse-Device, X-Proxyverse-Version", + "access-control-expose-headers": "Retry-After, WWW-Authenticate, Location", + "access-control-max-age": "600", +}; + +/** + * The fake byte.vet (OAuth + Proxyverse sync API) over HTTP, at the real + * paths. All logic lives in `SyncStore`; this only adapts node:http to it and + * adds CORS. + */ +export function createSyncServer(options?: SyncStoreOptions) { + const store = new SyncStore(options); + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => { + if (req.method === "OPTIONS") { + res.writeHead(204, CORS_HEADERS); + res.end(); + return; + } + const headers: Record = {}; + for (const [k, v] of Object.entries(req.headers)) + headers[k] = Array.isArray(v) ? v.join(", ") : v; + const body = Buffer.concat(chunks).toString("utf8"); + const out = store.handle({ + method: req.method ?? "GET", + url: `http://${req.headers.host ?? "127.0.0.1"}${req.url ?? "/"}`, + headers, + body: body || undefined, + }); + res.writeHead(out.status, { ...CORS_HEADERS, ...out.headers }); + res.end(out.body); + }); + }); + return { server, store }; +} diff --git a/e2e/servers/syncStore.ts b/e2e/servers/syncStore.ts new file mode 100644 index 0000000..b4e41c7 --- /dev/null +++ b/e2e/servers/syncStore.ts @@ -0,0 +1,1162 @@ +/// +/** + * In-memory fake of byte.vet's OAuth server and Proxyverse sync API, for + * tests. Pure logic, no sockets: `syncApi.ts` serves it over HTTP for e2e, and + * unit tests use it directly or through `store.fetch` (a `fetch` drop-in). + * + * Behavior follows docs/sync-protocol.md (§2, §3, §4, §6) and the real + * server's wire contract (webapp docs/proxyverse-sync.openapi.yaml): + * webapp `src/lib/proxyverse/{api,validate}.ts` and ws-relay + * `src/sync/sync-store.ts`. One account, one user. + * + * Only depends on `node:crypto`, so it type-checks under both the root and + * the e2e tsconfig and runs in vitest. + */ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import type { components } from "../../src/services/sync/api.gen"; + +// ── Wire types: the contract's, generated from docs/sync-api.openapi.yaml ── + +export const HLC_PATTERN = /^(\d{15}):(\d{4}):([A-Za-z0-9-]{1,64})$/; + +type Schemas = components["schemas"]; +export type SyncRecord = Schemas["SyncRecord"]; +export type PushRecord = Schemas["PushRecord"]; +export type RejectReason = Schemas["RejectReason"]; +export type PushResult = Schemas["PushResult"]; +export type ChangesResult = Schemas["ChangesResult"]; +export type Device = Schemas["Device"]; +export type DeviceInput = Schemas["DeviceInput"]; +export type SnapshotMeta = Schemas["SnapshotMeta"]; +export type SnapshotReason = SnapshotMeta["reason"]; +export type SyncErrorCode = Schemas["Problem"]["code"]; + +export const SYNC_ERROR_STATUS: Record = { + invalid_request: 400, + invalid_token: 401, + device_revoked: 403, + not_found: 404, + cursor_expired: 410, + quota_exceeded: 413, + client_too_old: 426, + rate_limited: 429, + unavailable: 503, +}; + +const PROBLEM_TITLES: Record = { + invalid_request: "Invalid request", + invalid_token: "Invalid or expired access token", + device_revoked: "Device revoked", + not_found: "Not found", + cursor_expired: "Cursor expired", + quota_exceeded: "Quota exceeded", + client_too_old: "Client too old", + rate_limited: "Rate limited", + unavailable: "Service unavailable", +}; + +const PROBLEM_TYPE_BASE = "https://byte.vet/docs/proxyverse-sync#"; + +/** RFC 9457 problem details, as the real server sends them. */ +export interface Problem { + type: string; + title: string; + status: number; + code: string; + detail?: string; +} + +/** A request-level error (`application/problem+json`). */ +export class SyncProblem extends Error { + constructor( + readonly code: string, + readonly detail?: string, + readonly status = SYNC_ERROR_STATUS[code as SyncErrorCode] ?? 500, + readonly retryAfter?: number + ) { + super(`${code}${detail ? `: ${detail}` : ""}`); + } + + get body(): Problem { + return { + type: PROBLEM_TYPE_BASE + this.code, + title: PROBLEM_TITLES[this.code] ?? this.code, + status: this.status, + code: this.code, + ...(this.detail !== undefined ? { detail: this.detail } : {}), + }; + } +} + +/** An OAuth error (RFC 6749 §5.2): `{error, error_description}`. */ +export class OAuthError extends Error { + constructor( + readonly error: string, + readonly description?: string, + readonly status = error === "invalid_client" || error === "invalid_token" ? 401 : 400 + ) { + super(`${error}${description ? `: ${description}` : ""}`); + } + + get body() { + return { + error: this.error, + ...(this.description ? { error_description: this.description } : {}), + }; + } +} + +// ── Limits (spec §3.7) ───────────────────────────────────────────────── + +export const SYNC_LIMITS = { + recordPayloadBytes: 256 * 1024, + recordsPerAccount: 2000, + accountBytes: 8 * 1024 * 1024, + recordsPerPush: 100, + requestBodyBytes: 2 * 1024 * 1024, + recordIdBytes: 200, + changesDefaultLimit: 500, + changesMaxLimit: 1000, + clockSkewMs: 5 * 60 * 1000, +} as const; + +export const KNOWN_SCHEMAS = new Set(["2025-01"]); +const RESERVED_PROFILES = new Set(["direct", "system"]); + +export const SYNC_BASE_PATH = "/api/proxyverse/v1"; +export const OAUTH_BASE_PATH = "/api/auth/oauth2"; +export const SUPPORTED_SCOPES = ["openid", "email", "offline_access", "sync"]; + +// ── Options ──────────────────────────────────────────────────────────── + +export interface SyncStoreOptions { + /** Accepted OAuth client ids. */ + clientIDs?: string[]; + /** Redirect URI check; default: `https://*.chromiumapp.org/oauth` or `https://*.extensions.allizom.org/oauth`. */ + redirectURIAllowed?: (uri: string, clientID: string) => boolean; + /** The account's user. */ + user?: { sub: string; email: string }; + accessTokenTtlMs?: number; + refreshTokenTtlMs?: number; + /** Per-account quotas (records incl. tombstones, and payload bytes). */ + maxRecords?: number; + maxBytes?: number; + /** `X-Proxyverse-Version` below this gets 426 `client_too_old`. */ + minVersion?: string; +} + +export const DEFAULT_CLIENT_IDS = [ + "proxyverse-extension", + "proxyverse-extension-dev", +]; + +const DEFAULT_REDIRECT_RE = + /^https:\/\/[a-z0-9-]+(\.[a-z0-9-]+)*\.(chromiumapp\.org|extensions\.allizom\.org)\/oauth$/i; + +const DEFAULTS: Required = { + clientIDs: DEFAULT_CLIENT_IDS, + redirectURIAllowed: (uri) => DEFAULT_REDIRECT_RE.test(uri), + user: { sub: "e2e-user", email: "e2e-user@example.test" }, + accessTokenTtlMs: 60 * 60 * 1000, + refreshTokenTtlMs: 90 * 24 * 60 * 60 * 1000, + maxRecords: SYNC_LIMITS.recordsPerAccount, + maxBytes: SYNC_LIMITS.accountBytes, + minVersion: "", +}; + +// ── Test-control types ───────────────────────────────────────────────── + +export interface Failure { + status: number; + code: string; + retryAfter?: number; + /** Only requests whose pathname contains this (e.g. "/records"); any request if omitted. */ + path?: string; + /** Only this method, e.g. "POST". */ + method?: string; + detail?: string; +} + +export interface RequestLogEntry { + method: string; + path: string; + query: Record; + status: number; + /** Problem `code` or OAuth `error`, for error responses. */ + code?: string; + device?: string; + version?: string; + /** Parsed JSON or form body, if any. */ + body?: unknown; + time: number; +} + +/** A record for `seed()`; missing fields get defaults. */ +export type SeedRecord = Partial> & { id: string }; + +/** A request as `handle()` sees it. Header names are lower-case. */ +export interface SyncRequest { + method: string; + /** Absolute URL. */ + url: string; + headers: Record; + body?: string; +} + +export interface SyncResponse { + status: number; + headers: Record; + body: string; +} + +// ── Helpers ──────────────────────────────────────────────────────────── + +const utf8Length = (s: string) => Buffer.byteLength(s, "utf8"); + +const isPlainObject = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export const isUUID = (v: unknown): v is string => + typeof v === "string" && UUID_RE.test(v); + +export function formatHlc(t: number, c: number, deviceID: string): string { + return `${String(t).padStart(15, "0")}:${String(c).padStart(4, "0")}:${deviceID}`; +} + +const hlcTime = (hlc: string) => Number(HLC_PATTERN.exec(hlc)?.[1] ?? 0); + +export function pkceChallenge(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} + +function compareVersions(a: string, b: string): number { + const pa = a.split(/[.+-]/).map((p) => Number.parseInt(p, 10) || 0); + const pb = b.split(/[.+-]/).map((p) => Number.parseInt(p, 10) || 0); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] ?? 0) - (pb[i] ?? 0); + if (d !== 0) return d < 0 ? -1 : 1; + } + return 0; +} + +const clone = (v: T): T => (v === undefined ? v : JSON.parse(JSON.stringify(v))); + +const PREF_VALIDATORS: Record boolean> = { + "theme.darkmode": (v) => v === 0 || v === 1 || v === 2, + "ui.language": (v) => + v === "auto" || (typeof v === "string" && /^[a-z]{2,3}(_[A-Z]{2})?$/.test(v)), + "ui.actionBehavior": (v) => v === "popup" || v === "sidebar", +}; + +type Validated = + | { ok: true; record: PushRecord } + | { ok: false; id: string; reason: RejectReason; detail: string }; + +/** + * Per-record validation, in the real server's order (webapp + * `validate.ts`). `clock_skew`, `stale` and quotas need state and are decided + * in `push`. + */ +export function validatePushRecord(input: unknown): Validated { + const fail = (id: string, reason: RejectReason, detail: string): Validated => ({ + ok: false, + id, + reason, + detail, + }); + if (!isPlainObject(input)) return fail("", "invalid_record", "record must be an object"); + const id = typeof input.id === "string" ? input.id : ""; + if (!id) return fail("", "invalid_record", "id must be a non-empty string"); + + const { hlc, deleted, schema, encoding, baseRev, data } = input; + if (typeof hlc !== "string" || !HLC_PATTERN.test(hlc)) + return fail(id, "invalid_record", "hlc must match TTTTTTTTTTTTTTT:CCCC:DEVICEID"); + if (typeof deleted !== "boolean") + return fail(id, "invalid_record", "deleted must be a boolean"); + if (typeof schema !== "string" || !schema) + return fail(id, "invalid_record", "schema must be a non-empty string"); + if (typeof encoding !== "string" || !encoding) + return fail(id, "invalid_record", "encoding must be a non-empty string"); + if ( + baseRev !== undefined && + !(typeof baseRev === "number" && Number.isSafeInteger(baseRev) && baseRev >= 0) + ) + return fail(id, "invalid_record", "baseRev must be a non-negative integer"); + if (!("data" in input) || data === undefined) + return fail(id, "invalid_record", "data is required (null for tombstones)"); + + if (utf8Length(id) > SYNC_LIMITS.recordIdBytes) + return fail(id, "invalid_record", `id exceeds ${SYNC_LIMITS.recordIdBytes} bytes`); + let kind: "profile" | "pref"; + let key: string; + if (id.startsWith("profile:") && id.length > "profile:".length) { + kind = "profile"; + key = id.slice("profile:".length); + if (RESERVED_PROFILES.has(key)) + return fail(id, "reserved_id", `profile "${key}" is built in and never synced`); + } else if (id.startsWith("pref:") && id.slice(5) in PREF_VALIDATORS) { + kind = "pref"; + key = id.slice(5); + } else { + return fail(id, "invalid_record", "id must be profile: or pref:"); + } + + if (deleted && data !== null) + return fail(id, "invalid_record", "tombstones must have data: null"); + if (!deleted && data === null) + return fail(id, "invalid_record", "data must not be null unless deleted"); + + const record: PushRecord = { + id, + ...(baseRev !== undefined ? { baseRev: baseRev as number } : {}), + hlc, + deleted, + schema, + encoding, + data, + }; + + // unknown encodings (future E2E) are stored opaquely, unvalidated (§3.4) + const opaque = encoding !== "json"; + if (!opaque && !KNOWN_SCHEMAS.has(schema)) + return fail(id, "unknown_schema", `unknown schema version "${schema}"`); + + const size = utf8Length(JSON.stringify(data)); + if (size > SYNC_LIMITS.recordPayloadBytes) + return fail( + id, + "too_large", + `payload is ${size} bytes; limit is ${SYNC_LIMITS.recordPayloadBytes}` + ); + + if (deleted || opaque) return { ok: true, record }; + + if (!isPlainObject(data)) return fail(id, "invalid_record", "data must be an object"); + if (kind === "pref") { + if (!PREF_VALIDATORS[key](data.value)) + return fail(id, "invalid_record", `invalid value for pref "${key}"`); + } else { + // Lighter than the real server, which parses `data` with a vendored copy + // of the client's zod `ProxyProfile` (and stores the parsed result, with + // unknown keys stripped). Here: profileID matches, proxyType is a string, + // and the payload is stored as sent. + if (typeof data.proxyType !== "string") + return fail(id, "invalid_record", "proxyType must be a string"); + if (data.profileID !== key) + return fail(id, "invalid_record", "data.profileID does not match the record id"); + } + return { ok: true, record }; +} + +// ── OAuth state ──────────────────────────────────────────────────────── + +interface AuthCode { + clientID: string; + redirectURI: string; + challenge: string; + scopes: string[]; + expiresAt: number; +} + +interface TokenEntry { + kind: "access" | "refresh"; + clientID: string; + scopes: string[]; + family: string; + expiresAt: number; + revoked: boolean; + /** Refresh tokens: already exchanged once. */ + used?: boolean; +} + +export interface TokenResponse { + access_token: string; + token_type: "Bearer"; + expires_in: number; + refresh_token?: string; + scope: string; +} + +interface StoredDevice extends Device { + revokedAt: number | null; +} + +interface Snapshot extends SnapshotMeta { + records: SyncRecord[]; + seq: number; +} + +// ── The store ────────────────────────────────────────────────────────── + +export class SyncStore { + private base: Required; + private opts!: Required; + + // sync state + private recs = new Map(); + private rev = 0; + private purgedRev = 0; + private devs = new Map(); + private snaps: Snapshot[] = []; + private snapSeq = 0; + + // oauth state + private codes = new Map(); + private tokens = new Map(); + + // test controls + private fixedNow: number | null = null; + private failures: Failure[] = []; + /** Every request through `handle()`/`fetch`, oldest first. */ + log: RequestLogEntry[] = []; + + constructor(options: SyncStoreOptions = {}) { + this.base = { ...DEFAULTS, ...options }; + this.reset(); + } + + // ── Test controls ── + + /** Back to an empty account, no tokens, the constructor's options, real time. */ + reset() { + this.opts = { ...this.base }; + this.recs.clear(); + this.rev = 0; + this.purgedRev = 0; + this.devs.clear(); + this.snaps = []; + this.snapSeq = 0; + this.codes.clear(); + this.tokens.clear(); + this.fixedNow = null; + this.failures = []; + this.log = []; + } + + /** Override options until the next `reset()`. */ + configure(options: SyncStoreOptions) { + this.opts = { ...this.opts, ...options }; + } + + get user() { + return { ...this.opts.user }; + } + + /** Freeze the server clock at `ms`; `null` goes back to real time. */ + setNow(ms: number | null) { + this.fixedNow = ms; + } + + now(): number { + return this.fixedNow ?? Date.now(); + } + + /** One-shot error for the next matching request (queued; first match wins). */ + failNext(failure: Failure) { + this.failures.push(failure); + } + + /** Expire every access token issued so far (refresh tokens stay valid). */ + expireAccessTokens() { + const past = this.now() - 1; + for (const t of this.tokens.values()) if (t.kind === "access") t.expiresAt = past; + } + + /** + * Write records directly, as if pushed by another device: no validation, + * no LWW. Each gets the next rev. Defaults: `hlc` now (counter = position), + * device `seed-device`, live, schema 2025-01, encoding json. + */ + seed(records: SeedRecord[]): SyncRecord[] { + const now = this.now(); + return records.map((r, i) => { + const deviceID = r.deviceID ?? "seed-device"; + const deleted = r.deleted ?? false; + const rec: SyncRecord = { + id: r.id, + rev: ++this.rev, + hlc: r.hlc ?? formatHlc(now, i, deviceID), + deviceID, + deleted, + schema: r.schema ?? "2025-01", + encoding: r.encoding ?? "json", + data: deleted ? null : clone(r.data ?? null), + }; + this.recs.set(rec.id, rec); + return clone(rec); + }); + } + + /** All stored records, tombstones included, by rev. */ + records(): SyncRecord[] { + return [...this.recs.values()].sort((a, b) => a.rev - b.rev).map(clone); + } + + get(id: string): SyncRecord | undefined { + const r = this.recs.get(id); + return r && clone(r); + } + + /** The account's current rev. */ + currentRev() { + return this.rev; + } + + /** + * Drop every tombstone (as the 90-day purge would) and remember the highest + * purged rev; `changes(since)` with 0 < since < that rev then gets 410. + */ + purgeTombstones(): number { + let max = 0; + for (const [id, r] of this.recs) { + if (!r.deleted) continue; + max = Math.max(max, r.rev); + this.recs.delete(id); + } + this.purgedRev = Math.max(this.purgedRev, max); + return this.purgedRev; + } + + /** Logged requests whose path contains `path`. */ + requests(path?: string, method?: string): RequestLogEntry[] { + return this.log.filter( + (e) => (!path || e.path.includes(path)) && (!method || e.method === method) + ); + } + + // ── OAuth ── + + /** + * Authorization endpoint; auto-approves. Returns the redirect URL (with + * `code` and `state`, or `error`). Throws `OAuthError` when the client or + * redirect URI is invalid (nothing to redirect to safely). + */ + authorize(params: { + client_id?: string | null; + redirect_uri?: string | null; + code_challenge?: string | null; + code_challenge_method?: string | null; + state?: string | null; + scope?: string | null; + response_type?: string | null; + }): string { + const clientID = params.client_id ?? ""; + const redirectURI = params.redirect_uri ?? ""; + if (!this.opts.clientIDs.includes(clientID)) + throw new OAuthError("invalid_client", "unknown client_id"); + if (!this.opts.redirectURIAllowed(redirectURI, clientID)) + throw new OAuthError("invalid_request", "redirect_uri is not registered"); + + const target = new URL(redirectURI); + const redirectError = (error: string, description: string) => { + target.searchParams.set("error", error); + target.searchParams.set("error_description", description); + if (params.state) target.searchParams.set("state", params.state); + return target.href; + }; + if (params.response_type !== undefined && params.response_type !== null && params.response_type !== "code") + return redirectError("unsupported_response_type", "response_type must be code"); + if (!params.code_challenge || params.code_challenge_method !== "S256") + return redirectError("invalid_request", "PKCE with S256 is required"); + + const scopes = (params.scope ?? "") + .split(/\s+/) + .filter((s) => SUPPORTED_SCOPES.includes(s)); + const code = randomBytes(24).toString("base64url"); + this.codes.set(code, { + clientID, + redirectURI, + challenge: params.code_challenge, + scopes, + expiresAt: this.now() + 10 * 60 * 1000, + }); + target.searchParams.set("code", code); + if (params.state) target.searchParams.set("state", params.state); + return target.href; + } + + /** Token endpoint: `authorization_code` (PKCE) and `refresh_token` grants. */ + token(params: Record): TokenResponse { + const clientID = params.client_id ?? ""; + if (!this.opts.clientIDs.includes(clientID)) + throw new OAuthError("invalid_client", "unknown client_id"); + + if (params.grant_type === "authorization_code") { + const code = this.codes.get(params.code ?? ""); + if (!code) throw new OAuthError("invalid_grant", "unknown or used code"); + this.codes.delete(params.code!); // single use, even on failure + if (code.expiresAt <= this.now()) throw new OAuthError("invalid_grant", "code expired"); + if (code.clientID !== clientID) + throw new OAuthError("invalid_grant", "code was issued to another client"); + if (code.redirectURI !== params.redirect_uri) + throw new OAuthError("invalid_grant", "redirect_uri mismatch"); + const verifier = params.code_verifier ?? ""; + if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)) + throw new OAuthError("invalid_request", "code_verifier must be 43-128 chars"); + if (pkceChallenge(verifier) !== code.challenge) + throw new OAuthError("invalid_grant", "PKCE verification failed"); + return this.issue(clientID, code.scopes, randomUUID()); + } + + if (params.grant_type === "refresh_token") { + const value = params.refresh_token ?? ""; + const entry = this.tokens.get(value); + if (!entry || entry.kind !== "refresh") + throw new OAuthError("invalid_grant", "unknown refresh token"); + if (entry.clientID !== clientID) + throw new OAuthError("invalid_grant", "refresh token was issued to another client"); + if (entry.used) { + // reuse of a rotated token: assume theft, revoke the whole family + this.revokeFamily(entry.family); + throw new OAuthError("invalid_grant", "refresh token reused"); + } + if (entry.revoked) throw new OAuthError("invalid_grant", "refresh token revoked"); + if (entry.expiresAt <= this.now()) + throw new OAuthError("invalid_grant", "refresh token expired"); + entry.used = true; + return this.issue(clientID, entry.scopes, entry.family); + } + + throw new OAuthError("unsupported_grant_type"); + } + + /** + * Issue tokens without the authorize dance (for unit tests). Scopes + * default to `openid email offline_access sync`. + */ + issueTokens( + scopes = ["openid", "email", "offline_access", "sync"], + clientID = this.opts.clientIDs[0] + ): TokenResponse { + return this.issue(clientID, scopes, randomUUID()); + } + + private issue(clientID: string, scopes: string[], family: string): TokenResponse { + const now = this.now(); + const access = `at_${randomBytes(24).toString("base64url")}`; + this.tokens.set(access, { + kind: "access", + clientID, + scopes, + family, + expiresAt: now + this.opts.accessTokenTtlMs, + revoked: false, + }); + const res: TokenResponse = { + access_token: access, + token_type: "Bearer", + expires_in: Math.floor(this.opts.accessTokenTtlMs / 1000), + scope: scopes.join(" "), + }; + if (scopes.includes("offline_access")) { + const refresh = `rt_${randomBytes(24).toString("base64url")}`; + this.tokens.set(refresh, { + kind: "refresh", + clientID, + scopes, + family, + expiresAt: now + this.opts.refreshTokenTtlMs, + revoked: false, + }); + res.refresh_token = refresh; + } + return res; + } + + private revokeFamily(family: string) { + for (const t of this.tokens.values()) if (t.family === family) t.revoked = true; + } + + /** + * Revocation endpoint (RFC 7009): always succeeds. A refresh token also + * revokes its family's access tokens. + */ + revoke(token: string) { + const entry = this.tokens.get(token); + if (!entry) return; + if (entry.kind === "refresh") this.revokeFamily(entry.family); + else entry.revoked = true; + } + + /** Userinfo endpoint. Throws `OAuthError("invalid_token")` (401). */ + userinfo(accessToken: string): { sub: string; email?: string } { + const entry = this.validAccessToken(accessToken); + if (!entry) throw new OAuthError("invalid_token", "invalid or expired access token"); + return entry.scopes.includes("email") + ? { sub: this.opts.user.sub, email: this.opts.user.email } + : { sub: this.opts.user.sub }; + } + + private validAccessToken(token: string): TokenEntry | null { + const entry = this.tokens.get(token); + if (!entry || entry.kind !== "access" || entry.revoked) return null; + if (entry.expiresAt <= this.now()) return null; + return entry; + } + + /** + * Checks a sync API bearer token as the real server does: 401 when + * missing/unknown/expired, 403 without scope `sync` or for another client. + */ + authenticate(authorization: string | undefined) { + const m = /^Bearer\s+(\S+)$/i.exec(authorization ?? ""); + const entry = m ? this.validAccessToken(m[1]) : null; + if (!entry) throw new SyncProblem("invalid_token", "missing, invalid or expired access token"); + if (!entry.scopes.includes("sync") || !this.opts.clientIDs.includes(entry.clientID)) + throw new SyncProblem("invalid_token", "token lacks scope sync", 403); + } + + // ── Devices ── + + private assertNotRevoked(deviceID: string) { + if (this.devs.get(deviceID)?.revokedAt != null) throw new SyncProblem("device_revoked"); + } + + /** Register a device implicitly / bump `lastSeen`. */ + touchDevice(deviceID: string) { + this.putDevice(deviceID, {}); + } + + putDevice(deviceID: string, input: DeviceInput): Device { + this.assertNotRevoked(deviceID); + const now = this.now(); + const d = this.devs.get(deviceID) ?? { + id: deviceID, + name: null, + browser: null, + extVersion: null, + createdAt: now, + lastSeen: now, + revokedAt: null, + }; + d.name = input.name ?? d.name; + d.browser = input.browser ?? d.browser; + d.extVersion = input.extVersion ?? d.extVersion; + d.lastSeen = now; + this.devs.set(deviceID, d); + const { revokedAt: _, ...device } = d; + return clone(device); + } + + /** Live devices, most recently seen first. */ + listDevices(): Device[] { + return [...this.devs.values()] + .filter((d) => d.revokedAt === null) + .sort((a, b) => b.lastSeen - a.lastSeen) + .map(({ revokedAt: _, ...d }) => clone(d)); + } + + /** Returns false for an unknown or already revoked device. */ + revokeDevice(deviceID: string): boolean { + const d = this.devs.get(deviceID); + if (!d || d.revokedAt !== null) return false; + d.revokedAt = this.now(); + return true; + } + + // ── Records ── + + /** `GET /changes`. Throws 410 `cursor_expired` when 0 < since < purged rev. */ + changes(since = 0, limit: number = SYNC_LIMITS.changesDefaultLimit): ChangesResult { + const lim = Math.min(Math.max(limit, 1), SYNC_LIMITS.changesMaxLimit); + // since=0 is a full resync and always allowed (as on the real server) + if (since > 0 && since < this.purgedRev) throw new SyncProblem("cursor_expired"); + const rows = [...this.recs.values()] + .filter((r) => r.rev > since) + .sort((a, b) => a.rev - b.rev); + const page = rows.slice(0, lim); + return { + records: page.map(clone), + cursor: page.length ? page[page.length - 1].rev : Math.max(since, this.rev), + hasMore: rows.length > lim, + }; + } + + private totals() { + let n = 0; + let bytes = 0; + for (const r of this.recs.values()) { + n++; + if (!r.deleted) bytes += utf8Length(JSON.stringify(r.data)); + } + return { n, bytes }; + } + + /** + * `POST /records`: each record on its own, in order. Results keep request + * order per bucket; as on the real server, validation rejects come first. + */ + push(records: unknown[], deviceID = "00000000-0000-4000-8000-000000000000"): PushResult { + if (!Array.isArray(records) || records.length < 1 || records.length > SYNC_LIMITS.recordsPerPush) + throw new SyncProblem( + "invalid_request", + `Body must be { records: [...] } with 1 to ${SYNC_LIMITS.recordsPerPush} records` + ); + this.assertNotRevoked(deviceID); + + const invalid: PushResult["rejected"] = []; + const valid: PushRecord[] = []; + for (const input of records) { + const v = validatePushRecord(input); + if (v.ok) valid.push(v.record); + else invalid.push({ id: v.id, reason: v.reason }); + } + + const result: PushResult = { accepted: [], rejected: invalid }; + for (const rec of valid) { + if (hlcTime(rec.hlc) > this.now() + SYNC_LIMITS.clockSkewMs) { + result.rejected.push({ id: rec.id, reason: "clock_skew" }); + continue; + } + const stored = this.recs.get(rec.id); + if (stored && rec.hlc === stored.hlc) { + result.accepted.push({ id: rec.id, rev: stored.rev }); + continue; + } + if (stored && rec.hlc < stored.hlc) { + result.rejected.push({ id: rec.id, reason: "stale", current: clone(stored) }); + continue; + } + const data = rec.deleted ? null : rec.data; + const size = rec.deleted ? 0 : utf8Length(JSON.stringify(data)); + const totals = this.totals(); + const n = totals.n + (stored ? 0 : 1); + const storedSize = stored && !stored.deleted ? utf8Length(JSON.stringify(stored.data)) : 0; + if (n > this.opts.maxRecords || totals.bytes - storedSize + size > this.opts.maxBytes) { + result.rejected.push({ id: rec.id, reason: "quota_exceeded" }); + continue; + } + const row: SyncRecord = { + id: rec.id, + rev: ++this.rev, + hlc: rec.hlc, + deviceID, + deleted: rec.deleted, + schema: rec.schema, + encoding: rec.encoding, + data: clone(data), + }; + this.recs.set(rec.id, row); + result.accepted.push({ id: rec.id, rev: row.rev }); + } + return result; + } + + // ── Snapshots ── + + createSnapshot(createdBy: string, reason: SnapshotReason = "manual"): SnapshotMeta { + const records = this.records().filter((r) => !r.deleted); + const snap: Snapshot = { + id: randomUUID(), + createdAt: this.now(), + reason, + rev: this.rev, + recordCount: records.length, + createdBy, + records, + seq: ++this.snapSeq, + }; + this.snaps.push(snap); + return this.metaOf(snap); + } + + private metaOf({ id, createdAt, reason, rev, recordCount, createdBy }: Snapshot): SnapshotMeta { + return { id, createdAt, reason, rev, recordCount, createdBy }; + } + + /** Newest first. */ + listSnapshots(): SnapshotMeta[] { + return [...this.snaps] + .sort((a, b) => b.createdAt - a.createdAt || b.rev - a.rev || b.seq - a.seq) + .map((s) => this.metaOf(s)); + } + + private findSnapshot(id: string): Snapshot { + const snap = this.snaps.find((s) => s.id === id); + if (!snap) throw new SyncProblem("not_found", "snapshot"); + return snap; + } + + getSnapshot(id: string): { records: SyncRecord[] } { + return { records: clone(this.findSnapshot(id).records) }; + } + + /** + * Restore (§6.3): a `before-restore` snapshot, then every snapshot record + * and a tombstone for every live record absent from it, as new writes with + * HLC `max(maxStoredT + 1, now):0000:server`. Quotas are not enforced. + */ + restore(id: string, deviceID: string): { rev: number; written: number } { + this.assertNotRevoked(deviceID); + const snap = this.findSnapshot(id); + this.createSnapshot(deviceID, "before-restore"); + let maxT = 0; + for (const r of this.recs.values()) maxT = Math.max(maxT, hlcTime(r.hlc)); + const hlc = formatHlc(Math.max(maxT + 1, this.now()), 0, "server"); + + const keep = new Set(snap.records.map((r) => r.id)); + const live = [...this.recs.values()].filter((r) => !r.deleted && !keep.has(r.id)); + let written = 0; + for (const r of snap.records) { + this.recs.set(r.id, { ...clone(r), rev: ++this.rev, hlc, deviceID, deleted: false }); + written++; + } + for (const r of live) { + this.recs.set(r.id, { ...r, rev: ++this.rev, hlc, deviceID, deleted: true, data: null }); + written++; + } + return { rev: this.rev, written }; + } + + // ── HTTP dispatch (no sockets) ── + + /** Discovery metadata (RFC 8414) for a server at `origin`. */ + metadata(origin: string) { + const issuer = `${origin}/api/auth`; + return { + issuer, + authorization_endpoint: `${origin}${OAUTH_BASE_PATH}/authorize`, + token_endpoint: `${origin}${OAUTH_BASE_PATH}/token`, + revocation_endpoint: `${origin}${OAUTH_BASE_PATH}/revoke`, + userinfo_endpoint: `${origin}${OAUTH_BASE_PATH}/userinfo`, + scopes_supported: SUPPORTED_SCOPES, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + }; + } + + /** Answer one request. Never throws. */ + handle(req: SyncRequest): SyncResponse { + const url = new URL(req.url); + const method = req.method.toUpperCase(); + const entry: RequestLogEntry = { + method, + path: url.pathname, + query: Object.fromEntries(url.searchParams), + status: 0, + device: req.headers["x-proxyverse-device"], + version: req.headers["x-proxyverse-version"], + time: Date.now(), + }; + if (req.body) { + try { + entry.body = (req.headers["content-type"] ?? "").includes("application/x-www-form-urlencoded") + ? Object.fromEntries(new URLSearchParams(req.body)) + : JSON.parse(req.body); + } catch { + entry.body = req.body; + } + } + + let res: SyncResponse; + try { + res = this.route(method, url, req); + } catch (err) { + if (err instanceof SyncProblem) res = problemResponse(err); + else if (err instanceof OAuthError) res = jsonResponse(err.body, err.status); + else + res = problemResponse( + new SyncProblem("unavailable", err instanceof Error ? err.message : String(err), 503) + ); + } + entry.status = res.status; + if (res.status >= 400) { + try { + const body = JSON.parse(res.body); + entry.code = body.code ?? body.error; + } catch { + /* not JSON */ + } + } + this.log.push(entry); + return res; + } + + /** + * A `fetch` drop-in over `handle()`, for unit tests: absolute URLs only; + * the origin is ignored except for discovery metadata. + */ + fetch = async (input: string | URL | Request, init?: RequestInit): Promise => { + const request = new Request(input, init); + const headers: Record = {}; + request.headers.forEach((v, k) => (headers[k] = v)); + const body = request.body ? await request.text() : undefined; + const res = this.handle({ method: request.method, url: request.url, headers, body }); + const nullBody = res.status === 204 || res.status === 304 || (res.status >= 300 && res.status < 400 && !res.body); + return new Response(nullBody ? null : res.body, { status: res.status, headers: res.headers }); + }; + + private takeFailure(method: string, path: string): Failure | undefined { + const i = this.failures.findIndex( + (f) => (!f.path || path.includes(f.path)) && (!f.method || f.method.toUpperCase() === method) + ); + return i < 0 ? undefined : this.failures.splice(i, 1)[0]; + } + + private route(method: string, url: URL, req: SyncRequest): SyncResponse { + const path = url.pathname.replace(/\/+$/, "") || "/"; + + const failure = this.takeFailure(method, path); + if (failure) + throw new SyncProblem(failure.code, failure.detail, failure.status, failure.retryAfter); + + if (path === "/.well-known/oauth-authorization-server" && method === "GET") + return jsonResponse(this.metadata(url.origin)); + + if (path.startsWith(OAUTH_BASE_PATH + "/")) return this.routeOAuth(method, path.slice(OAUTH_BASE_PATH.length), url, req); + + if (path.startsWith(SYNC_BASE_PATH + "/")) return this.routeSync(method, path.slice(SYNC_BASE_PATH.length), url, req); + + throw new SyncProblem("not_found", `no route for ${method} ${path}`); + } + + private routeOAuth(method: string, sub: string, url: URL, req: SyncRequest): SyncResponse { + // Clients must not send byte.vet's cookies. byte.vet's auth server + // refuses cookie-bearing cross-site POSTs (its token endpoint did until + // it learned to ignore them); enforcing it here keeps the client honest. + if (method === "POST" && req.headers.cookie) { + return jsonResponse({ message: "Missing or null Origin", code: "MISSING_OR_NULL_ORIGIN" }, 403); + } + const form = () => Object.fromEntries(new URLSearchParams(req.body ?? "")) as Record; + if (sub === "/authorize" && method === "GET") { + const q = url.searchParams; + const location = this.authorize({ + client_id: q.get("client_id"), + redirect_uri: q.get("redirect_uri"), + code_challenge: q.get("code_challenge"), + code_challenge_method: q.get("code_challenge_method"), + state: q.get("state"), + scope: q.get("scope"), + response_type: q.get("response_type"), + }); + // signing in leaves a session cookie, as on byte.vet + return { + status: 302, + headers: { + location, + "cache-control": "no-store", + "set-cookie": "e2e.session_token=signed-in; Path=/; HttpOnly; SameSite=Lax", + }, + body: "", + }; + } + if (sub === "/token" && method === "POST") return jsonResponse(this.token(form())); + if (sub === "/revoke" && method === "POST") { + this.revoke(form().token ?? ""); + return { status: 200, headers: { "cache-control": "no-store" }, body: "" }; + } + if (sub === "/userinfo" && (method === "GET" || method === "POST")) { + const m = /^Bearer\s+(\S+)$/i.exec(req.headers.authorization ?? ""); + return jsonResponse(this.userinfo(m?.[1] ?? "")); + } + throw new OAuthError("invalid_request", `no route for ${method} ${sub}`, 404); + } + + private routeSync(method: string, sub: string, url: URL, req: SyncRequest): SyncResponse { + // same order as the real server: bearer, device header, version, liveness + this.authenticate(req.headers.authorization); + const deviceID = req.headers["x-proxyverse-device"]; + if (!isUUID(deviceID)) throw new SyncProblem("invalid_request", "X-Proxyverse-Device must be a UUID"); + const version = req.headers["x-proxyverse-version"]; + if (!version) throw new SyncProblem("invalid_request", "X-Proxyverse-Version is required"); + if (this.opts.minVersion && compareVersions(version, this.opts.minVersion) < 0) + throw new SyncProblem("client_too_old", `Proxyverse ${this.opts.minVersion} or newer is required to sync`); + + if (req.body && utf8Length(req.body) > SYNC_LIMITS.requestBodyBytes) + throw new SyncProblem("invalid_request", "Request body exceeds 2 MiB"); + const json = (): unknown => { + if (!req.body) return null; + try { + return JSON.parse(req.body); + } catch { + throw new SyncProblem("invalid_request", "Body is not valid JSON"); + } + }; + + const deviceMatch = /^\/devices\/([^/]+)$/.exec(sub); + // PUT /devices/{id} registers the device itself + if (!(deviceMatch && method === "PUT")) this.touchDevice(deviceID); + + if (sub === "/devices" && method === "GET") return jsonResponse(this.listDevices()); + + if (deviceMatch) { + const id = decodeURIComponent(deviceMatch[1]); + if (!isUUID(id)) throw new SyncProblem("invalid_request", "deviceID must be a UUID"); + if (method === "PUT") { + const body = json(); + if (!isPlainObject(body)) throw new SyncProblem("invalid_request", "Body must be an object"); + const field = (k: keyof DeviceInput) => { + const v = body[k]; + if (v === undefined || v === null) return undefined; + if (typeof v !== "string" || v.length > 200) + throw new SyncProblem("invalid_request", `${k} must be a string of at most 200 characters`); + return v; + }; + return jsonResponse( + this.putDevice(id, { name: field("name"), browser: field("browser"), extVersion: field("extVersion") }) + ); + } + if (method === "DELETE") { + if (!this.revokeDevice(id)) throw new SyncProblem("not_found", "Unknown device"); + return { status: 204, headers: {}, body: "" }; + } + } + + if (sub === "/changes" && method === "GET") { + const int = (name: string, fallback: number) => { + const v = url.searchParams.get(name); + if (v === null || v === "") return fallback; + if (!/^\d+$/.test(v)) throw new SyncProblem("invalid_request", `${name} must be a non-negative integer`); + return Number(v); + }; + return jsonResponse(this.changes(int("since", 0), int("limit", SYNC_LIMITS.changesDefaultLimit))); + } + + if (sub === "/records" && method === "POST") { + const body = json(); + if (!isPlainObject(body) || !Array.isArray(body.records)) + throw new SyncProblem("invalid_request", "Body must be { records: [...] } with 1 to 100 records"); + return jsonResponse(this.push(body.records, deviceID)); + } + + if (sub === "/snapshots" && method === "GET") return jsonResponse(this.listSnapshots()); + if (sub === "/snapshots" && method === "POST") { + const body = json(); + const reason = isPlainObject(body) && body.reason === "before-overwrite" ? "before-overwrite" : "manual"; + return jsonResponse(this.createSnapshot(deviceID, reason), 201); + } + + const snap = /^\/snapshots\/([^/]+)(\/restore)?$/.exec(sub); + if (snap) { + const id = decodeURIComponent(snap[1]); + if (!snap[2] && method === "GET") return jsonResponse(this.getSnapshot(id)); + if (snap[2] && method === "POST") return jsonResponse(this.restore(id, deviceID)); + } + + throw new SyncProblem("not_found", `no route for ${method} ${sub}`); + } +} + +function jsonResponse(body: unknown, status = 200): SyncResponse { + return { + status, + headers: { "content-type": "application/json", "cache-control": "no-store" }, + body: JSON.stringify(body), + }; +} + +function problemResponse(p: SyncProblem): SyncResponse { + const headers: Record = { + "content-type": "application/problem+json", + "cache-control": "no-store", + }; + if (p.status === 401 && p.code === "invalid_token") headers["www-authenticate"] = 'Bearer error="invalid_token"'; + if (p.retryAfter !== undefined || p.status === 429 || p.status === 503) + headers["retry-after"] = String(Math.max(0, Math.ceil(p.retryAfter ?? (p.status === 429 ? 60 : 30)))); + return { status: p.status, headers, body: JSON.stringify(p.body) }; +} diff --git a/e2e/specs/popup.e2e.ts b/e2e/specs/popup.e2e.ts index 3399c24..d64d189 100644 --- a/e2e/specs/popup.e2e.ts +++ b/e2e/specs/popup.e2e.ts @@ -83,9 +83,9 @@ test.describe("popup", () => { await sw.setProfiles([proxyA]); const popup = await openPopup(); - const home = await popup.openNewTab("popup-config"); - await expect(home).toHaveURL(/#\/profiles\/new$/); // home redirects there - await home.close(); + const preference = await popup.openNewTab("popup-config"); + await expect(preference).toHaveURL(/#\/preference$/); + await preference.close(); await popup.select("proxyA"); const editor = await popup.openNewTab("popup-config"); diff --git a/e2e/specs/preference.e2e.ts b/e2e/specs/preference.e2e.ts index 33c16f2..c594d67 100644 --- a/e2e/specs/preference.e2e.ts +++ b/e2e/specs/preference.e2e.ts @@ -1,6 +1,6 @@ import { test, expect } from "../fixtures"; import { expectRoute } from "../fixtures/routing"; -import { chooseOption, toast } from "../pages/arco"; +import { chooseOption, popconfirmOK, toast } from "../pages/arco"; import { Preference } from "../pages/preference"; import { autoProfile, @@ -92,9 +92,7 @@ test.describe("preference", () => { await expect(page.getByText("界面", { exact: true })).toBeVisible(); // Arco's own texts follow await importButton.click(); - await expect( - page.locator(".arco-popconfirm:visible .arco-btn-primary") - ).toHaveText("确定"); + await expect(popconfirmOK(page)).toHaveText("确定"); // so do the other open pages await expect(popup.page.getByTestId("popup-profile-direct")).toHaveText( "直接连接" diff --git a/e2e/specs/sync.e2e.ts b/e2e/specs/sync.e2e.ts new file mode 100644 index 0000000..cc83cb6 --- /dev/null +++ b/e2e/specs/sync.e2e.ts @@ -0,0 +1,195 @@ +import type { SyncSettings } from "@/services/sync/state"; +import { test, expect } from "../fixtures"; +import { expectRoute } from "../fixtures/routing"; +import { proxyProfile, server, standardProfiles } from "../data/profiles"; +import { + CLIENT_ID, + expectSynced, + remoteProfile, + signInDirectly, + SyncSection, + useFakeByteVet, +} from "../pages/sync"; + +// Cloud sync against the fake byte.vet (e2e/servers/syncApi.ts). It runs on +// loopback, so sync traffic never goes through the profile under test. + +test.describe("sync", () => { + test("signs in through the browser's auth window and uploads the profiles", async ({ + sw, + servers, + openConfig, + }) => { + const { proxyA } = standardProfiles(servers); + await sw.setProfiles([proxyA]); + await useFakeByteVet(sw, servers); + + const page = await openConfig("/preference"); + const sync = new SyncSection(page); + await sync.byId("pref-sync-signin").click(); + + await expect(sync.byId("pref-sync-account")).toHaveText( + servers.sync.user.email + ); + await expect + .poll(() => servers.sync.get("profile:proxyA")?.data) + .toMatchObject({ profileID: "proxyA", profileName: "Proxy A" }); + await expect(sync.byId("pref-sync-status")).toContainText(/Last synced/); + + // the PKCE exchange really happened, as the dev client + const token = servers.sync.requests("/oauth2/token", "POST")[0]; + expect(token.body).toMatchObject({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + }); + }); + + test("pushes a local edit a moment after it's saved", async ({ + sw, + servers, + }) => { + const { proxyA } = standardProfiles(servers); + await sw.setProfiles([proxyA]); + await signInDirectly(sw, servers); + await expect.poll(() => servers.sync.get("profile:proxyA")).toBeDefined(); + + await sw.setProfiles([{ ...proxyA, profileName: "Renamed" }]); + await expect + .poll(() => servers.sync.get("profile:proxyA")?.data) + .toMatchObject({ profileName: "Renamed" }); + }); + + test("applies remote changes on Sync now and on the poll alarm", async ({ + sw, + servers, + openConfig, + }) => { + const { proxyA, proxyB } = standardProfiles(servers); + await signInDirectly(sw, servers); + await expectSynced(sw); + expect((await sw.getAlarms()).map((a) => a.name)).toContain("sync:poll"); + + const page = await openConfig("/preference"); + servers.sync.seed([remoteProfile(proxyA)]); + await new SyncSection(page).syncNow(); + await expect.poll(async () => Object.keys(await sw.getProfiles())).toEqual(["proxyA"]); + + servers.sync.seed([remoteProfile(proxyB)]); + await sw.fireAlarm("sync:poll"); + await expect + .poll(async () => Object.keys(await sw.getProfiles()).sort()) + .toEqual(["proxyA", "proxyB"]); + }); + + test("a remote deletion of the active profile switches to direct", async ({ + sw, + servers, + page, + openConfig, + activate, + }) => { + const { proxyA } = standardProfiles(servers); + await sw.setProfiles([proxyA]); + await signInDirectly(sw, servers); + await expect.poll(() => servers.sync.get("profile:proxyA")).toBeDefined(); + + await activate("proxyA"); + const site = servers.url("sync-delete.test"); + await expectRoute(page, site, "proxyA"); + + servers.sync.seed([remoteProfile(proxyA, true)]); + await new SyncSection(await openConfig("/preference")).syncNow(); + await expect.poll(() => sw.getProfiles()).toEqual({}); + await expectRoute(page, site, "direct"); + }); + + test("a local-only profile stays on this device", async ({ + sw, + servers, + openEditor, + }) => { + const { proxyA, proxyB } = standardProfiles(servers); + await useFakeByteVet(sw, servers); + await sw.eval( + () => chrome.storage.local.set({ "sync.settings": { syncPrefs: true, localOnly: ["proxyA"] } }) + ); + await sw.setProfiles([proxyA, proxyB]); + await signInDirectly(sw, servers); + await expect.poll(() => servers.sync.get("profile:proxyB")).toBeDefined(); + expect(servers.sync.get("profile:proxyA")).toBeUndefined(); + + // marking a synced profile local-only in the editor detaches it + const editor = await openEditor("proxyB"); + await editor.byId("profile-local-only").click(); + await editor.saveOK(); + await expect + .poll(async () => ((await sw.getLocal("sync.settings")) as SyncSettings | undefined)?.localOnly) + .toEqual(["proxyA", "proxyB"]); + + await sw.setProfiles([proxyA, { ...proxyB, profileName: "Private now" }]); + // nothing to push, so the run after the change records nothing: sync + // explicitly to be sure the edit had its chance to leave + const since = Date.now(); + await editor.page.goto(`${sw.origin}/index.html#/preference`); + await new SyncSection(editor.page).syncNow(); + await expectSynced(sw, since); + expect(servers.sync.get("profile:proxyB")?.data).toMatchObject({ + profileName: "Proxy B", + }); + }); + + test("a change made just before the service worker stops still syncs", async ({ + sw, + servers, + }) => { + await signInDirectly(sw, servers); + await expectSynced(sw); + + const p = proxyProfile({ profileID: "late" }, { default: server(servers.ports.proxyA) }); + await sw.setProfiles([p]); + await sw.restart(); // before the 2 s debounce fires + await expect.poll(() => servers.sync.get("profile:late")).toBeDefined(); + }); + + test("asks how to combine data on the first sign-in, then merges", async ({ + sw, + servers, + openConfig, + }) => { + const { proxyA, proxyB } = standardProfiles(servers); + servers.sync.seed([remoteProfile(proxyB)]); + await sw.setProfiles([proxyA]); + await signInDirectly(sw, servers); + + const page = await openConfig("/preference"); + const sync = new SyncSection(page); + await expect(sync.byId("pref-sync-first-choice")).toBeVisible(); + expect(servers.sync.get("profile:proxyA")).toBeUndefined(); + + await sync.byId("pref-sync-choice-merge").click(); + await expect(sync.byId("pref-sync-first-choice")).toBeHidden(); + await expect + .poll(async () => Object.keys(await sw.getProfiles()).sort()) + .toEqual(["proxyA", "proxyB"]); + expect(servers.sync.get("profile:proxyA")).toBeDefined(); + }); + + test("restores a snapshot", async ({ sw, servers, openConfig }) => { + const { proxyA } = standardProfiles(servers); + await sw.setProfiles([proxyA]); + await signInDirectly(sw, servers); + await expect.poll(() => servers.sync.get("profile:proxyA")).toBeDefined(); + const snapshot = servers.sync.createSnapshot("e2e"); + + await sw.setProfiles([]); + await expect + .poll(() => servers.sync.get("profile:proxyA")?.deleted) + .toBe(true); + + const page = await openConfig("/preference"); + const sync = new SyncSection(page); + await sync.byId("pref-sync-snapshots").click(); + await sync.confirm(`pref-sync-restore-${snapshot.id}`); + await expect.poll(async () => Object.keys(await sw.getProfiles())).toEqual(["proxyA"]); + }); +}); diff --git a/manifest.json b/manifest.json index dfebb43..9bf707a 100644 --- a/manifest.json +++ b/manifest.json @@ -28,6 +28,7 @@ "webRequestAuthProvider", "activeTab", "alarms", - "sidePanel" + "sidePanel", + "identity" ] } diff --git a/package.json b/package.json index 381ce97..f7ec066 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,11 @@ "test:e2e": "playwright test -c e2e", "test:e2e:chromium": "E2E_TARGET=chrome playwright test -c e2e --project chromium", "test:e2e:firefox": "E2E_TARGET=firefox playwright test -c e2e --project firefox-smoke", - "test:e2e:typecheck": "tsc -p e2e" + "test:e2e:typecheck": "tsc -p e2e", + "test:e2e:live": "playwright test -c e2e/live", + "sync:api-types": "node scripts/sync-api-types.mjs", + "build:preview": "PROXYVERSE_ENV=northernlight npm run build", + "build:preview:firefox": "PROXYVERSE_ENV=northernlight npm run build:firefox -- --outDir dist-firefox --emptyOutDir" }, "directories": { "test": "tests" diff --git a/public/_locales/ar/messages.json b/public/_locales/ar/messages.json index 8e61c9f..b3b5add 100644 --- a/public/_locales/ar/messages.json +++ b/public/_locales/ar/messages.json @@ -361,14 +361,6 @@ "message": "الاستيراد والتصدير" }, - "preferences_section_import_export_desc": { - "message": "صدّر ملفات تعريف الوكيل إلى ملف للنسخ الاحتياطي أو المشاركة، أو استوردها لاستعادتها عند الحاجة." - }, - - "preferences_section_import_switchyomega": { - "message": "يقبل خيار «استيراد ملفات التعريف» أيضًا نسخة احتياطية من ProxySwitchyOmega أو ZeroOmega بامتداد (.bak). تظهر بعد الاستيراد قائمة بكل ما تعذّر تحويله بدقة." - }, - "preferences_tag_beta": { "message": "تجريبي" }, diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json index 690abde..d27b615 100644 --- a/public/_locales/en/messages.json +++ b/public/_locales/en/messages.json @@ -360,14 +360,23 @@ "preferences_section_import_export": { "message": "Import & Export" }, - - "preferences_section_import_export_desc": { - "message": "Export your proxy profiles to a file for backup or sharing, or import them to restore profiles when needed." + "preferences_export": { + "message": "Export" }, - - "preferences_section_import_switchyomega": { - "message": "Import Profiles also accepts a ProxySwitchyOmega / ZeroOmega backup (.bak). Anything that can't be converted exactly is listed after the import." + "preferences_export_hint": { + "message": "Save your proxy profiles to a file, for backup or sharing." + }, + "preferences_import": { + "message": "Import" + }, + "preferences_import_hint": { + "message": "Restore profiles from a Proxyverse file." }, + "preferences_import_switchyomega_hint": { + "message": "Also reads ProxySwitchyOmega / ZeroOmega backups (.bak). Anything that can't be converted exactly is listed after the import." + }, + + "preferences_tag_beta": { "message": "Beta" @@ -468,6 +477,157 @@ "message": "
Due to Firefox's restrictions, Proxyverse does not work properly as it's not enabled in private browsing windows.
\n
Please enable the extension in private windows.
\n" }, + "preferences_section_sync": { + "message": "Sync" + }, + "sync_desc": { + "message": "Sync your profiles and preferences across browsers and devices with a byte.vet account." + }, + "sync_privacy_note": { + "message": "Profiles sync with everything in them, including proxy usernames and passwords. They're sent over an encrypted connection, stored encrypted, and used only to keep your devices in sync. Mark a profile \"This device only\" to keep it off the cloud." + }, + "sync_action_signin": { + "message": "Sign in to sync" + }, + "sync_signin_expired": { + "message": "The sign-in of $1 expired. Sign in again to keep syncing." + }, + "sync_account": { + "message": "Account" + }, + "sync_account_unknown": { + "message": "Signed in" + }, + "sync_action_signout": { + "message": "Sign out" + }, + "sync_signout_confirm": { + "message": "Stop syncing on this browser? Your profiles stay here." + }, + "sync_status": { + "message": "Status" + }, + "sync_action_now": { + "message": "Sync now" + }, + "sync_time_recent": { + "message": "a few seconds ago" + }, + "sync_status_never": { + "message": "Not synced yet." + }, + "sync_status_ok": { + "message": "Last synced $1." + }, + "sync_status_failed": { + "message": "$1 item(s) couldn't be synced: the server refused them. Edit them to try again." + }, + "sync_status_held": { + "message": "$1 item(s) were synced by a newer Proxyverse and can't be read by this version. Update Proxyverse to use them." + }, + "sync_error_auth": { + "message": "Sync stopped $1: sign in again." + }, + "sync_error_clock_skew": { + "message": "Synced $1, but some changes were refused because this device's clock is ahead. Check the system clock." + }, + "sync_error_unreachable": { + "message": "Sync failed $1: byte.vet can't be reached. Proxyverse will retry." + }, + "sync_error_client_too_old": { + "message": "Sync stopped $1: this version of Proxyverse is too old. Update it to keep syncing." + }, + "sync_error_quota": { + "message": "Sync failed $1: the account is full. Delete some profiles." + }, + "sync_error_other": { + "message": "Sync failed $1 ($2)." + }, + "sync_prefs": { + "message": "Sync preferences" + }, + "sync_prefs_hint": { + "message": "Theme, language and toolbar button behavior. Turn off to keep this browser's own." + }, + "sync_snapshots": { + "message": "Snapshots" + }, + "sync_snapshots_hint": { + "message": "byte.vet keeps copies of your synced data, so a bad change can be undone." + }, + "sync_action_snapshots": { + "message": "Restore…" + }, + "sync_snapshots_desc": { + "message": "Restoring a snapshot replaces the synced data on every device. A snapshot of the current data is taken first." + }, + "sync_snapshot_meta": { + "message": "$1, $2 item(s)" + }, + "sync_snapshot_reason_daily": { + "message": "Daily" + }, + "sync_snapshot_reason_manual": { + "message": "Manual" + }, + "sync_snapshot_reason_before_restore": { + "message": "Before a restore" + }, + "sync_snapshot_reason_before_overwrite": { + "message": "Before an upload" + }, + "sync_action_restore": { + "message": "Restore" + }, + "sync_restore_confirm": { + "message": "Replace the synced data on every device with this snapshot?" + }, + "sync_restore_done": { + "message": "Restored. Other devices get it when they next sync." + }, + "sync_first_title": { + "message": "Your account already has data" + }, + "sync_first_desc": { + "message": "Both this browser and your byte.vet account have profiles. How should they be combined?" + }, + "sync_first_merge": { + "message": "Merge both" + }, + "sync_first_merge_hint": { + "message": "Keep everything. Where both have the same profile, this browser's wins." + }, + "sync_first_cloud": { + "message": "Use the account's data" + }, + "sync_first_cloud_hint": { + "message": "Replace this browser's profiles with the account's." + }, + "sync_first_cloud_confirm": { + "message": "Profiles that are only in this browser will be deleted. Continue?" + }, + "sync_first_backup": { + "message": "Download a backup first" + }, + "sync_first_upload": { + "message": "Upload this browser's data" + }, + "sync_first_upload_hint": { + "message": "Replace the account's profiles with this browser's, on every device. A snapshot is taken first." + }, + "sync_first_upload_confirm": { + "message": "Profiles that are only in the account will be deleted on every device. Continue?" + }, + "profile_local_only": { + "message": "This device only" + }, + "profile_local_only_hint": { + "message": "Never sync this profile. Other devices keep the copy they already have." + }, + "profile_local_only_referenced": { + "message": "Synced profiles route to this one: $1. On other devices those rules will point to a missing profile." + }, + "_": { "message": "" } diff --git a/public/_locales/pt_BR/messages.json b/public/_locales/pt_BR/messages.json index 9a763a3..9c64862 100644 --- a/public/_locales/pt_BR/messages.json +++ b/public/_locales/pt_BR/messages.json @@ -361,14 +361,6 @@ "message": "Importação e exportação" }, - "preferences_section_import_export_desc": { - "message": "Exporte seus perfis de proxy para um arquivo para backup ou compartilhamento, ou importe-os para restaurar perfis quando necessário." - }, - - "preferences_section_import_switchyomega": { - "message": "\"Importar perfis\" também aceita um backup do ProxySwitchyOmega / ZeroOmega (.bak). O que não puder ser convertido exatamente será listado após a importação." - }, - "preferences_tag_beta": { "message": "Beta" }, diff --git a/public/_locales/zh_CN/messages.json b/public/_locales/zh_CN/messages.json index c5e9de1..22accd2 100644 --- a/public/_locales/zh_CN/messages.json +++ b/public/_locales/zh_CN/messages.json @@ -361,14 +361,6 @@ "message": "导入 / 导出" }, - "preferences_section_import_export_desc": { - "message": "将代理配置导出成文件以便进行备份或分享,也可导入文件还原配置。" - }, - - "preferences_section_import_switchyomega": { - "message": "“导入配置”也支持 ProxySwitchyOmega / ZeroOmega 的备份文件(.bak)。无法完全转换的内容会在导入后列出。" - }, - "preferences_tag_beta": { "message": "Beta" }, diff --git a/public/_locales/zh_TW/messages.json b/public/_locales/zh_TW/messages.json index 0d3b8b2..65b74fe 100644 --- a/public/_locales/zh_TW/messages.json +++ b/public/_locales/zh_TW/messages.json @@ -361,14 +361,6 @@ "message": "匯入與匯出設定檔" }, - "preferences_section_import_export_desc": { - "message": "將您的代理伺服器設定檔匯出至檔案,以便備份或分享,並可匯入以還原。" - }, - - "preferences_section_import_switchyomega": { - "message": "「匯入設定檔」也支援 ProxySwitchyOmega / ZeroOmega 的備份檔(.bak)。無法完整轉換的項目會在匯入後列出。" - }, - "preferences_tag_beta": { "message": "Beta" }, diff --git a/scripts/sync-api-types.mjs b/scripts/sync-api-types.mjs new file mode 100644 index 0000000..2501509 --- /dev/null +++ b/scripts/sync-api-types.mjs @@ -0,0 +1,32 @@ +// Regenerates src/services/sync/api.gen.ts from the vendored sync API +// contract (docs/sync-api.openapi.yaml), and stamps it with the contract's +// SHA-256 so a unit test notices when the two drift apart. +// +// openapi-typescript runs through npx rather than as a dependency: its +// TypeScript peer is 5.x and this repo is on 6.x. + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const SPEC = "docs/sync-api.openapi.yaml"; +const OUT = "src/services/sync/api.gen.ts"; + +execFileSync( + "npx", + ["-y", "openapi-typescript@7.13.0", SPEC, "-o", OUT], + { cwd: root, stdio: "inherit" } +); + +const hash = createHash("sha256") + .update(readFileSync(`${root}/${SPEC}`)) + .digest("hex"); +const generated = readFileSync(`${root}/${OUT}`, "utf8"); +writeFileSync( + `${root}/${OUT}`, + `// Generated from ${SPEC} by \`npm run sync:api-types\`.\n` + + `// openapi-sha256: ${hash}\n` + + generated +); diff --git a/src/adapters/base.ts b/src/adapters/base.ts index e33e490..b9caaea 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -91,6 +91,27 @@ export function setMessageOverride(override: MessageOverride | undefined) { messageOverride = override; } +/** The part of the browsers' `identity` API sign-in uses. */ +type IdentityAPI = { + launchWebAuthFlow(details: { + url: string; + interactive: boolean; + }): Promise; + getRedirectURL(path: string): string; +}; + +export const IDENTITY_UNAVAILABLE = + "The identity API is unavailable in this browser"; + +/** What this extension build runs on; sync names devices after it. */ +export type ExtensionInfo = { + /** The manifest version, e.g. "2.3.0". */ + version: string; + browser: "chrome" | "edge" | "firefox" | "web"; + /** runtime.getPlatformInfo's os, e.g. "mac", "win", "linux". */ + os: string; +}; + export type ActionBehavior = "popup" | "sidebar"; // the pages the toolbar icon opens @@ -119,6 +140,7 @@ export abstract class BaseAdapter { // local storage abstract set(key: string, val: T): Promise; abstract get(key: string): Promise; + abstract remove(key: string): Promise; async getWithDefault(key: string, defaultVal: T): Promise { const ret = await this.get(key); if (ret === undefined) { @@ -192,6 +214,30 @@ export abstract class BaseAdapter { handleToolbarClicks(): void {} abstract reloadTab(tabID: number): Promise; + // identity: sign-in through a browser-hosted web auth flow (cloud sync). + // Chrome and Firefox share the API; it's absent where a browser lacks it + // (e.g. Firefox for Android, some Chromium forks). + protected identity(): IdentityAPI | undefined { + return undefined; + } + + /** Resolves with the final redirect URL of the flow. */ + async launchWebAuthFlow(url: string, interactive: boolean): Promise { + const identity = this.identity(); + if (!identity) throw new Error(IDENTITY_UNAVAILABLE); + const redirect = await identity.launchWebAuthFlow({ url, interactive }); + if (!redirect) throw new Error("The web auth flow returned no redirect URL"); + return redirect; + } + + /** The extension's redirect URL for `path`, e.g. "/oauth". */ + getRedirectURL(path: string): string { + const identity = this.identity(); + if (!identity) throw new Error(IDENTITY_UNAVAILABLE); + return identity.getRedirectURL(path); + } + abstract extensionInfo(): Promise; + // messages abstract onMessage( callback: ( diff --git a/src/adapters/chrome.ts b/src/adapters/chrome.ts index b0b3601..de281ac 100644 --- a/src/adapters/chrome.ts +++ b/src/adapters/chrome.ts @@ -15,6 +15,7 @@ import { WebRequestErrorOccurredDetails, WebRequestResponseStartedDetails, ActionBehavior, + ExtensionInfo, POPUP_PAGE, SIDEBAR_PAGE, } from "./base"; @@ -35,6 +36,10 @@ export class Chrome extends BaseAdapter { return ret[key] as T | undefined; } + async remove(key: string): Promise { + await chrome.storage.local.remove(key); + } + onLocalStorageChanged( callback: (changes: Record) => void ): () => void { @@ -153,6 +158,21 @@ export class Chrome extends BaseAdapter { await chrome.tabs.reload(tabID); } + protected identity() { + // typed as always there, but absent where the browser lacks the API + return chrome.identity as typeof chrome.identity | undefined; + } + + async extensionInfo(): Promise { + const { os } = await chrome.runtime.getPlatformInfo(); + return { + version: chrome.runtime.getManifest().version, + // Edge runs this adapter too; only its user agent tells them apart + browser: navigator.userAgent.includes("Edg/") ? "edge" : "chrome", + os, + }; + } + onMessage( callback: ( message: any, diff --git a/src/adapters/firefox.ts b/src/adapters/firefox.ts index 59f95e7..d9b117c 100644 --- a/src/adapters/firefox.ts +++ b/src/adapters/firefox.ts @@ -13,6 +13,7 @@ import { WebRequestErrorOccurredDetails, WebRequestResponseStartedDetails, ActionBehavior, + ExtensionInfo, POPUP_PAGE, } from "./base"; @@ -31,6 +32,10 @@ export class Firefox extends BaseAdapter { return ret[key] as T | undefined; } + async remove(key: string): Promise { + await browser.storage.local.remove(key); + } + onLocalStorageChanged( callback: (changes: Record) => void ): () => void { @@ -181,6 +186,20 @@ export class Firefox extends BaseAdapter { await browser.tabs.reload(tabID); } + protected identity() { + // typed as always there, but absent where the browser lacks the API + return browser.identity as typeof browser.identity | undefined; + } + + async extensionInfo(): Promise { + const { os } = await browser.runtime.getPlatformInfo(); + return { + version: browser.runtime.getManifest().version, + browser: "firefox", + os, + }; + } + onMessage( callback: ( message: any, diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 6f0128d..e608174 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -31,4 +31,5 @@ export type { SimpleProxyServer, PacScript, ProxyRules, + ExtensionInfo, } from "./base"; diff --git a/src/adapters/web.ts b/src/adapters/web.ts index 596e262..c7b2b57 100644 --- a/src/adapters/web.ts +++ b/src/adapters/web.ts @@ -4,6 +4,7 @@ import { BaseAdapter, BlockingResponse, BrowserFlavor, + ExtensionInfo, MessageSender, ProxyConfig, ProxyErrorDetails, @@ -42,6 +43,12 @@ export class WebBrowser extends BaseAdapter { s = localStorage.getItem(key); return s && JSON.parse(s); } + async remove(key: string): Promise { + const oldValue = await this.get(key); + localStorage.removeItem(key); + const changes = { [key]: { newValue: undefined, oldValue } }; + this.storageListeners.forEach((cb) => cb(changes)); + } onLocalStorageChanged( callback: (changes: Record) => void @@ -136,6 +143,11 @@ export class WebBrowser extends BaseAdapter { async reloadTab(tabID: number): Promise { console.log(`Reload tab: ${tabID}`); } + + + async extensionInfo(): Promise { + return { version: "0.0.0", browser: "web", os: "unknown" }; + } async getTab(_tabID: number): Promise { return this.getActiveTab(); } diff --git a/src/background.ts b/src/background.ts index 8b9a81e..eb01214 100644 --- a/src/background.ts +++ b/src/background.ts @@ -30,6 +30,7 @@ import { getActionBehavior, onActionBehaviorChanged, } from "./services/ui"; +import { initSync } from "./services/sync/background"; // indicator const activeProfile = async () => @@ -85,6 +86,9 @@ function initPacRefresh() { initPacRefresh(); +// cloud sync of profiles and preferences through byte.vet +initSync(); + // proxy auth provider class ProxyAuthProvider { // requests[requestID] = request attempts. 0 means the 1st attempt diff --git a/src/components/PreferencePage.vue b/src/components/PreferencePage.vue index 3574da5..ba1449a 100644 --- a/src/components/PreferencePage.vue +++ b/src/components/PreferencePage.vue @@ -19,6 +19,7 @@ import { } from "@/services/ui"; import { useFileDialog } from "@vueuse/core"; import IconBrowserPanel from "@/components/icons/IconBrowserPanel.vue"; +import SyncSection from "@/components/sync/SyncSection.vue"; import { Notification } from "@arco-design/web-vue"; import { IconSettings, @@ -168,12 +169,13 @@ const exportProfiles = async () => { -
- {{ - $t("preferences_section_interface") - }} + + +
+ {{ + $t("preferences_section_interface") + }} - { - -
- -
- {{ - $t("preferences_section_import_export") - }} - - {{ - $t("preferences_section_import_export_desc") - }} - - - {{ - $t("preferences_tag_beta") - }} - {{ $t("preferences_section_import_switchyomega") }} - +
- - - - {{ $t("preferences_action_profile_export") }} - +
+ {{ + $t("preferences_section_import_export") + }} - - + - {{ $t("preferences_action_profile_import") }} + {{ $t("preferences_action_profile_export") }} - - -
+ + + + + + + {{ $t("preferences_action_profile_import") }} + + + + +
+ + + (); const editing = computed( () => newProfileMode || + localOnly.value !== savedLocalOnly.value || (savedSnapshot.value !== undefined && savedSnapshot.value !== snapshot()) ); +// "This device only" (spec §5.6): a sync setting of this device, not part of +// the profile, so it never reaches exports or other devices. Shown once +// signed in to sync. +const syncStatus = useSyncStatus(); +const localOnly = ref(false); +const savedLocalOnly = ref(false); +// names of synced auto switch profiles that route to this one +const syncedReferrers = ref([]); + +const loadLocalOnly = ( + profileID: string, + profiles: ProfilesStorage, + settings: SyncSettings +) => { + localOnly.value = savedLocalOnly.value = settings.localOnly.includes(profileID); + syncedReferrers.value = Object.values(profiles) + .filter( + (p) => + p.proxyType === "auto" && + p.profileID !== profileID && + !settings.localOnly.includes(p.profileID) && + (p.defaultProfileID === profileID || + p.rules.some((r) => r.type !== "disabled" && r.profileID === profileID)) + ) + .map((p) => p.profileName); +}; + +const saveLocalOnly = async (profileID: string) => { + const on = localOnly.value; + await updateSyncSettings((settings) => { + const others = settings.localOnly.filter((id) => id !== profileID); + settings.localOnly = on ? [...others, profileID] : others; + }); + savedLocalOnly.value = on; +}; + const bypassList = computed({ get: () => { if (profileConfig.proxyType != "proxy") { @@ -216,6 +261,10 @@ const saveProfileEvent = async () => { } try { + // before the profile, so a new local-only profile never syncs + if (localOnly.value !== savedLocalOnly.value) { + await saveLocalOnly(profileConfig.profileID); + } await saveProfile(toRaw(profileConfig)); Notification.success({ content: Host.getMessage("config_feedback_saved"), @@ -332,7 +381,11 @@ const fetchPacNow = async () => { // load data const loadProfile = async (profileID: string) => { - const profile = await getProfile(profileID); + const [profiles, settings] = await Promise.all([ + listProfiles(), + getSyncSettings(), + ]); + const profile = lookupProfile(profiles, profileID); if (!profile) { // TODO: data not found @@ -344,6 +397,7 @@ const loadProfile = async (profileID: string) => { color: profile.color.toUpperCase(), }); savedSnapshot.value = snapshot(); + loadLocalOnly(profileID, profiles, settings); if ( profileConfig.proxyType == "proxy" && (profileConfig.proxyRules.ftp || @@ -435,6 +489,24 @@ watchEffect(async () => { + + + + {{ + $t("profile_local_only_referenced", syncedReferrers.join(", ")) + }} + + +