From bad16109485b131d64cca008c1a30f114bb729f9 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:27:15 +0200 Subject: [PATCH] spaces alpha --- .changeset/calm-spaces-project.md | 5 + README.md | 10 +- apps/spaces-demo-provider/.dev.vars.example | 1 + apps/spaces-demo-provider/.gitignore | 3 + apps/spaces-demo-provider/README.md | 36 + apps/spaces-demo-provider/package.json | 20 + apps/spaces-demo-provider/src/constants.ts | 6 + apps/spaces-demo-provider/src/lexicons.ts | 58 + apps/spaces-demo-provider/src/worker.ts | 108 ++ apps/spaces-demo-provider/tsconfig.json | 7 + apps/spaces-demo-provider/wrangler.jsonc | 30 + apps/spaces-demo/.env.example | 4 + apps/spaces-demo/.gitignore | 6 + apps/spaces-demo/README.md | 37 + apps/spaces-demo/package.json | 29 + apps/spaces-demo/src/app.css | 26 + apps/spaces-demo/src/app.d.ts | 23 + apps/spaces-demo/src/app.html | 11 + apps/spaces-demo/src/hooks.server.ts | 3 + apps/spaces-demo/src/lib/constants.ts | 7 + apps/spaces-demo/src/lib/server/auth.ts | 25 + apps/spaces-demo/src/routes/+layout.server.ts | 1 + apps/spaces-demo/src/routes/+layout.svelte | 6 + apps/spaces-demo/src/routes/+page.server.ts | 188 +++ apps/spaces-demo/src/routes/+page.svelte | 133 ++ apps/spaces-demo/svelte.config.js | 7 + apps/spaces-demo/tsconfig.json | 14 + apps/spaces-demo/vite.config.ts | 4 + apps/spaces-demo/wrangler.jsonc | 22 + docs/experimental-spaces-alpha.md | 38 + packages/contrail-spaces-alpha/README.md | 63 + packages/contrail-spaces-alpha/package.json | 47 + .../contrail-spaces-alpha/src/consumer.ts | 280 ++++ packages/contrail-spaces-alpha/src/crypto.ts | 111 ++ packages/contrail-spaces-alpha/src/index.ts | 17 + .../contrail-spaces-alpha/src/protocol.ts | 390 +++++ packages/contrail-spaces-alpha/src/storage.ts | 604 ++++++++ packages/contrail-spaces-alpha/src/sync.ts | 620 ++++++++ packages/contrail-spaces-alpha/src/uri.ts | 89 ++ packages/contrail-spaces-alpha/src/worker.ts | 841 +++++++++++ .../tests/consumer.test.ts | 27 + .../tests/crypto.test.ts | 16 + .../tests/protocol.test.ts | 49 + .../tests/storage.test.ts | 163 +++ .../contrail-spaces-alpha/tests/uri.test.ts | 46 + .../tests/worker-query.test.ts | 171 +++ .../contrail-spaces-alpha/tsconfig.build.json | 4 + packages/contrail-spaces-alpha/tsconfig.json | 7 + packages/contrail-spaces-alpha/tsup.config.ts | 13 + .../contrail-spaces-alpha/vitest.config.ts | 3 + packages/contrail/src/core/ingest.ts | 43 +- .../contrail/src/core/isolated-projection.ts | 1259 +++++++++++++++++ packages/contrail/src/core/service-auth.ts | 62 +- packages/contrail/src/index.ts | 3 + .../tests/isolated-projection.test.ts | 199 +++ packages/contrail/tests/service-auth.test.ts | 24 +- pnpm-lock.yaml | 403 ++++++ 57 files changed, 6405 insertions(+), 17 deletions(-) create mode 100644 .changeset/calm-spaces-project.md create mode 100644 apps/spaces-demo-provider/.dev.vars.example create mode 100644 apps/spaces-demo-provider/.gitignore create mode 100644 apps/spaces-demo-provider/README.md create mode 100644 apps/spaces-demo-provider/package.json create mode 100644 apps/spaces-demo-provider/src/constants.ts create mode 100644 apps/spaces-demo-provider/src/lexicons.ts create mode 100644 apps/spaces-demo-provider/src/worker.ts create mode 100644 apps/spaces-demo-provider/tsconfig.json create mode 100644 apps/spaces-demo-provider/wrangler.jsonc create mode 100644 apps/spaces-demo/.env.example create mode 100644 apps/spaces-demo/.gitignore create mode 100644 apps/spaces-demo/README.md create mode 100644 apps/spaces-demo/package.json create mode 100644 apps/spaces-demo/src/app.css create mode 100644 apps/spaces-demo/src/app.d.ts create mode 100644 apps/spaces-demo/src/app.html create mode 100644 apps/spaces-demo/src/hooks.server.ts create mode 100644 apps/spaces-demo/src/lib/constants.ts create mode 100644 apps/spaces-demo/src/lib/server/auth.ts create mode 100644 apps/spaces-demo/src/routes/+layout.server.ts create mode 100644 apps/spaces-demo/src/routes/+layout.svelte create mode 100644 apps/spaces-demo/src/routes/+page.server.ts create mode 100644 apps/spaces-demo/src/routes/+page.svelte create mode 100644 apps/spaces-demo/svelte.config.js create mode 100644 apps/spaces-demo/tsconfig.json create mode 100644 apps/spaces-demo/vite.config.ts create mode 100644 apps/spaces-demo/wrangler.jsonc create mode 100644 docs/experimental-spaces-alpha.md create mode 100644 packages/contrail-spaces-alpha/README.md create mode 100644 packages/contrail-spaces-alpha/package.json create mode 100644 packages/contrail-spaces-alpha/src/consumer.ts create mode 100644 packages/contrail-spaces-alpha/src/crypto.ts create mode 100644 packages/contrail-spaces-alpha/src/index.ts create mode 100644 packages/contrail-spaces-alpha/src/protocol.ts create mode 100644 packages/contrail-spaces-alpha/src/storage.ts create mode 100644 packages/contrail-spaces-alpha/src/sync.ts create mode 100644 packages/contrail-spaces-alpha/src/uri.ts create mode 100644 packages/contrail-spaces-alpha/src/worker.ts create mode 100644 packages/contrail-spaces-alpha/tests/consumer.test.ts create mode 100644 packages/contrail-spaces-alpha/tests/crypto.test.ts create mode 100644 packages/contrail-spaces-alpha/tests/protocol.test.ts create mode 100644 packages/contrail-spaces-alpha/tests/storage.test.ts create mode 100644 packages/contrail-spaces-alpha/tests/uri.test.ts create mode 100644 packages/contrail-spaces-alpha/tests/worker-query.test.ts create mode 100644 packages/contrail-spaces-alpha/tsconfig.build.json create mode 100644 packages/contrail-spaces-alpha/tsconfig.json create mode 100644 packages/contrail-spaces-alpha/tsup.config.ts create mode 100644 packages/contrail-spaces-alpha/vitest.config.ts create mode 100644 packages/contrail/src/core/isolated-projection.ts create mode 100644 packages/contrail/tests/isolated-projection.test.ts diff --git a/.changeset/calm-spaces-project.md b/.changeset/calm-spaces-project.md new file mode 100644 index 0000000..b50971a --- /dev/null +++ b/.changeset/calm-spaces-project.md @@ -0,0 +1,5 @@ +--- +"@atmo-dev/contrail": minor +--- + +Add protocol-neutral isolated projection scopes, staged partition generations, scoped search/references/relation counts, a canonical-ingest projection seam, and arbitrary exact-method service-auth verification for experimental extensions. diff --git a/README.md b/README.md index 7c6c4f0..3be432c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > **Pre-alpha.** Expect breaking changes. -Contrail turns public AT Protocol records into a queryable AppView. +Contrail turns AT Protocol records into a queryable AppView. The published package targets public records; an optional workspace-private alpha extension adds permissioned Space projections. It provides: @@ -146,15 +146,17 @@ The runnable [`apps/sqlite`](apps/sqlite) example wires the standard backfill CL - [Using a service](docs/public-services/using.md) - [Example: api.atmo.rsvp](docs/public-services/api-atmo-rsvp.md) - [SvelteKit + Cloudflare](docs/frameworks/sveltekit-cloudflare.md) +- [Experimental AT Protocol Spaces](docs/experimental-spaces-alpha.md) ## Repository layout -There is one published package and one implementation: +There is one published package and one optional unpublished alpha extension: ```text -packages/contrail/ @atmo-dev/contrail +packages/contrail/ @atmo-dev/contrail +packages/contrail-spaces-alpha/ @atmo-dev/contrail-spaces-alpha (private) ``` -The previous AppView, base, community, authority, record-host, sync, and Lexicon packages have been removed. +The Spaces extension is an aggregator/AppView, not an authority or record host. The previous custom authority, record-host, community, and realtime products remain removed. See [development.md](development.md) for repository commands. diff --git a/apps/spaces-demo-provider/.dev.vars.example b/apps/spaces-demo-provider/.dev.vars.example new file mode 100644 index 0000000..7460896 --- /dev/null +++ b/apps/spaces-demo-provider/.dev.vars.example @@ -0,0 +1 @@ +SPACES_CREDENTIAL_ENCRYPTION_KEY=replace-with-32-byte-base64-key diff --git a/apps/spaces-demo-provider/.gitignore b/apps/spaces-demo-provider/.gitignore new file mode 100644 index 0000000..153b333 --- /dev/null +++ b/apps/spaces-demo-provider/.gitignore @@ -0,0 +1,3 @@ +.wrangler +node_modules +.dev.vars diff --git a/apps/spaces-demo-provider/README.md b/apps/spaces-demo-provider/README.md new file mode 100644 index 0000000..5f121f0 --- /dev/null +++ b/apps/spaces-demo-provider/README.md @@ -0,0 +1,36 @@ +# Atmo Circle provider + +Standalone Contrail Spaces-alpha provider at `https://spaces.atmo.garden` (`did:web:spaces.atmo.garden#spaces`). + +It indexes `garden.atmo.circle.note` and `garden.atmo.circle.reaction` from one deterministic `garden.atmo.circle/self` Space per owner. The managing-app policy allows the owner and reciprocal Bluesky followers. Policy failures deny access. + +## Provision and deploy + +```sh +wrangler d1 create contrail-spaces-demo +wrangler queues create contrail-spaces-demo +``` + +Put the returned D1 ID into `wrangler.jsonc`, then install the credential encryption secret: + +```sh +openssl rand -base64 32 | pnpm exec wrangler secret put SPACES_CREDENTIAL_ENCRYPTION_KEY +``` + +Build the workspace packages and deploy: + +```sh +pnpm --filter @atmo-dev/contrail build +pnpm --filter @atmo-dev/contrail-spaces-alpha build +pnpm --filter contrail-spaces-demo-provider typecheck +pnpm --filter contrail-spaces-demo-provider deploy +``` + +Verify discovery before deploying the consumer: + +```sh +curl https://spaces.atmo.garden/.well-known/did.json +curl https://spaces.atmo.garden/.well-known/contrail-spaces-alpha +``` + +The Worker initializes its extension-owned D1 schema idempotently. No separate migration command is needed for this first alpha schema. diff --git a/apps/spaces-demo-provider/package.json b/apps/spaces-demo-provider/package.json new file mode 100644 index 0000000..a5e5f4b --- /dev/null +++ b/apps/spaces-demo-provider/package.json @@ -0,0 +1,20 @@ +{ + "name": "contrail-spaces-demo-provider", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@atmo-dev/contrail": "workspace:*", + "@atmo-dev/contrail-spaces-alpha": "workspace:*" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20260804.1", + "typescript": "^6.0.3", + "wrangler": "^4.118.0" + } +} diff --git a/apps/spaces-demo-provider/src/constants.ts b/apps/spaces-demo-provider/src/constants.ts new file mode 100644 index 0000000..6bbecbd --- /dev/null +++ b/apps/spaces-demo-provider/src/constants.ts @@ -0,0 +1,6 @@ +export const SPACE_TYPE = "garden.atmo.circle"; +export const SPACE_SKEY = "self"; +export const NOTE_COLLECTION = "garden.atmo.circle.note"; +export const REACTION_COLLECTION = "garden.atmo.circle.reaction"; +export const PROVIDER_ENDPOINT = "https://spaces.atmo.garden"; +export const PROVIDER_AUDIENCE = "did:web:spaces.atmo.garden#spaces"; diff --git a/apps/spaces-demo-provider/src/lexicons.ts b/apps/spaces-demo-provider/src/lexicons.ts new file mode 100644 index 0000000..2f649f0 --- /dev/null +++ b/apps/spaces-demo-provider/src/lexicons.ts @@ -0,0 +1,58 @@ +import { NOTE_COLLECTION, REACTION_COLLECTION } from "./constants"; + +export const noteLexicon = { + lexicon: 1, + id: NOTE_COLLECTION, + defs: { + main: { + type: "record", + key: "tid", + record: { + type: "object", + required: ["text", "createdAt"], + properties: { + text: { type: "string", minLength: 1, maxLength: 2000 }, + createdAt: { type: "string", format: "datetime" }, + reply: { type: "ref", ref: "#strongRef" }, + }, + }, + }, + strongRef: { + type: "object", + required: ["uri", "cid"], + properties: { + uri: { type: "string", format: "uri" }, + cid: { type: "string", format: "cid" }, + }, + }, + }, +} as const; + +export const reactionLexicon = { + lexicon: 1, + id: REACTION_COLLECTION, + defs: { + main: { + type: "record", + key: "tid", + record: { + type: "object", + required: ["subject", "createdAt"], + properties: { + subject: { type: "ref", ref: "#strongRef" }, + createdAt: { type: "string", format: "datetime" }, + }, + }, + }, + strongRef: { + type: "object", + required: ["uri", "cid"], + properties: { + uri: { type: "string", format: "uri" }, + cid: { type: "string", format: "cid" }, + }, + }, + }, +} as const; + +export const lexicons = [noteLexicon, reactionLexicon] as const; diff --git a/apps/spaces-demo-provider/src/worker.ts b/apps/spaces-demo-provider/src/worker.ts new file mode 100644 index 0000000..da7b9a6 --- /dev/null +++ b/apps/spaces-demo-provider/src/worker.ts @@ -0,0 +1,108 @@ +import type { ContrailConfig } from "@atmo-dev/contrail"; +import { createSpacesWorker } from "@atmo-dev/contrail-spaces-alpha/worker"; +import { parseSpaceUri } from "@atmo-dev/contrail-spaces-alpha"; +import { + NOTE_COLLECTION, + PROVIDER_AUDIENCE, + PROVIDER_ENDPOINT, + REACTION_COLLECTION, + SPACE_SKEY, + SPACE_TYPE, +} from "./constants"; +import { lexicons } from "./lexicons"; + +interface Env { + [key: string]: unknown; + DB: D1Database; + SPACES_QUEUE: Queue; + SPACES_CREDENTIAL_ENCRYPTION_KEY: string; + BSKY_APPVIEW_URL: string; +} + +const projection: ContrailConfig = { + namespace: SPACE_TYPE, + profiles: [], + collections: { + note: { + collection: NOTE_COLLECTION, + validate: true, + searchable: ["text"], + queryable: { createdAt: { type: "range" } }, + relations: { + reactions: { collection: "reaction", field: "subject.uri" }, + replies: { collection: "note", field: "reply.uri" }, + }, + references: { + reply: { collection: "note", field: "reply.uri" }, + }, + }, + reaction: { + collection: REACTION_COLLECTION, + validate: true, + references: { + note: { collection: "note", field: "subject.uri" }, + }, + }, + }, + validation: { verifyCid: true, strict: true }, + constellation: false, +}; + +async function mutualAccess( + userDid: string, + ownerDid: string, + appview: string, +): Promise { + if (userDid === ownerDid) return true; + const url = new URL("/xrpc/app.bsky.graph.getRelationships", appview); + url.searchParams.set("actor", userDid); + url.searchParams.append("others", ownerDid); + const response = await fetch(url, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(3_000), + }); + if (!response.ok) return false; + const body = await response.json() as { + relationships?: Array<{ + $type?: string; + did?: string; + following?: unknown; + followedBy?: unknown; + }>; + }; + const relation = body.relationships?.find((item) => item.did === ownerDid); + return Boolean(relation?.following && relation.followedBy); +} + +export default createSpacesWorker({ + projection, + lexicons, + service: { + endpoint: PROVIDER_ENDPOINT, + audience: PROVIDER_AUDIENCE, + }, + spaceTypes: { + [SPACE_TYPE]: { + collections: [NOTE_COLLECTION, REACTION_COLLECTION], + skey: SPACE_SKEY, + }, + }, + authorization: { + async authorize({ userDid, spaceUri }, { env }) { + const space = parseSpaceUri(spaceUri); + if (space.type !== SPACE_TYPE || space.skey !== SPACE_SKEY) return false; + try { + return await mutualAccess( + userDid, + space.authorityDid, + env.BSKY_APPVIEW_URL || "https://public.api.bsky.app", + ); + } catch (error) { + console.warn("[circle] relationship policy failed closed", error); + return false; + } + }, + }, + accessLeaseMs: 15 * 60_000, + reconcileIntervalMs: 5 * 60_000, +}); diff --git a/apps/spaces-demo-provider/tsconfig.json b/apps/spaces-demo-provider/tsconfig.json new file mode 100644 index 0000000..61fe203 --- /dev/null +++ b/apps/spaces-demo-provider/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types"] + }, + "include": ["src", "tests"] +} diff --git a/apps/spaces-demo-provider/wrangler.jsonc b/apps/spaces-demo-provider/wrangler.jsonc new file mode 100644 index 0000000..9c37540 --- /dev/null +++ b/apps/spaces-demo-provider/wrangler.jsonc @@ -0,0 +1,30 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "contrail-spaces-demo-provider", + "main": "src/worker.ts", + "compatibility_date": "2026-08-04", + "compatibility_flags": ["nodejs_compat"], + "observability": { "enabled": true }, + "routes": [ + { "pattern": "spaces.atmo.garden", "custom_domain": true } + ], + "vars": { + "BSKY_APPVIEW_URL": "https://public.api.bsky.app" + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "contrail-spaces-demo", + "database_id": "REPLACE_WITH_D1_DATABASE_ID" + } + ], + "queues": { + "producers": [ + { "binding": "SPACES_QUEUE", "queue": "contrail-spaces-demo" } + ], + "consumers": [ + { "queue": "contrail-spaces-demo", "max_batch_size": 10, "max_batch_timeout": 5 } + ] + }, + "triggers": { "crons": ["*/5 * * * *"] } +} diff --git a/apps/spaces-demo/.env.example b/apps/spaces-demo/.env.example new file mode 100644 index 0000000..3bf98d3 --- /dev/null +++ b/apps/spaces-demo/.env.example @@ -0,0 +1,4 @@ +# Leave OAUTH_PUBLIC_URL empty for the loopback development client. +OAUTH_PUBLIC_URL= +COOKIE_SECRET=dev-only-change-me +CLIENT_ASSERTION_KEY= diff --git a/apps/spaces-demo/.gitignore b/apps/spaces-demo/.gitignore new file mode 100644 index 0000000..ef9c1aa --- /dev/null +++ b/apps/spaces-demo/.gitignore @@ -0,0 +1,6 @@ +.svelte-kit +.wrangler +node_modules +.env +.env.* +!.env.example diff --git a/apps/spaces-demo/README.md b/apps/spaces-demo/README.md new file mode 100644 index 0000000..d9894f2 --- /dev/null +++ b/apps/spaces-demo/README.md @@ -0,0 +1,37 @@ +# Atmo Circle demo + +Small SvelteKit Cloudflare Worker demonstrating a separately deployed Contrail Spaces provider. + +- OAuth sessions stay in this consumer's KV namespaces through `@svelte-atproto/oauth`. +- one-time delegation tokens are forwarded in authenticated request bodies; +- service-auth JWTs are minted per exact provider method; +- writes go directly to each user's permissioned PDS repo; and +- reads come from `https://spaces.atmo.garden`. + +Only Spaces-compatible PDS accounts can use the alpha. + +## Provision and deploy + +Create two KV namespaces and put their IDs into `wrangler.jsonc`: + +```sh +wrangler kv namespace create OAUTH_SESSIONS +wrangler kv namespace create OAUTH_STATES +``` + +Install OAuth secrets: + +```sh +pnpx atproto-oauth secret | pnpm exec wrangler secret put COOKIE_SECRET +pnpx atproto-oauth keygen | pnpm exec wrangler secret put CLIENT_ASSERTION_KEY +``` + +Deploy the provider first, then: + +```sh +pnpm --filter @atmo-dev/contrail-spaces-alpha build +pnpm --filter contrail-spaces-demo build +pnpm --filter contrail-spaces-demo deploy +``` + +The configured custom domain is `circle.atmo.garden`. OAuth metadata is served at `/oauth-client-metadata.json` by `@svelte-atproto/oauth`. diff --git a/apps/spaces-demo/package.json b/apps/spaces-demo/package.json new file mode 100644 index 0000000..4fef499 --- /dev/null +++ b/apps/spaces-demo/package.json @@ -0,0 +1,29 @@ +{ + "name": "contrail-spaces-demo", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "typecheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "deploy": "wrangler deploy" + }, + "dependencies": { + "@atmo-dev/contrail-spaces-alpha": "workspace:*", + "@svelte-atproto/oauth": "^0.3.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20260804.1", + "@sveltejs/adapter-cloudflare": "^7.2.9", + "@sveltejs/kit": "^2.70.2", + "@sveltejs/vite-plugin-svelte": "^7.2.0", + "svelte": "^5.56.8", + "svelte-check": "^4.7.4", + "typescript": "^6.0.3", + "vite": "^8.2.0", + "wrangler": "^4.118.0" + } +} diff --git a/apps/spaces-demo/src/app.css b/apps/spaces-demo/src/app.css new file mode 100644 index 0000000..adbcea1 --- /dev/null +++ b/apps/spaces-demo/src/app.css @@ -0,0 +1,26 @@ +:root { + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + color: #e8edf7; + background: #0b1020; + font-synthesis: none; +} +* { box-sizing: border-box; } +body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top, #19254a, #0b1020 52%); } +button, input, textarea { font: inherit; } +button { cursor: pointer; } +a { color: #9fc1ff; } +main { width: min(760px, calc(100% - 2rem)); margin: 0 auto; padding: 2rem 0 5rem; } +header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 2rem; } +h1 { margin: 0; font-size: clamp(1.8rem, 6vw, 3.4rem); letter-spacing: -0.05em; } +.subtle { color: #a8b2ca; } +.card { background: rgba(20, 29, 55, .88); border: 1px solid #334367; border-radius: 16px; padding: 1rem; margin: 1rem 0; box-shadow: 0 14px 50px rgba(0,0,0,.18); } +.stack { display: grid; gap: .75rem; } +.row { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; } +input, textarea { width: 100%; color: inherit; background: #0d1429; border: 1px solid #3d4c70; border-radius: 10px; padding: .75rem; } +textarea { min-height: 7rem; resize: vertical; } +button { border: 0; border-radius: 999px; padding: .65rem 1rem; background: #7ca8ff; color: #081127; font-weight: 700; } +button.secondary { color: #dce6ff; background: #273556; } +.note { white-space: pre-wrap; line-height: 1.55; } +.meta { color: #929fbc; font-size: .82rem; overflow-wrap: anywhere; } +.error { color: #ffc2c2; background: #4a1f2a; border-radius: 10px; padding: .75rem; } +.badge { border-radius: 999px; background: #25375d; color: #bcd1ff; padding: .2rem .55rem; font-size: .8rem; } diff --git a/apps/spaces-demo/src/app.d.ts b/apps/spaces-demo/src/app.d.ts new file mode 100644 index 0000000..68a9f33 --- /dev/null +++ b/apps/spaces-demo/src/app.d.ts @@ -0,0 +1,23 @@ +import type { AuthenticatedPdsSession } from "@atmo-dev/contrail-spaces-alpha/consumer"; + +declare global { + namespace App { + interface Locals { + session: AuthenticatedPdsSession | null; + did: string | null; + } + interface Platform { + env: { + OAUTH_SESSIONS: KVNamespace; + OAUTH_STATES: KVNamespace; + OAUTH_PUBLIC_URL: string; + COOKIE_SECRET: string; + CLIENT_ASSERTION_KEY: string; + }; + context: ExecutionContext; + caches: CacheStorage; + } + } +} + +export {}; diff --git a/apps/spaces-demo/src/app.html b/apps/spaces-demo/src/app.html new file mode 100644 index 0000000..adf8bd8 --- /dev/null +++ b/apps/spaces-demo/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/apps/spaces-demo/src/hooks.server.ts b/apps/spaces-demo/src/hooks.server.ts new file mode 100644 index 0000000..c773f69 --- /dev/null +++ b/apps/spaces-demo/src/hooks.server.ts @@ -0,0 +1,3 @@ +import { atproto } from "$lib/server/auth"; + +export const handle = atproto.handle; diff --git a/apps/spaces-demo/src/lib/constants.ts b/apps/spaces-demo/src/lib/constants.ts new file mode 100644 index 0000000..6161951 --- /dev/null +++ b/apps/spaces-demo/src/lib/constants.ts @@ -0,0 +1,7 @@ +export const SPACE_TYPE = "garden.atmo.circle"; +export const SPACE_SKEY = "self"; +export const NOTE_COLLECTION = "garden.atmo.circle.note"; +export const REACTION_COLLECTION = "garden.atmo.circle.reaction"; +export const COLLECTIONS = [NOTE_COLLECTION, REACTION_COLLECTION] as const; +export const PROVIDER_ENDPOINT = "https://spaces.atmo.garden"; +export const PROVIDER_AUDIENCE = "did:web:spaces.atmo.garden#spaces"; diff --git a/apps/spaces-demo/src/lib/server/auth.ts b/apps/spaces-demo/src/lib/server/auth.ts new file mode 100644 index 0000000..7de2dac --- /dev/null +++ b/apps/spaces-demo/src/lib/server/auth.ts @@ -0,0 +1,25 @@ +import { env } from "$env/dynamic/private"; +import { createAtprotoAuth } from "@svelte-atproto/oauth/server"; +import { cloudflareKV } from "@svelte-atproto/oauth/server/stores/cloudflare"; +import { spacesConsumerOAuthScopes } from "@atmo-dev/contrail-spaces-alpha/consumer"; +import { + COLLECTIONS, + PROVIDER_AUDIENCE, + SPACE_SKEY, + SPACE_TYPE, +} from "$lib/constants"; + +export const atproto = createAtprotoAuth({ + origin: env.OAUTH_PUBLIC_URL, + cookieSecret: env.COOKIE_SECRET, + clientAssertionKey: env.CLIENT_ASSERTION_KEY, + scope: spacesConsumerOAuthScopes({ + audience: PROVIDER_AUDIENCE, + namespace: SPACE_TYPE, + collections: COLLECTIONS, + spaceType: SPACE_TYPE, + skey: SPACE_SKEY, + }), + sessions: cloudflareKV("OAUTH_SESSIONS"), + states: cloudflareKV("OAUTH_STATES", { ttl: 600 }), +}); diff --git a/apps/spaces-demo/src/routes/+layout.server.ts b/apps/spaces-demo/src/routes/+layout.server.ts new file mode 100644 index 0000000..92f27b2 --- /dev/null +++ b/apps/spaces-demo/src/routes/+layout.server.ts @@ -0,0 +1 @@ +export const load = ({ locals }) => ({ did: locals.did }); diff --git a/apps/spaces-demo/src/routes/+layout.svelte b/apps/spaces-demo/src/routes/+layout.svelte new file mode 100644 index 0000000..3fa208a --- /dev/null +++ b/apps/spaces-demo/src/routes/+layout.svelte @@ -0,0 +1,6 @@ + + +{@render children()} diff --git a/apps/spaces-demo/src/routes/+page.server.ts b/apps/spaces-demo/src/routes/+page.server.ts new file mode 100644 index 0000000..8df2111 --- /dev/null +++ b/apps/spaces-demo/src/routes/+page.server.ts @@ -0,0 +1,188 @@ +import { fail, redirect } from "@sveltejs/kit"; +import { + SpacesProviderClient, + createSpace, + createSpaceRecord, + formatSpaceUri, +} from "@atmo-dev/contrail-spaces-alpha/consumer"; +import { + NOTE_COLLECTION, + PROVIDER_AUDIENCE, + PROVIDER_ENDPOINT, + REACTION_COLLECTION, + SPACE_SKEY, + SPACE_TYPE, +} from "$lib/constants"; +import type { Actions, PageServerLoad } from "./$types"; + +interface NoteRecord { + uri: string; + did: string; + cid: string; + counts?: Record; + value: { + text: string; + createdAt: string; + reply?: { uri: string; cid: string }; + }; +} + +function circleUri(ownerDid: string): string { + return formatSpaceUri({ authorityDid: ownerDid, type: SPACE_TYPE, skey: SPACE_SKEY }); +} + +function provider(session: NonNullable) { + return new SpacesProviderClient({ + endpoint: PROVIDER_ENDPOINT, + audience: PROVIDER_AUDIENCE, + namespace: SPACE_TYPE, + session, + }); +} + +function signedIn(locals: App.Locals) { + if (!locals.session || !locals.did) throw redirect(303, "/"); + return { session: locals.session, did: locals.did }; +} + +function validDid(value: unknown): value is string { + return typeof value === "string" && /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/.test(value); +} + +function ownerFrom(form: FormData, fallback: string): string { + const owner = form.get("owner"); + return validDid(owner) ? owner : fallback; +} + +export const load: PageServerLoad = async ({ locals, url }) => { + if (!locals.session || !locals.did) { + return { signedIn: false as const, owner: null, space: null, notes: [] }; + } + const ownerParam = url.searchParams.get("owner"); + const owner = validDid(ownerParam) ? ownerParam : locals.did; + const space = circleUri(owner); + try { + const result = await provider(locals.session).listSpaceRecords({ + space, + collection: NOTE_COLLECTION, + limit: 100, + search: url.searchParams.get("search") ?? undefined, + }); + return { + signedIn: true as const, + owner, + space, + notes: result.records, + viewer: locals.did, + needsAuthorization: false, + }; + } catch (error) { + return { + signedIn: true as const, + owner, + space, + notes: [] as NoteRecord[], + viewer: locals.did, + needsAuthorization: true, + error: error instanceof Error ? error.message : "Circle unavailable", + }; + } +}; + +export const actions: Actions = { + async create({ locals }) { + const { session, did } = signedIn(locals); + try { + const created = await createSpace(session, { + type: SPACE_TYPE, + skey: SPACE_SKEY, + managingApp: PROVIDER_AUDIENCE, + }); + await provider(session).authorizeSpace(created.uri); + } catch (error) { + return fail(400, { + action: "create", + message: error instanceof Error ? error.message : "Could not create circle", + }); + } + throw redirect(303, `/?owner=${encodeURIComponent(did)}`); + }, + + async authorize({ request, locals }) { + const { session, did } = signedIn(locals); + const form = await request.formData(); + const owner = ownerFrom(form, did); + try { + await provider(session).authorizeSpace(circleUri(owner)); + } catch (error) { + return fail(403, { + action: "authorize", + message: error instanceof Error ? error.message : "Circle access denied", + }); + } + throw redirect(303, `/?owner=${encodeURIComponent(owner)}`); + }, + + async post({ request, locals }) { + const { session, did } = signedIn(locals); + const form = await request.formData(); + const owner = ownerFrom(form, did); + const text = String(form.get("text") ?? "").trim(); + if (!text || text.length > 2000) { + return fail(400, { action: "post", message: "Note must be 1–2000 characters" }); + } + const replyUri = form.get("replyUri"); + const replyCid = form.get("replyCid"); + try { + const space = circleUri(owner); + await createSpaceRecord(session, { + space, + collection: NOTE_COLLECTION, + record: { + $type: NOTE_COLLECTION, + text, + createdAt: new Date().toISOString(), + ...(typeof replyUri === "string" && replyUri && + typeof replyCid === "string" && replyCid + ? { reply: { uri: replyUri, cid: replyCid } } + : {}), + }, + }); + await provider(session).syncSpace(space, did); + } catch (error) { + return fail(400, { + action: "post", + message: error instanceof Error ? error.message : "Could not post note", + }); + } + throw redirect(303, `/?owner=${encodeURIComponent(owner)}`); + }, + + async react({ request, locals }) { + const { session, did } = signedIn(locals); + const form = await request.formData(); + const owner = ownerFrom(form, did); + const uri = String(form.get("uri") ?? ""); + const cid = String(form.get("cid") ?? ""); + if (!uri || !cid) return fail(400, { action: "react", message: "Invalid note" }); + try { + const space = circleUri(owner); + await createSpaceRecord(session, { + space, + collection: REACTION_COLLECTION, + record: { + $type: REACTION_COLLECTION, + subject: { uri, cid }, + createdAt: new Date().toISOString(), + }, + }); + await provider(session).syncSpace(space, did); + } catch (error) { + return fail(400, { + action: "react", + message: error instanceof Error ? error.message : "Could not react", + }); + } + throw redirect(303, `/?owner=${encodeURIComponent(owner)}`); + }, +}; diff --git a/apps/spaces-demo/src/routes/+page.svelte b/apps/spaces-demo/src/routes/+page.svelte new file mode 100644 index 0000000..df55f48 --- /dev/null +++ b/apps/spaces-demo/src/routes/+page.svelte @@ -0,0 +1,133 @@ + + + + Atmo Circle — Contrail Spaces demo + + + +
+
+
+

Atmo Circle

+
private notes, verified and projected by Contrail
+
+ {#if data.did} + + {/if} +
+ + {#if form?.message} +
{form.message}
+ {/if} + + {#if !data.signedIn} +
+

Sign in to a Spaces-compatible PDS

+ + + {#if loginError}
{loginError}
{/if} +

This alpha only works with PDSes implementing the permissioned-data proposal.

+
+ {:else} +
+
+ viewer{data.viewer} +
+
+ + +
+
+ + {#if data.owner === data.viewer && data.needsAuthorization} +
+

Create or reconnect your circle

+

Your mutual followers can read and write. Records stay in each writer's permissioned PDS repo.

+
+
+ +
+
+ + +
+
+
+ {:else if data.needsAuthorization} +
+ +

Connect this circle

+

The provider needs a one-time delegation before it can verify and index this Space.

+ {#if data.error}
{data.error}
{/if} + +
+ {:else} +
+ + + + {#if replyUri} +
+ replying to {replyUri.slice(-13)} + +
+ {/if} + +
+
+ +
+ {#each data.notes as note (note.uri)} +
+
{note.value.text}
+
{note.did} · {new Date(note.value.createdAt).toLocaleString()}
+
+
+ + + + +
+ + {note.counts?.note ?? 0} replies +
+
+ {:else} +
No synchronized notes yet. New writes may take a few seconds to arrive through the provider queue.
+ {/each} +
+ {/if} + {/if} +
diff --git a/apps/spaces-demo/svelte.config.js b/apps/spaces-demo/svelte.config.js new file mode 100644 index 0000000..4bc6a09 --- /dev/null +++ b/apps/spaces-demo/svelte.config.js @@ -0,0 +1,7 @@ +import adapter from "@sveltejs/adapter-cloudflare"; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +export default { + preprocess: vitePreprocess(), + kit: { adapter: adapter() }, +}; diff --git a/apps/spaces-demo/tsconfig.json b/apps/spaces-demo/tsconfig.json new file mode 100644 index 0000000..4344710 --- /dev/null +++ b/apps/spaces-demo/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/apps/spaces-demo/vite.config.ts b/apps/spaces-demo/vite.config.ts new file mode 100644 index 0000000..7fd75b9 --- /dev/null +++ b/apps/spaces-demo/vite.config.ts @@ -0,0 +1,4 @@ +import { sveltekit } from "@sveltejs/kit/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ plugins: [sveltekit()] }); diff --git a/apps/spaces-demo/wrangler.jsonc b/apps/spaces-demo/wrangler.jsonc new file mode 100644 index 0000000..bb8a1ed --- /dev/null +++ b/apps/spaces-demo/wrangler.jsonc @@ -0,0 +1,22 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "contrail-spaces-demo", + "main": ".svelte-kit/cloudflare/_worker.js", + "compatibility_date": "2026-08-04", + "compatibility_flags": ["nodejs_compat"], + "assets": { + "binding": "ASSETS", + "directory": ".svelte-kit/cloudflare" + }, + "observability": { "enabled": true }, + "routes": [ + { "pattern": "circle.atmo.garden", "custom_domain": true } + ], + "vars": { + "OAUTH_PUBLIC_URL": "https://circle.atmo.garden" + }, + "kv_namespaces": [ + { "binding": "OAUTH_SESSIONS", "id": "REPLACE_WITH_KV_NAMESPACE_ID" }, + { "binding": "OAUTH_STATES", "id": "REPLACE_WITH_KV_NAMESPACE_ID" } + ] +} diff --git a/docs/experimental-spaces-alpha.md b/docs/experimental-spaces-alpha.md new file mode 100644 index 0000000..1a667e0 --- /dev/null +++ b/docs/experimental-spaces-alpha.md @@ -0,0 +1,38 @@ +# Experimental AT Protocol Spaces support + +Contrail's first Spaces-alpha implementation lives in the optional, unpublished `@atmo-dev/contrail-spaces-alpha` workspace package. + +The implementation preserves two separate trust boundaries: + +1. a DPoP-bound Space credential allows the provider to synchronize one Space; and +2. a method-bound service JWT plus exact-Space application authorization allows a caller to query its cached projection. + +A successful sync credential is never treated as caller authorization. + +## Storage isolation + +Public records remain in `records_*` and `record_versions`. Private rows use extension-initialized `isolated_records_*`, `isolated_record_versions`, isolated FTS mappings, and a visible partition-generation map. Anonymous public code cannot address these tables. + +A Space incarnation maps to one opaque projection scope. Each writer repo is staged in a new partition generation during full recovery. The visibility pointer and verified checkpoint switch in one database batch, after which the prior generation can be deleted. + +## Worker lifecycle + +`createSpacesWorker` composes: + +- authenticated delegation authorization; +- exact-Space private collection routes; +- managing-app access checks; +- signed write/deletion notifications; +- Queue consumption; +- scheduled bounded reconciliation; and +- renewable, owner-fenced per-repo leases. + +Consumer-triggered synchronization always reconciles the authority's complete `listRepos` result; only service-authenticated authority notifications use the targeted writer path. Rediscovery is accepted only for a watch already hidden by a verified deletion, so an ordinary read-authorized caller cannot rotate an active generation. + +Notifications only enqueue a target revision/hash. Record bodies always come from authenticated writer hosts and are accepted only after commit/LtHash or full-CAR verification. CAR bodies are size-limited while streaming, including responses without `Content-Length`. Relation-count columns use additive initialization migrations when projection configuration evolves. + +## Current limitations + +This first alpha supports D1 and the shared SQLite conformance path. PostgreSQL uses the same protocol-neutral isolated schema/query code but has not yet received a live Spaces deployment test. Blobs, private feeds, labels, profile hydration, custom SQL, combined scope queries, and client-attested app allow-lists remain unavailable. + +See the two `apps/spaces-demo*` projects for deployment instructions. diff --git a/packages/contrail-spaces-alpha/README.md b/packages/contrail-spaces-alpha/README.md new file mode 100644 index 0000000..eada4ee --- /dev/null +++ b/packages/contrail-spaces-alpha/README.md @@ -0,0 +1,63 @@ +# `@atmo-dev/contrail-spaces-alpha` + +Experimental AT Protocol Spaces support for Contrail. This package is intentionally workspace-private while the upstream protocol is alpha. + +Pinned compatibility tuple: + +- Contrail: workspace `0.18.x` +- Spaces extension: `0.1.0-alpha.0` +- `@atproto/space`: `0.0.0-spaces-alpha-20260818163953` +- supported Lexicons: permissioned-data/Spaces alpha from 2026-08-18 + +## What it provides + +- delegation-token to DPoP-bound Space credential exchange; +- encrypted, expiring provider credential storage; +- explicit Space watches and per-writer checkpoints; +- signed commit, LtHash, and full CAR verification; +- staged writer-generation recovery with atomic visibility cutover; +- incremental repo operation synchronization; +- signed notification routes, Queue jobs, leases, and scheduled reconciliation; +- exact method-bound service authentication; +- delegation access leases or an authoritative application callback; and +- exact-Space list/get/search/reference/relation-count queries over Contrail's isolated projection seam. + +Public Contrail tables and anonymous methods are unchanged. Installing the package does not create isolated tables; `createSpacesWorker` initializes them only in a provider deployment. + +## Entry points + +```ts +import { createSpacesWorker } from "@atmo-dev/contrail-spaces-alpha/worker"; +import { + SpacesProviderClient, + createSpace, + createSpaceRecord, + spacesConsumerOAuthScopes, +} from "@atmo-dev/contrail-spaces-alpha/consumer"; +``` + +The consumer owns OAuth refresh tokens and writes through the user's PDS. The provider receives only one-time delegation evidence, stores an encrypted short-lived credential, verifies permissioned repos, and serves authorized private projections. + +## Provider requirements + +A Cloudflare deployment needs: + +- one D1 database; +- a Queue producer/consumer (with `waitUntil` fallback for development); +- a scheduled trigger; +- the `nodejs_compat` compatibility flag; and +- a 32-byte base64 `SPACES_CREDENTIAL_ENCRYPTION_KEY` secret. + +Credentials and DPoP private keys are AES-256-GCM encrypted with Space-generation-bound associated data. The D1 projection and backups still contain plaintext private records; this is access control, not end-to-end encryption. + +## Deliberately deferred + +- blobs; +- private feeds and labels; +- profile hydration; +- arbitrary private custom SQL; +- combined public/private pagination; +- client-attested `appAccess` allow-lists; and +- generic membership or invitation semantics. + +See [`apps/spaces-demo-provider`](../../apps/spaces-demo-provider) and [`apps/spaces-demo`](../../apps/spaces-demo) for the complete Worker + SvelteKit example. diff --git a/packages/contrail-spaces-alpha/package.json b/packages/contrail-spaces-alpha/package.json new file mode 100644 index 0000000..ce6ad80 --- /dev/null +++ b/packages/contrail-spaces-alpha/package.json @@ -0,0 +1,47 @@ +{ + "name": "@atmo-dev/contrail-spaces-alpha", + "version": "0.1.0-alpha.0", + "description": "Experimental AT Protocol Spaces synchronization and private projections for Contrail", + "private": true, + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./worker": { + "types": "./dist/worker.d.ts", + "import": "./dist/worker.js" + }, + "./consumer": { + "types": "./dist/consumer.d.ts", + "import": "./dist/consumer.js" + } + }, + "scripts": { + "build": "tsup", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@atmo-dev/contrail": "workspace:*", + "@atproto/common-web": "0.0.0-spaces-alpha-20260818163953", + "@atproto/identity": "0.0.0-spaces-alpha-20260818163953", + "@atproto/jwk-jose": "0.2.4", + "@atproto/lex-data": "0.1.7", + "@atproto/space": "0.0.0-spaces-alpha-20260818163953" + }, + "devDependencies": { + "@atcute/crypto": "2.4.4", + "@atcute/xrpc-server": "^2.0.2", + "@cloudflare/workers-types": "^5.20260804.1", + "@types/node": "^26.1.2", + "tsup": "^8.5.1", + "typescript": "^6.0.3", + "vitest": "^4.1.10", + "wrangler": "^4.118.0" + } +} diff --git a/packages/contrail-spaces-alpha/src/consumer.ts b/packages/contrail-spaces-alpha/src/consumer.ts new file mode 100644 index 0000000..baef5ad --- /dev/null +++ b/packages/contrail-spaces-alpha/src/consumer.ts @@ -0,0 +1,280 @@ +import { formatServiceOAuthScope } from "@atmo-dev/contrail"; +import { + formatSpaceRecordUri, + formatSpaceUri, + parseSpaceUri, +} from "./uri"; + +export interface AuthenticatedPdsSession { + did: string; + handle(pathname: string, init?: RequestInit): Promise; +} + +export interface SpacePermissionScopeInput { + type: string; + authority?: string; + skey?: string; + collections: readonly string[]; + actions?: readonly ("read" | "create" | "update" | "delete")[]; + manage?: readonly ("create" | "update" | "delete")[]; +} + +/** Build the current alpha permission token without requiring consumer apps to + * install the alpha scope parser alongside @svelte-atproto/oauth. */ +export function formatSpacePermissionScope(input: SpacePermissionScopeInput): string { + const params = new URLSearchParams(); + params.set("authority", input.authority ?? "*"); + if (input.skey) params.set("skey", input.skey); + for (const collection of input.collections) params.append("collection", collection); + for (const action of input.actions ?? ["read", "create", "update", "delete"]) { + params.append("action", action); + } + for (const manage of input.manage ?? ["create", "update", "delete"]) { + params.append("manage", manage); + } + return `space:${input.type}?${params.toString()}`; +} + +export function spacesConsumerOAuthScopes(input: { + audience: string; + namespace: string; + collections: readonly string[]; + spaceType: string; + skey?: string; +}): string[] { + const methods = [ + `${input.namespace}.authorizeSpace`, + `${input.namespace}.syncSpace`, + ...input.collections.flatMap((collection) => { + const short = collection.split(".").at(-1)!; + return [ + `${input.namespace}.${short}.listSpaceRecords`, + `${input.namespace}.${short}.getSpaceRecord`, + ]; + }), + ]; + return [ + "atproto", + formatSpacePermissionScope({ + type: input.spaceType, + skey: input.skey, + collections: input.collections, + }), + formatServiceOAuthScope(input.audience as never, methods), + ]; +} + +async function readJson(response: Response): Promise { + let body: unknown; + try { + body = await response.json(); + } catch { + body = undefined; + } + if (!response.ok) { + const object = body && typeof body === "object" ? body as Record : {}; + const code = typeof object.error === "string" ? object.error : `HTTP${response.status}`; + const message = typeof object.message === "string" ? object.message : code; + throw new Error(message); + } + return body as T; +} + +async function pdsQuery( + session: AuthenticatedPdsSession, + method: string, + params: Record, +): Promise { + const query = new URLSearchParams(params); + const response = await session.handle(`/xrpc/${method}?${query}`, { + headers: { accept: "application/json" }, + }); + return readJson(response); +} + +async function pdsProcedure( + session: AuthenticatedPdsSession, + method: string, + body: Record, +): Promise { + const response = await session.handle(`/xrpc/${method}`, { + method: "POST", + headers: { accept: "application/json", "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return readJson(response); +} + +export function createSpace( + session: AuthenticatedPdsSession, + input: { + type: string; + skey?: string; + managingApp: string; + }, +): Promise<{ uri: string }> { + return pdsProcedure(session, "com.atproto.simplespace.createSpace", { + type: input.type, + ...(input.skey ? { skey: input.skey } : {}), + policy: { + $type: "com.atproto.simplespace.defs#managingAppPolicy", + managingApp: input.managingApp, + }, + appAccess: { $type: "com.atproto.simplespace.defs#open" }, + }); +} + +export function createSpaceRecord( + session: AuthenticatedPdsSession, + input: { + space: string; + collection: string; + record: Record; + rkey?: string; + }, +): Promise<{ uri: string; cid: string }> { + parseSpaceUri(input.space); + return pdsProcedure(session, "com.atproto.space.createRecord", { + space: input.space, + repo: session.did, + collection: input.collection, + ...(input.rkey ? { rkey: input.rkey } : {}), + validate: false, + record: input.record, + }); +} + +export function deleteSpaceRecord( + session: AuthenticatedPdsSession, + input: { space: string; collection: string; rkey: string }, +): Promise> { + return pdsProcedure(session, "com.atproto.space.deleteRecord", { + space: input.space, + repo: session.did, + collection: input.collection, + rkey: input.rkey, + }); +} + +export async function getDelegationToken( + session: AuthenticatedPdsSession, + space: string, +): Promise { + parseSpaceUri(space); + const result = await pdsQuery<{ token: string }>( + session, + "com.atproto.space.getDelegationToken", + { space }, + ); + if (!result.token) throw new Error("PDS returned no delegation token"); + return result.token; +} + +export async function getServiceAuthToken( + session: AuthenticatedPdsSession, + input: { audience: string; method: string }, +): Promise { + const result = await pdsQuery<{ token: string }>( + session, + "com.atproto.server.getServiceAuth", + { aud: input.audience, lxm: input.method }, + ); + if (!result.token) throw new Error("PDS returned no service-auth token"); + return result.token; +} + +export interface SpacesProviderClientOptions { + endpoint: string; + audience: string; + namespace: string; + session: AuthenticatedPdsSession; + fetch?: typeof globalThis.fetch; +} + +export class SpacesProviderClient { + private readonly fetchImpl: typeof globalThis.fetch; + + constructor(readonly options: SpacesProviderClientOptions) { + this.fetchImpl = options.fetch ?? globalThis.fetch; + } + + private async call( + method: string, + init: RequestInit & { params?: URLSearchParams }, + ): Promise { + const token = await getServiceAuthToken(this.options.session, { + audience: this.options.audience, + method, + }); + const url = new URL(`/xrpc/${method}`, this.options.endpoint); + if (init.params) url.search = init.params.toString(); + const headers = new Headers(init.headers); + headers.set("accept", "application/json"); + headers.set("authorization", `Bearer ${token}`); + const response = await this.fetchImpl(url, { ...init, headers }); + return readJson(response); + } + + async authorizeSpace( + space: string, + options: { rediscover?: boolean } = {}, + ): Promise<{ space: string; generation: number; accessExpiresAt: string }> { + const delegation = await getDelegationToken(this.options.session, space); + return this.call(`${this.options.namespace}.authorizeSpace`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ space, delegation, rediscover: options.rediscover }), + }); + } + + syncSpace(space: string, repo?: string): Promise<{ queued: boolean }> { + return this.call(`${this.options.namespace}.syncSpace`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ space, repo }), + }); + } + + listSpaceRecords>(input: { + space: string; + collection: string; + limit?: number; + cursor?: string; + search?: string; + did?: string; + filters?: Record; + }): Promise<{ + records: T[]; + cursor?: string; + references: Record>; + }> { + const short = input.collection.split(".").at(-1)!; + const params = new URLSearchParams({ space: input.space }); + if (input.limit) params.set("limit", String(input.limit)); + if (input.cursor) params.set("cursor", input.cursor); + if (input.search) params.set("search", input.search); + if (input.did) params.set("did", input.did); + for (const [key, value] of Object.entries(input.filters ?? {})) { + params.set(key, value); + } + return this.call( + `${this.options.namespace}.${short}.listSpaceRecords`, + { method: "GET", params }, + ); + } + + getSpaceRecord>(input: { + space: string; + collection: string; + uri: string; + }): Promise<{ record: T; references: Record> }> { + const short = input.collection.split(".").at(-1)!; + const params = new URLSearchParams({ space: input.space, uri: input.uri }); + return this.call( + `${this.options.namespace}.${short}.getSpaceRecord`, + { method: "GET", params }, + ); + } +} + +export { formatSpaceRecordUri, formatSpaceUri }; diff --git a/packages/contrail-spaces-alpha/src/crypto.ts b/packages/contrail-spaces-alpha/src/crypto.ts new file mode 100644 index 0000000..d37335f --- /dev/null +++ b/packages/contrail-spaces-alpha/src/crypto.ts @@ -0,0 +1,111 @@ +export interface EncryptedValue { + version: 1; + iv: string; + ciphertext: string; +} + +function base64(bytes: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return btoa(binary); +} + +function fromBase64(value: string): Uint8Array { + const normalized = value.replaceAll("-", "+").replaceAll("_", "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)); +} + +function cryptoApi(): Crypto { + const api = (globalThis as typeof globalThis & { crypto?: Crypto }).crypto; + if (!api?.subtle) throw new Error("WebCrypto is required"); + return api; +} + +export async function generateCredentialEncryptionKey(): Promise { + return base64(cryptoApi().getRandomValues(new Uint8Array(32))); +} + +async function importKey(value: string): Promise { + const bytes = fromBase64(value); + if (bytes.byteLength !== 32) { + throw new TypeError("credential encryption key must contain exactly 32 bytes"); + } + return cryptoApi().subtle.importKey("raw", bytes, "AES-GCM", false, [ + "encrypt", + "decrypt", + ]); +} + +export async function encryptJson( + value: unknown, + keyValue: string, + context: string, +): Promise { + const key = await importKey(keyValue); + const iv = cryptoApi().getRandomValues(new Uint8Array(12)); + const plaintext = new TextEncoder().encode(JSON.stringify(value)); + const additionalData = new TextEncoder().encode(context); + const encrypted = await cryptoApi().subtle.encrypt( + { name: "AES-GCM", iv, additionalData }, + key, + plaintext, + ); + return { + version: 1, + iv: base64(iv), + ciphertext: base64(new Uint8Array(encrypted)), + }; +} + +export async function decryptJson( + value: EncryptedValue, + keyValue: string, + context: string, +): Promise { + if (value.version !== 1) throw new Error("Unsupported encrypted value version"); + const key = await importKey(keyValue); + const plaintext = await cryptoApi().subtle.decrypt( + { + name: "AES-GCM", + iv: fromBase64(value.iv), + additionalData: new TextEncoder().encode(context), + }, + key, + fromBase64(value.ciphertext), + ); + return JSON.parse(new TextDecoder().decode(plaintext)) as T; +} + +export interface JwtClaims { + iss?: string; + sub?: string; + aud?: string | string[]; + exp?: number; + iat?: number; + [key: string]: unknown; +} + +/** Decode untrusted claims for consistency/expiry checks. Signature validation + * remains the responsibility of the protocol endpoint that consumes the JWT. */ +export function decodeJwtClaims(token: string): JwtClaims { + const parts = token.split("."); + if (parts.length !== 3) throw new TypeError("Expected a compact JWT"); + try { + const decoded = new TextDecoder().decode(fromBase64(parts[1])); + const claims = JSON.parse(decoded); + if (!claims || typeof claims !== "object" || Array.isArray(claims)) { + throw new Error("claims are not an object"); + } + return claims as JwtClaims; + } catch (error) { + throw new TypeError(`Invalid JWT claims: ${(error as Error).message}`); + } +} + +export function jwtExpiresAt(token: string): number | null { + const exp = decodeJwtClaims(token).exp; + return typeof exp === "number" && Number.isFinite(exp) ? exp * 1000 : null; +} diff --git a/packages/contrail-spaces-alpha/src/index.ts b/packages/contrail-spaces-alpha/src/index.ts new file mode 100644 index 0000000..0a9fc8f --- /dev/null +++ b/packages/contrail-spaces-alpha/src/index.ts @@ -0,0 +1,17 @@ +export * from "./crypto"; +export * from "./protocol"; +export * from "./storage"; +export * from "./sync"; +export * from "./uri"; +export { + AUTHORIZE_SPACE_METHOD, + GET_SPACE_RECORD_METHOD, + LIST_SPACE_RECORDS_METHOD, + SYNC_SPACE_METHOD, + createSpacesWorker, +} from "./worker"; +export type { + SpaceAuthorizationInput, + SpacesWorkerEnv, + SpacesWorkerOptions, +} from "./worker"; diff --git a/packages/contrail-spaces-alpha/src/protocol.ts b/packages/contrail-spaces-alpha/src/protocol.ts new file mode 100644 index 0000000..5389948 --- /dev/null +++ b/packages/contrail-spaces-alpha/src/protocol.ts @@ -0,0 +1,390 @@ +import { getPdsEndpoint } from "@atproto/common-web"; +import { IdResolver } from "@atproto/identity"; +import { JoseKey } from "@atproto/jwk-jose"; +import { createDpopProof } from "@atproto/space"; +import { validateExternalUrl } from "@atmo-dev/contrail"; +import { parseSpaceUri } from "./uri"; + +export interface ProtocolOptions { + plcUrl?: string; + fetch?: typeof globalThis.fetch; + additionalAllowedHosts?: string[]; +} + +export class SpaceProtocolError extends Error { + constructor( + readonly status: number, + readonly code: string, + message?: string, + ) { + super(message ?? code); + this.name = "SpaceProtocolError"; + } + + get accessDenied(): boolean { + return [ + "UserNotAuthorized", + "AppNotAuthorized", + "NotAuthorized", + "InvalidDelegationToken", + "InvalidClientAttestation", + "AuthRequired", + ].includes(this.code) || this.status === 401 || this.status === 403; + } + + get deleted(): boolean { + return this.code === "SpaceDeleted"; + } + + get notFound(): boolean { + return this.code === "SpaceNotFound" || this.status === 404; + } +} + +export class SpaceIdentityResolver { + private readonly resolver: IdResolver; + + constructor(readonly options: ProtocolOptions = {}) { + this.resolver = new IdResolver({ plcUrl: options.plcUrl }); + } + + async resolvePds(did: string): Promise { + const document = await this.resolver.did.resolve(did); + if (!document) throw new Error(`Could not resolve ${did}`); + const endpoint = getPdsEndpoint(document); + if (!endpoint) throw new Error(`${did} has no PDS endpoint`); + if (!validateExternalUrl(endpoint, this.options.additionalAllowedHosts)) { + throw new Error(`${did} resolved to a disallowed PDS endpoint`); + } + return endpoint.replace(/\/$/, ""); + } + + async resolveSigningKey(did: string, forceRefresh = false): Promise { + return this.resolver.did.resolveAtprotoKey(did, forceRefresh); + } +} + +export interface StoredSpaceCredential { + token: string; + privateJwk: Record; + expiresAt: number; +} + +export class SpaceCredentialTransport { + constructor( + readonly token: string, + readonly key: JoseKey, + readonly expiresAt: number, + private readonly fetchImpl: typeof globalThis.fetch = globalThis.fetch, + ) {} + + static async restore( + value: StoredSpaceCredential, + fetchImpl: typeof globalThis.fetch = globalThis.fetch, + ): Promise { + const key = await JoseKey.fromJWK(value.privateJwk); + return new SpaceCredentialTransport(value.token, key, value.expiresAt, fetchImpl); + } + + serialize(): StoredSpaceCredential { + const privateJwk = this.key.privateJwk; + if (!privateJwk) throw new Error("DPoP key is not exportable"); + return { + token: this.token, + privateJwk: { ...privateJwk }, + expiresAt: this.expiresAt, + }; + } + + fetch = async ( + input: string | URL | Request, + init?: RequestInit, + ): Promise => { + if (Date.now() >= this.expiresAt) { + throw new SpaceProtocolError(401, "CredentialExpired"); + } + const request = new Request(input, { ...init, redirect: "error" }); + request.headers.set("authorization", `DPoP ${this.token}`); + request.headers.set( + "dpop", + await createDpopProof(this.key, { + htm: request.method, + htu: request.url, + credential: this.token, + }), + ); + return this.fetchImpl(request); + }; +} + +function decodeBase64(value: string): Uint8Array { + const normalized = value.replaceAll("-", "+").replaceAll("_", "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)); +} + +/** Decode AT Protocol JSON bytes while leaving ordinary record objects intact. */ +function decodeLexJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(decodeLexJson); + if (!value || typeof value !== "object") return value; + const object = value as Record; + if (Object.keys(object).length === 1 && typeof object.$bytes === "string") { + return decodeBase64(object.$bytes); + } + return Object.fromEntries( + Object.entries(object).map(([key, item]) => [key, decodeLexJson(item)]), + ); +} + +async function responseBody(response: Response): Promise { + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + try { + return decodeLexJson(await response.json()); + } catch { + return undefined; + } + } + return undefined; +} + +async function assertResponse(response: Response): Promise { + const body = await responseBody(response); + if (!response.ok) { + const object = body && typeof body === "object" + ? body as Record + : undefined; + const code = typeof object?.error === "string" + ? object.error + : response.status >= 500 + ? "UpstreamFailure" + : "InvalidRequest"; + const message = typeof object?.message === "string" ? object.message : undefined; + throw new SpaceProtocolError(response.status, code, message); + } + return body as T; +} + +function xrpcUrl(service: string, method: string, params?: Record): URL { + const url = new URL(`/xrpc/${method}`, service); + for (const [key, value] of Object.entries(params ?? {})) { + if (value === undefined) continue; + url.searchParams.set(key, String(value)); + } + return url; +} + +export async function exchangeSpaceCredential(input: { + authorityPds: string; + delegationToken: string; + spaceUri: string; + clientAttestation?: string; + fetch?: typeof globalThis.fetch; +}): Promise { + parseSpaceUri(input.spaceUri); + const key = await JoseKey.generate(["ES256"]); + const request = new Request( + xrpcUrl(input.authorityPds, "com.atproto.space.getSpaceCredential"), + { + method: "POST", + redirect: "error", + headers: { + accept: "application/json", + authorization: `Bearer ${input.delegationToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + space: input.spaceUri, + ...(input.clientAttestation + ? { clientAttestation: input.clientAttestation } + : {}), + }), + }, + ); + request.headers.set( + "dpop", + await createDpopProof(key, { htm: request.method, htu: request.url }), + ); + const response = await (input.fetch ?? globalThis.fetch)(request); + const body = await assertResponse<{ credential?: unknown }>(response); + if (typeof body?.credential !== "string" || !body.credential) { + throw new SpaceProtocolError(502, "InvalidResponse", "No Space credential returned"); + } + const claims = JSON.parse( + new TextDecoder().decode(decodeBase64(body.credential.split(".")[1] ?? "")), + ) as { exp?: unknown }; + if (typeof claims.exp !== "number" || claims.exp * 1000 <= Date.now()) { + throw new SpaceProtocolError(502, "InvalidResponse", "Credential has no valid expiry"); + } + return new SpaceCredentialTransport( + body.credential, + key, + claims.exp * 1000, + input.fetch ?? globalThis.fetch, + ); +} + +async function credentialQuery( + transport: SpaceCredentialTransport, + service: string, + method: string, + params: Record, +): Promise { + return assertResponse(await transport.fetch(xrpcUrl(service, method, params), { + headers: { accept: "application/json" }, + })); +} + +async function credentialProcedure( + transport: SpaceCredentialTransport, + service: string, + method: string, + body: Record, +): Promise { + return assertResponse(await transport.fetch(xrpcUrl(service, method), { + method: "POST", + headers: { accept: "application/json", "content-type": "application/json" }, + body: JSON.stringify(body), + })); +} + +export interface SpaceDescription { + uri: string; + policy: { $type?: string; managingApp?: string; [key: string]: unknown }; + appAccess: { $type?: string; [key: string]: unknown }; +} + +export function getSpace( + transport: SpaceCredentialTransport, + authorityPds: string, + spaceUri: string, +): Promise { + return credentialQuery(transport, authorityPds, "com.atproto.simplespace.getSpace", { + space: spaceUri, + }); +} + +export interface ListedRepo { + did: string; + rev: string; + hash: Uint8Array; +} + +export interface ListReposOutput { + repos: ListedRepo[]; + cursor?: string; +} + +export function listRepos( + transport: SpaceCredentialTransport, + authorityPds: string, + input: { space: string; limit?: number; cursor?: string }, +): Promise { + return credentialQuery(transport, authorityPds, "com.atproto.space.listRepos", input); +} + +export interface RepoOp { + rev: string; + collection: string; + rkey: string; + cid: string | null; + prev: string | null; + value?: unknown; +} + +export interface SignedCommitInput { + ver: number; + hash: Uint8Array; + ikm: Uint8Array; + sig: Uint8Array; + mac: Uint8Array; + rev: string; +} + +export interface ListRepoOpsOutput { + ops: RepoOp[]; + commit?: SignedCommitInput; + cursor?: string; +} + +export function listRepoOps( + transport: SpaceCredentialTransport, + writerPds: string, + input: { + space: string; + repo: string; + since: string; + limit?: number; + cursor?: string; + }, +): Promise { + return credentialQuery(transport, writerPds, "com.atproto.space.listRepoOps", input); +} + +export async function getRepoCar( + transport: SpaceCredentialTransport, + writerPds: string, + input: { space: string; repo: string; maxBytes?: number }, +): Promise { + const response = await transport.fetch( + xrpcUrl(writerPds, "com.atproto.space.getRepo", { + space: input.space, + repo: input.repo, + }), + { headers: { accept: "application/vnd.ipld.car" } }, + ); + if (!response.ok) await assertResponse(response); + const maximum = input.maxBytes ?? 16 * 1024 * 1024; + if (!Number.isSafeInteger(maximum) || maximum < 1) { + throw new TypeError("maxBytes must be a positive integer"); + } + const declaredHeader = response.headers.get("content-length"); + const declared = declaredHeader === null ? null : Number(declaredHeader); + if (declared !== null && Number.isFinite(declared) && declared > maximum) { + await response.body?.cancel(); + throw new SpaceProtocolError(413, "RepoTooLarge"); + } + if (!response.body) return new Uint8Array(); + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (length + value.byteLength > maximum) { + try { + await reader.cancel("RepoTooLarge"); + } catch { + // The size violation is authoritative even if the peer rejects cancel. + } + throw new SpaceProtocolError(413, "RepoTooLarge"); + } + chunks.push(value); + length += value.byteLength; + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +export function registerNotify( + transport: SpaceCredentialTransport, + authorityPds: string, + input: { space: string; service: string }, +): Promise<{ expiresAt: string }> { + return credentialProcedure( + transport, + authorityPds, + "com.atproto.space.registerNotify", + input, + ); +} diff --git a/packages/contrail-spaces-alpha/src/storage.ts b/packages/contrail-spaces-alpha/src/storage.ts new file mode 100644 index 0000000..d10a122 --- /dev/null +++ b/packages/contrail-spaces-alpha/src/storage.ts @@ -0,0 +1,604 @@ +import type { ContrailConfig, Database, Statement } from "@atmo-dev/contrail"; +import { + deleteIsolatedScope, + initIsolatedProjection, +} from "@atmo-dev/contrail"; +import { decryptJson, encryptJson, type EncryptedValue } from "./crypto"; +import type { StoredSpaceCredential } from "./protocol"; +import { parseSpaceUri, spaceProjectionKey } from "./uri"; + +export type WatchStatus = "active" | "paused" | "hidden"; + +export interface SpaceWatch { + spaceUri: string; + authorityDid: string; + spaceType: string; + generation: number; + status: WatchStatus; + registrationExpiresAt: string | null; + nextReconcileAt: number; + lastReconciledAt: number | null; + lastError: string | null; +} + +export interface SpaceRepoState { + spaceUri: string; + spaceGeneration: number; + repoDid: string; + pdsUrl: string; + visibleWriterGeneration: number; + rev: string; + ltHash: Uint8Array; + commitHash: Uint8Array; + removalObservations: number; +} + +export async function initSpacesStorage( + db: Database, + projection: ContrailConfig, +): Promise { + await initIsolatedProjection(db, projection); + const bigint = db.dialect?.bigintType ?? "INTEGER"; + const statements = [ + `CREATE TABLE IF NOT EXISTS spaces_watches ( + space_uri TEXT PRIMARY KEY, + authority_did TEXT NOT NULL, + space_type TEXT NOT NULL, + generation INTEGER NOT NULL, + status TEXT NOT NULL, + registration_expires_at TEXT, + next_reconcile_at ${bigint} NOT NULL, + last_reconciled_at ${bigint}, + last_error TEXT, + created_at ${bigint} NOT NULL, + updated_at ${bigint} NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_spaces_watches_due + ON spaces_watches(status, next_reconcile_at)`, + `CREATE TABLE IF NOT EXISTS spaces_repo_state ( + space_uri TEXT NOT NULL, + space_generation INTEGER NOT NULL, + repo_did TEXT NOT NULL, + pds_url TEXT NOT NULL, + visible_writer_generation INTEGER NOT NULL, + rev TEXT NOT NULL, + lt_hash BLOB NOT NULL, + commit_hash BLOB NOT NULL, + removal_observations INTEGER NOT NULL DEFAULT 0, + updated_at ${bigint} NOT NULL, + PRIMARY KEY (space_uri, space_generation, repo_did) + )`, + `CREATE TABLE IF NOT EXISTS spaces_access_leases ( + user_did TEXT NOT NULL, + space_uri TEXT NOT NULL, + space_generation INTEGER NOT NULL, + expires_at ${bigint} NOT NULL, + evidence TEXT NOT NULL, + created_at ${bigint} NOT NULL, + PRIMARY KEY (user_did, space_uri, space_generation) + )`, + `CREATE INDEX IF NOT EXISTS idx_spaces_access_expiry + ON spaces_access_leases(expires_at)`, + `CREATE TABLE IF NOT EXISTS spaces_credentials ( + space_uri TEXT NOT NULL, + space_generation INTEGER NOT NULL, + viewer_did TEXT NOT NULL, + encrypted_value TEXT NOT NULL, + expires_at ${bigint} NOT NULL, + updated_at ${bigint} NOT NULL, + PRIMARY KEY (space_uri, space_generation) + )`, + `CREATE TABLE IF NOT EXISTS spaces_sync_leases ( + space_uri TEXT NOT NULL, + space_generation INTEGER NOT NULL, + repo_did TEXT NOT NULL, + owner TEXT NOT NULL, + expires_at ${bigint} NOT NULL, + PRIMARY KEY (space_uri, space_generation, repo_did) + )`, + ]; + for (const sql of statements) await db.prepare(sql).run(); +} + +function rowToWatch(row: Record): SpaceWatch { + return { + spaceUri: String(row.space_uri), + authorityDid: String(row.authority_did), + spaceType: String(row.space_type), + generation: Number(row.generation), + status: String(row.status) as WatchStatus, + registrationExpiresAt: + row.registration_expires_at === null ? null : String(row.registration_expires_at), + nextReconcileAt: Number(row.next_reconcile_at), + lastReconciledAt: + row.last_reconciled_at === null ? null : Number(row.last_reconciled_at), + lastError: row.last_error === null ? null : String(row.last_error), + }; +} + +export async function getSpaceWatch( + db: Database, + spaceUri: string, +): Promise { + const row = await db.prepare( + `SELECT space_uri, authority_did, space_type, generation, status, + registration_expires_at, next_reconcile_at, last_reconciled_at, last_error + FROM spaces_watches WHERE space_uri = ?`, + ).bind(spaceUri).first>(); + return row ? rowToWatch(row) : null; +} + +export async function ensureSpaceWatch( + db: Database, + input: { spaceUri: string; reconcileAt?: number }, +): Promise { + const parsed = parseSpaceUri(input.spaceUri); + const now = Date.now(); + await db.prepare( + `INSERT INTO spaces_watches + (space_uri, authority_did, space_type, generation, status, + next_reconcile_at, created_at, updated_at) + VALUES (?, ?, ?, 1, 'active', ?, ?, ?) + ON CONFLICT(space_uri) DO UPDATE SET + authority_did = excluded.authority_did, + space_type = excluded.space_type, + status = CASE WHEN spaces_watches.status = 'hidden' + THEN spaces_watches.status ELSE 'active' END, + next_reconcile_at = CASE WHEN spaces_watches.next_reconcile_at < excluded.next_reconcile_at + THEN spaces_watches.next_reconcile_at ELSE excluded.next_reconcile_at END, + updated_at = excluded.updated_at`, + ).bind( + input.spaceUri, + parsed.authorityDid, + parsed.type, + input.reconcileAt ?? now, + now, + now, + ).run(); + const watch = await getSpaceWatch(db, input.spaceUri); + if (!watch) throw new Error("Could not retain Space watch"); + if (watch.status === "hidden") { + throw new Error("Deleted Space requires explicit rediscovery"); + } + return watch; +} + +export async function rediscoverSpace( + db: Database, + spaceUri: string, +): Promise { + const parsed = parseSpaceUri(spaceUri); + const hidden = await getSpaceWatch(db, spaceUri); + if (!hidden || hidden.status !== "hidden") { + throw new Error("Only a previously deleted Space can be rediscovered"); + } + const now = Date.now(); + // hideDeletedSpace already advanced the generation before making the watch + // hidden. Rediscovery activates that fresh, empty generation exactly once. + const nextGeneration = hidden.generation; + await db.prepare( + `UPDATE spaces_watches SET + authority_did = ?, space_type = ?, generation = ?, status = 'active', + registration_expires_at = NULL, next_reconcile_at = ?, + last_reconciled_at = NULL, last_error = NULL, updated_at = ? + WHERE space_uri = ? AND generation = ? AND status = 'hidden'`, + ).bind( + parsed.authorityDid, + parsed.type, + nextGeneration, + now, + now, + spaceUri, + hidden.generation, + ).run(); + const watch = await getSpaceWatch(db, spaceUri); + if (!watch || watch.status !== "active" || watch.generation !== nextGeneration) { + throw new Error("Concurrent Space rediscovery changed the watch generation"); + } + return watch; +} + +export async function listDueWatches( + db: Database, + now: number, + limit: number, +): Promise { + const rows = await db.prepare( + `SELECT space_uri, authority_did, space_type, generation, status, + registration_expires_at, next_reconcile_at, last_reconciled_at, last_error + FROM spaces_watches + WHERE status = 'active' AND next_reconcile_at <= ? + ORDER BY next_reconcile_at, space_uri LIMIT ?`, + ).bind(now, limit).all>(); + return (rows.results ?? []).map(rowToWatch); +} + +export async function updateWatch( + db: Database, + spaceUri: string, + input: { + registrationExpiresAt?: string | null; + nextReconcileAt?: number; + reconciledAt?: number; + error?: string | null; + status?: WatchStatus; + /** Ignore stale work from a prior deletion/rediscovery generation. */ + expectedGeneration?: number; + }, +): Promise { + const current = await getSpaceWatch(db, spaceUri); + if (!current) return; + const generationGuard = input.expectedGeneration === undefined + ? "" + : " AND generation = ?"; + const statement = db.prepare( + `UPDATE spaces_watches SET + registration_expires_at = ?, next_reconcile_at = ?, + last_reconciled_at = ?, last_error = ?, status = ?, updated_at = ? + WHERE space_uri = ?${generationGuard}`, + ).bind( + input.registrationExpiresAt === undefined + ? current.registrationExpiresAt + : input.registrationExpiresAt, + input.nextReconcileAt ?? current.nextReconcileAt, + input.reconciledAt === undefined ? current.lastReconciledAt : input.reconciledAt, + input.error === undefined ? current.lastError : input.error, + input.status ?? current.status, + Date.now(), + spaceUri, + ...(input.expectedGeneration === undefined ? [] : [input.expectedGeneration]), + ); + await statement.run(); +} + +function credentialContext(spaceUri: string, generation: number): string { + return `contrail-spaces-alpha:credential:${spaceProjectionKey(spaceUri, generation)}`; +} + +export async function saveCredential( + db: Database, + input: { + spaceUri: string; + generation: number; + viewerDid: string; + credential: StoredSpaceCredential; + encryptionKey: string; + }, +): Promise { + const encrypted = await encryptJson( + input.credential, + input.encryptionKey, + credentialContext(input.spaceUri, input.generation), + ); + await db.prepare( + `INSERT INTO spaces_credentials + (space_uri, space_generation, viewer_did, encrypted_value, expires_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(space_uri, space_generation) DO UPDATE SET + viewer_did = excluded.viewer_did, + encrypted_value = excluded.encrypted_value, + expires_at = excluded.expires_at, + updated_at = excluded.updated_at`, + ).bind( + input.spaceUri, + input.generation, + input.viewerDid, + JSON.stringify(encrypted), + input.credential.expiresAt, + Date.now(), + ).run(); +} + +export async function loadCredential( + db: Database, + input: { spaceUri: string; generation: number; encryptionKey: string }, +): Promise<{ viewerDid: string; credential: StoredSpaceCredential } | null> { + const row = await db.prepare( + `SELECT viewer_did, encrypted_value, expires_at FROM spaces_credentials + WHERE space_uri = ? AND space_generation = ?`, + ).bind(input.spaceUri, input.generation).first<{ + viewer_did: string; + encrypted_value: string; + expires_at: number; + }>(); + if (!row || Number(row.expires_at) <= Date.now()) return null; + const credential = await decryptJson( + JSON.parse(row.encrypted_value) as EncryptedValue, + input.encryptionKey, + credentialContext(input.spaceUri, input.generation), + ); + if (credential.expiresAt <= Date.now()) return null; + return { viewerDid: row.viewer_did, credential }; +} + +export async function saveAccessLease( + db: Database, + input: { + userDid: string; + spaceUri: string; + generation: number; + expiresAt: number; + evidence?: string; + }, +): Promise { + const now = Date.now(); + await db.prepare( + `INSERT INTO spaces_access_leases + (user_did, space_uri, space_generation, expires_at, evidence, created_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_did, space_uri, space_generation) DO UPDATE SET + expires_at = excluded.expires_at, evidence = excluded.evidence, + created_at = excluded.created_at`, + ).bind( + input.userDid, + input.spaceUri, + input.generation, + input.expiresAt, + input.evidence ?? "delegation", + now, + ).run(); +} + +export async function hasAccessLease( + db: Database, + input: { userDid: string; spaceUri: string; generation: number; now?: number }, +): Promise { + const row = await db.prepare( + `SELECT 1 AS ok FROM spaces_access_leases + WHERE user_did = ? AND space_uri = ? AND space_generation = ? AND expires_at > ?`, + ).bind( + input.userDid, + input.spaceUri, + input.generation, + input.now ?? Date.now(), + ).first<{ ok: number }>(); + return row?.ok === 1; +} + +function asBytes(value: unknown, name: string): Uint8Array { + if (value instanceof Uint8Array) return value; + if (value instanceof ArrayBuffer) return new Uint8Array(value); + if (Array.isArray(value) && value.every((item) => Number.isInteger(item))) { + return Uint8Array.from(value as number[]); + } + throw new Error(`Invalid ${name} bytes in Space checkpoint`); +} + +function rowToRepo(row: Record): SpaceRepoState { + return { + spaceUri: String(row.space_uri), + spaceGeneration: Number(row.space_generation), + repoDid: String(row.repo_did), + pdsUrl: String(row.pds_url), + visibleWriterGeneration: Number(row.visible_writer_generation), + rev: String(row.rev), + ltHash: asBytes(row.lt_hash, "LtHash"), + commitHash: asBytes(row.commit_hash, "commit hash"), + removalObservations: Number(row.removal_observations), + }; +} + +export async function listRepoStates( + db: Database, + watch: Pick, +): Promise { + const rows = await db.prepare( + `SELECT space_uri, space_generation, repo_did, pds_url, + visible_writer_generation, rev, lt_hash, commit_hash, removal_observations + FROM spaces_repo_state WHERE space_uri = ? AND space_generation = ?`, + ).bind(watch.spaceUri, watch.generation).all>(); + return (rows.results ?? []).map(rowToRepo); +} + +export async function getRepoState( + db: Database, + watch: Pick, + repoDid: string, +): Promise { + const row = await db.prepare( + `SELECT space_uri, space_generation, repo_did, pds_url, + visible_writer_generation, rev, lt_hash, commit_hash, removal_observations + FROM spaces_repo_state + WHERE space_uri = ? AND space_generation = ? AND repo_did = ?`, + ).bind(watch.spaceUri, watch.generation, repoDid).first>(); + return row ? rowToRepo(row) : null; +} + +/** Commit checkpoint metadata after projection statements in the same batch. */ +export function saveRepoStateStatement( + db: Database, + state: Omit & { removalObservations?: number }, +): Statement { + return db.prepare( + `INSERT INTO spaces_repo_state + (space_uri, space_generation, repo_did, pds_url, + visible_writer_generation, rev, lt_hash, commit_hash, + removal_observations, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(space_uri, space_generation, repo_did) DO UPDATE SET + pds_url = excluded.pds_url, + visible_writer_generation = excluded.visible_writer_generation, + rev = excluded.rev, lt_hash = excluded.lt_hash, + commit_hash = excluded.commit_hash, + removal_observations = excluded.removal_observations, + updated_at = excluded.updated_at`, + ).bind( + state.spaceUri, + state.spaceGeneration, + state.repoDid, + state.pdsUrl, + state.visibleWriterGeneration, + state.rev, + state.ltHash, + state.commitHash, + state.removalObservations ?? 0, + Date.now(), + ); +} + +export async function noteRepoOmissions( + db: Database, + watch: Pick, + present: ReadonlySet, +): Promise { + const states = await listRepoStates(db, watch); + const confirmed: SpaceRepoState[] = []; + for (const state of states) { + if (present.has(state.repoDid)) { + if (state.removalObservations !== 0) { + await db.prepare( + `UPDATE spaces_repo_state SET removal_observations = 0 + WHERE space_uri = ? AND space_generation = ? AND repo_did = ?`, + ).bind(watch.spaceUri, watch.generation, state.repoDid).run(); + } + continue; + } + const observations = state.removalObservations + 1; + await db.prepare( + `UPDATE spaces_repo_state SET removal_observations = ?, updated_at = ? + WHERE space_uri = ? AND space_generation = ? AND repo_did = ?`, + ).bind(observations, Date.now(), watch.spaceUri, watch.generation, state.repoDid).run(); + if (observations >= 2) confirmed.push({ ...state, removalObservations: observations }); + } + return confirmed; +} + +export async function deleteRepoState( + db: Database, + watch: Pick, + repoDid: string, +): Promise { + await db.prepare( + `DELETE FROM spaces_repo_state + WHERE space_uri = ? AND space_generation = ? AND repo_did = ?`, + ).bind(watch.spaceUri, watch.generation, repoDid).run(); +} + +export async function acquireSyncLease( + db: Database, + input: { + spaceUri: string; + generation: number; + repoDid: string; + owner: string; + ttlMs?: number; + }, +): Promise { + const now = Date.now(); + const expires = now + (input.ttlMs ?? 60_000); + await db.prepare( + `INSERT INTO spaces_sync_leases + (space_uri, space_generation, repo_did, owner, expires_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(space_uri, space_generation, repo_did) DO UPDATE SET + owner = excluded.owner, expires_at = excluded.expires_at + WHERE spaces_sync_leases.expires_at <= ? OR spaces_sync_leases.owner = ?`, + ).bind( + input.spaceUri, + input.generation, + input.repoDid, + input.owner, + expires, + now, + input.owner, + ).run(); + const row = await db.prepare( + `SELECT owner, expires_at FROM spaces_sync_leases + WHERE space_uri = ? AND space_generation = ? AND repo_did = ?`, + ).bind(input.spaceUri, input.generation, input.repoDid).first<{ + owner: string; + expires_at: number; + }>(); + return row?.owner === input.owner && Number(row.expires_at) === expires; +} + +export async function renewSyncLease( + db: Database, + input: { + spaceUri: string; + generation: number; + repoDid: string; + owner: string; + ttlMs?: number; + }, +): Promise { + const now = Date.now(); + const expires = now + (input.ttlMs ?? 60_000); + await db.prepare( + `UPDATE spaces_sync_leases SET expires_at = ? + WHERE space_uri = ? AND space_generation = ? AND repo_did = ? + AND owner = ? AND expires_at > ?`, + ).bind( + expires, + input.spaceUri, + input.generation, + input.repoDid, + input.owner, + now, + ).run(); + const row = await db.prepare( + `SELECT owner, expires_at FROM spaces_sync_leases + WHERE space_uri = ? AND space_generation = ? AND repo_did = ?`, + ).bind(input.spaceUri, input.generation, input.repoDid).first<{ + owner: string; + expires_at: number; + }>(); + return row?.owner === input.owner && Number(row.expires_at) === expires; +} + +export async function releaseSyncLease( + db: Database, + input: { spaceUri: string; generation: number; repoDid: string; owner: string }, +): Promise { + await db.prepare( + `DELETE FROM spaces_sync_leases + WHERE space_uri = ? AND space_generation = ? AND repo_did = ? AND owner = ?`, + ).bind(input.spaceUri, input.generation, input.repoDid, input.owner).run(); +} + +/** Immediately deny all reads and invalidate credentials/work for a deleted Space. */ +export async function hideDeletedSpace( + db: Database, + watch: SpaceWatch, +): Promise { + const nextGeneration = watch.generation + 1; + await db.batch([ + db.prepare( + `UPDATE spaces_watches SET status = 'hidden', generation = ?, + registration_expires_at = NULL, last_error = NULL, updated_at = ? + WHERE space_uri = ? AND generation = ?`, + ).bind(nextGeneration, Date.now(), watch.spaceUri, watch.generation), + db.prepare( + `DELETE FROM isolated_projection_partitions WHERE scope_key = ?`, + ).bind(spaceProjectionKey(watch.spaceUri, watch.generation)), + db.prepare( + `DELETE FROM spaces_access_leases + WHERE space_uri = ? AND space_generation = ?`, + ).bind(watch.spaceUri, watch.generation), + db.prepare( + `DELETE FROM spaces_credentials + WHERE space_uri = ? AND space_generation = ?`, + ).bind(watch.spaceUri, watch.generation), + db.prepare( + `DELETE FROM spaces_sync_leases + WHERE space_uri = ? AND space_generation = ?`, + ).bind(watch.spaceUri, watch.generation), + ]); +} + +/** Complete plaintext projection/checkpoint cleanup after deletion has already + * hidden the scope and invalidated credentials. */ +export async function purgeSpaceGeneration( + db: Database, + projection: ContrailConfig, + watch: Pick, +): Promise { + await deleteIsolatedScope(db, projection, { + kind: "isolated", + key: spaceProjectionKey(watch.spaceUri, watch.generation), + }); + await db.prepare( + `DELETE FROM spaces_repo_state + WHERE space_uri = ? AND space_generation = ?`, + ).bind(watch.spaceUri, watch.generation).run(); +} diff --git a/packages/contrail-spaces-alpha/src/sync.ts b/packages/contrail-spaces-alpha/src/sync.ts new file mode 100644 index 0000000..826c4cc --- /dev/null +++ b/packages/contrail-spaces-alpha/src/sync.ts @@ -0,0 +1,620 @@ +import { parseCid } from "@atproto/lex-data"; +import { + RepoCommit, + verifyCommit, + verifyRepoCarFull, + type SignedCommit, +} from "@atproto/space"; +import { + activateIsolatedPartitionStatement, + createIngestEvent, + createIsolatedProjection, + deleteIsolatedPartitionGeneration, + hideIsolatedPartitionStatement, + ingestRecords, + rebuildIsolatedCountsStatements, + recordTimeUs, + type ContrailConfig, + type Database, + type IngestEvent, +} from "@atmo-dev/contrail"; +import { + getRepoCar, + getSpace, + listRepoOps, + listRepos, + registerNotify, + SpaceCredentialTransport, + SpaceIdentityResolver, + SpaceProtocolError, + type ProtocolOptions, + type SignedCommitInput, +} from "./protocol"; +import { + acquireSyncLease, + deleteRepoState, + getRepoState, + getSpaceWatch, + hideDeletedSpace, + listDueWatches, + listRepoStates, + loadCredential, + noteRepoOmissions, + purgeSpaceGeneration, + releaseSyncLease, + renewSyncLease, + saveRepoStateStatement, + updateWatch, + type SpaceRepoState, + type SpaceWatch, +} from "./storage"; +import { formatSpaceRecordUri, parseSpaceUri, spaceProjectionKey } from "./uri"; + +export interface SpaceTypeConfig { + collections: readonly string[]; + skey?: string; + /** Require this provider to be the managing app. Defaults to true. Set false + * only for a deliberately supported upstream-owned policy. */ + requireManagingApp?: boolean; +} + +export interface SpacesSyncConfig { + projection: ContrailConfig; + spaceTypes: Record; + serviceAudience: string; + credentialEncryptionKey: string; + reconcileIntervalMs?: number; + registrationRenewalWindowMs?: number; + /** Maximum mutations admitted with one verified incremental checkpoint. + * Larger commits recover through a staged full CAR instead (default 10). */ + maxAtomicMutations?: number; + /** Maximum complete repo CAR buffered by the Worker (default 16 MiB). */ + maxRepoCarBytes?: number; + protocol?: ProtocolOptions; + logger?: Pick; + onInvalidate?: (spaceUri: string) => void | Promise; +} + +export interface ReconcileOptions { + limit?: number; + deadline?: number; +} + +const SYNC_LEASE_TTL_MS = 90_000; + +class SyncLeaseLostError extends Error { + constructor() { + super("Space repo sync lease was lost"); + this.name = "SyncLeaseLostError"; + } +} + +function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + for (let index = 0; index < left.byteLength; index++) { + if (left[index] !== right[index]) return false; + } + return true; +} + +function asSignedCommit(input: SignedCommitInput): SignedCommit { + if (input.ver !== 1) throw new Error(`Unsupported Space commit version ${input.ver}`); + return input as SignedCommit; +} + +function configuredCollections(config: SpacesSyncConfig, spaceType: string): Set { + return new Set(config.spaceTypes[spaceType]?.collections ?? []); +} + +export class SpacesSyncEngine { + readonly identities: SpaceIdentityResolver; + private readonly logger: Pick; + + constructor( + readonly db: Database, + readonly config: SpacesSyncConfig, + ) { + this.identities = new SpaceIdentityResolver(config.protocol); + this.logger = config.logger ?? console; + } + + private scope(watch: SpaceWatch) { + return { + kind: "isolated" as const, + key: spaceProjectionKey(watch.spaceUri, watch.generation), + }; + } + + private async transport(watch: SpaceWatch): Promise { + const stored = await loadCredential(this.db, { + spaceUri: watch.spaceUri, + generation: watch.generation, + encryptionKey: this.config.credentialEncryptionKey, + }); + if (!stored) { + await updateWatch(this.db, watch.spaceUri, { + status: "paused", + error: "No unexpired Space credential", + expectedGeneration: watch.generation, + }); + throw new SpaceProtocolError(401, "CredentialExpired"); + } + return SpaceCredentialTransport.restore( + stored.credential, + this.config.protocol?.fetch, + ); + } + + async validateWatch( + watch: SpaceWatch, + suppliedTransport?: SpaceCredentialTransport, + ): Promise { + const transport = suppliedTransport ?? await this.transport(watch); + const parsed = parseSpaceUri(watch.spaceUri); + const type = this.config.spaceTypes[parsed.type]; + if (!type) throw new Error(`Unsupported Space type: ${parsed.type}`); + if (type.skey !== undefined && parsed.skey !== type.skey) { + throw new Error(`Unsupported Space key for ${parsed.type}`); + } + const authorityPds = await this.identities.resolvePds(parsed.authorityDid); + const description = await getSpace(transport, authorityPds, watch.spaceUri); + if (description.uri !== watch.spaceUri) { + throw new Error("Space authority returned a different URI"); + } + const managingPolicy = description.policy?.$type === + "com.atproto.simplespace.defs#managingAppPolicy"; + if (type.requireManagingApp !== false && !managingPolicy) { + throw new Error("Space does not use the configured managing-app policy"); + } + if (managingPolicy && + description.policy.managingApp !== this.config.serviceAudience) { + throw new Error("Space is managed by a different application"); + } + if ( + description.appAccess?.$type !== undefined && + description.appAccess.$type !== "com.atproto.simplespace.defs#open" + ) { + throw new Error("The first Spaces-alpha provider supports only open appAccess"); + } + } + + async reconcileDue(options: ReconcileOptions = {}): Promise { + const limit = Math.min(Math.max(1, options.limit ?? 5), 50); + const deadline = options.deadline ?? Date.now() + 25_000; + const watches = await listDueWatches(this.db, Date.now(), limit); + let completed = 0; + for (const watch of watches) { + if (Date.now() >= deadline) break; + try { + await this.reconcileSpace(watch.spaceUri, { deadline }); + completed++; + } catch (error) { + this.logger.error(`[spaces] reconciliation failed for ${watch.spaceUri}`, error); + } + } + return completed; + } + + async reconcileSpace( + spaceUri: string, + options: { deadline?: number; preferredRepo?: string } = {}, + ): Promise { + const watch = await getSpaceWatch(this.db, spaceUri); + if (!watch || watch.status === "hidden") return; + const deadline = options.deadline ?? Date.now() + 25_000; + try { + const transport = await this.transport(watch); + await this.validateWatch(watch, transport); + const authorityPds = await this.identities.resolvePds(watch.authorityDid); + const renewalWindow = this.config.registrationRenewalWindowMs ?? 60 * 60_000; + if ( + !watch.registrationExpiresAt || + Date.parse(watch.registrationExpiresAt) - Date.now() < renewalWindow + ) { + const registration = await registerNotify(transport, authorityPds, { + space: watch.spaceUri, + service: this.config.serviceAudience, + }); + await updateWatch(this.db, watch.spaceUri, { + registrationExpiresAt: registration.expiresAt, + expectedGeneration: watch.generation, + }); + } + + const advertised = new Map(); + let cursor: string | undefined; + do { + const page = await listRepos(transport, authorityPds, { + space: watch.spaceUri, + limit: 1000, + cursor, + }); + for (const repo of page.repos) { + if (!/^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/.test(repo.did) || + !repo.rev || !(repo.hash instanceof Uint8Array) || + repo.hash.byteLength !== 32) { + throw new Error("Space authority returned an invalid writer entry"); + } + advertised.set(repo.did, { rev: repo.rev, hash: repo.hash }); + } + cursor = page.cursor; + } while (cursor && Date.now() < deadline); + if (cursor) throw new Error("Reconciliation deadline reached during writer listing"); + + const local = new Map( + (await listRepoStates(this.db, watch)).map((state) => [state.repoDid, state]), + ); + const advertisedEntries = [...advertised]; + if (options.preferredRepo && advertised.has(options.preferredRepo)) { + advertisedEntries.sort(([left], [right]) => + left === options.preferredRepo ? -1 : right === options.preferredRepo ? 1 : 0); + } + for (const [repoDid, remote] of advertisedEntries) { + if (Date.now() >= deadline) break; + const state = local.get(repoDid); + if (!state || state.rev !== remote.rev || !bytesEqual(state.commitHash, remote.hash)) { + await this.syncRepo(watch.spaceUri, repoDid); + } + } + + // One stale authority omission cannot erase a verified writer. Two + // complete reconciliations are the deliberately conservative v1 evidence. + const removed = await noteRepoOmissions(this.db, watch, new Set(advertised.keys())); + for (const state of removed) await this.removeWriter(watch, state); + + const now = Date.now(); + await updateWatch(this.db, watch.spaceUri, { + status: "active", + error: null, + reconciledAt: now, + nextReconcileAt: now + (this.config.reconcileIntervalMs ?? 5 * 60_000), + expectedGeneration: watch.generation, + }); + } catch (error) { + if (error instanceof SpaceProtocolError && error.deleted) { + await this.deleteSpace(watch); + return; + } + const paused = error instanceof SpaceProtocolError && + (error.accessDenied || error.code === "CredentialExpired"); + await updateWatch(this.db, watch.spaceUri, { + status: paused ? "paused" : watch.status, + error: error instanceof Error ? error.message.slice(0, 500) : "Sync failed", + nextReconcileAt: Date.now() + 60_000, + expectedGeneration: watch.generation, + }); + throw error; + } + } + + async syncRepo( + spaceUri: string, + repoDid: string, + advertised?: { rev?: string; hash?: Uint8Array }, + ): Promise { + const watch = await getSpaceWatch(this.db, spaceUri); + if (!watch || watch.status === "hidden") return; + const owner = crypto.randomUUID(); + if (!await acquireSyncLease(this.db, { + spaceUri, + generation: watch.generation, + repoDid, + owner, + ttlMs: SYNC_LEASE_TTL_MS, + })) return; + // Every durable stage renews this owner token first. Work may outlive the + // original TTL while downloading/verifying, but a worker that loses the + // lease is fenced before it can resume projection or checkpoint writes. + const assertLease = async (): Promise => { + if (!await renewSyncLease(this.db, { + spaceUri, + generation: watch.generation, + repoDid, + owner, + ttlMs: SYNC_LEASE_TTL_MS, + })) { + throw new SyncLeaseLostError(); + } + }; + try { + const currentWatch = await getSpaceWatch(this.db, spaceUri); + if (!currentWatch || currentWatch.generation !== watch.generation || + currentWatch.status === "hidden") return; + const transport = await this.transport(watch); + await assertLease(); + const state = await getRepoState(this.db, watch, repoDid); + if (!state) { + await this.recoverRepo(watch, repoDid, transport, assertLease); + } else { + try { + await this.incrementalRepo(watch, state, transport, assertLease); + } catch (error) { + if (error instanceof SyncLeaseLostError) throw error; + this.logger.warn( + `[spaces] incremental sync fell back to recovery for ${spaceUri} ${repoDid}`, + error, + ); + await this.recoverRepo(watch, repoDid, transport, assertLease); + } + } + if (advertised?.hash) { + const updated = await getRepoState(this.db, watch, repoDid); + if (updated && advertised.rev === updated.rev && + !bytesEqual(updated.commitHash, advertised.hash)) { + throw new Error("Synchronized commit did not match notification target hash"); + } + } + await this.config.onInvalidate?.(spaceUri); + } catch (error) { + if (error instanceof SpaceProtocolError && error.deleted) { + await assertLease(); + await this.deleteSpace(watch); + } else { + throw error; + } + } finally { + await releaseSyncLease(this.db, { + spaceUri, + generation: watch.generation, + repoDid, + owner, + }); + } + } + + private async incrementalRepo( + watch: SpaceWatch, + local: SpaceRepoState, + transport: SpaceCredentialTransport, + assertLease: () => Promise, + ): Promise { + const writerPds = await this.identities.resolvePds(local.repoDid); + const state = RepoCommit.fromState(local.ltHash); + const events: IngestEvent[] = []; + let cursor: string | undefined; + let commit: SignedCommit | undefined; + const allowed = configuredCollections(this.config, watch.spaceType); + do { + const page = await listRepoOps(transport, writerPds, { + space: watch.spaceUri, + repo: local.repoDid, + since: local.rev, + cursor, + limit: 1000, + }); + for (const operation of page.ops) { + state.applyOp({ + collection: operation.collection, + rkey: operation.rkey, + cid: operation.cid ? parseCid(operation.cid) : null, + prev: operation.prev ? parseCid(operation.prev) : null, + }); + if (!allowed.has(operation.collection)) continue; + if (operation.cid && operation.value === undefined) { + throw new Error("Incremental operation omitted its record value"); + } + const observedAt = Date.now() * 1000; + events.push(createIngestEvent({ + uri: formatSpaceRecordUri({ + spaceUri: watch.spaceUri, + writerDid: local.repoDid, + collection: operation.collection, + rkey: operation.rkey, + }), + did: local.repoDid, + collection: operation.collection, + rkey: operation.rkey, + operation: operation.cid + ? operation.prev + ? "update" + : "create" + : "delete", + cid: operation.cid, + value: operation.value, + timeUs: recordTimeUs( + operation.value, + operation.collection, + this.config.projection, + observedAt, + ), + indexedAt: observedAt, + source: { + id: `space:${watch.spaceUri}:${watch.generation}`, + revision: operation.rev, + time_us: observedAt, + }, + })); + if (events.length > (this.config.maxAtomicMutations ?? 10)) { + throw new Error("Incremental commit exceeds the bounded atomic mutation limit"); + } + } + cursor = page.cursor; + if (page.commit) commit = asSignedCommit(page.commit); + } while (cursor); + if (!commit) throw new Error("Incremental operation log did not reach a commit"); + const key = await this.identities.resolveSigningKey(local.repoDid); + const valid = await verifyCommit( + commit, + { space: watch.spaceUri, author: local.repoDid, rev: commit.rev }, + key, + ); + if (!valid || !state.matches(commit)) { + // Retry key rotation once before forcing a full recovery. + const refreshed = await this.identities.resolveSigningKey(local.repoDid, true); + const refreshedValid = await verifyCommit( + commit, + { space: watch.spaceUri, author: local.repoDid, rev: commit.rev }, + refreshed, + ); + if (!refreshedValid || !state.matches(commit)) { + throw new Error("Incremental commit signature or LtHash verification failed"); + } + } + if (events.length > (this.config.maxAtomicMutations ?? 10)) { + throw new Error("Incremental commit exceeds the bounded atomic mutation limit"); + } + await assertLease(); + const checkpoint = saveRepoStateStatement(this.db, { + ...local, + pdsUrl: writerPds, + rev: commit.rev, + ltHash: state.setHash.state(), + commitHash: commit.hash, + removalObservations: 0, + }); + if (events.length) { + await ingestRecords(this.db, events, this.config.projection, { + projection: createIsolatedProjection({ + scope: this.scope(watch), + partition: local.repoDid, + generation: local.visibleWriterGeneration, + }), + trailingStatements: [checkpoint], + skipDiagnostics: true, + }); + } else { + await this.db.batch([checkpoint]); + } + } + + private async recoverRepo( + watch: SpaceWatch, + repoDid: string, + transport: SpaceCredentialTransport, + assertLease: () => Promise, + ): Promise { + const prior = await getRepoState(this.db, watch, repoDid); + const writerGeneration = (prior?.visibleWriterGeneration ?? 0) + 1; + const writerPds = await this.identities.resolvePds(repoDid); + const bytes = await getRepoCar(transport, writerPds, { + space: watch.spaceUri, + repo: repoDid, + maxBytes: this.config.maxRepoCarBytes, + }); + const key = await this.identities.resolveSigningKey(repoDid); + let recovered; + try { + recovered = await verifyRepoCarFull( + [bytes], + { space: watch.spaceUri, author: repoDid, didKey: key }, + ); + } catch { + const refreshed = await this.identities.resolveSigningKey(repoDid, true); + recovered = await verifyRepoCarFull( + [bytes], + { space: watch.spaceUri, author: repoDid, didKey: refreshed }, + ); + } + const allowed = configuredCollections(this.config, watch.spaceType); + const events: IngestEvent[] = []; + const observedAt = Date.now() * 1000; + for (const record of recovered.records) { + if (!allowed.has(record.collection)) continue; + events.push(createIngestEvent({ + uri: formatSpaceRecordUri({ + spaceUri: watch.spaceUri, + writerDid: repoDid, + collection: record.collection, + rkey: record.rkey, + }), + did: repoDid, + collection: record.collection, + rkey: record.rkey, + operation: "create", + cid: record.cid.toString(), + value: record.record, + timeUs: recordTimeUs( + record.record, + record.collection, + this.config.projection, + observedAt, + ), + indexedAt: observedAt, + source: { + id: `space:${watch.spaceUri}:${watch.generation}`, + revision: recovered.commit.rev, + time_us: observedAt, + }, + })); + } + + const target = { + scope: this.scope(watch), + partition: repoDid, + generation: writerGeneration, + }; + await assertLease(); + await deleteIsolatedPartitionGeneration(this.db, this.config.projection, target); + const chunkSize = this.config.maxAtomicMutations ?? 10; + for (let offset = 0; offset < events.length; offset += chunkSize) { + await assertLease(); + await ingestRecords( + this.db, + events.slice(offset, offset + chunkSize), + this.config.projection, + { + projection: createIsolatedProjection(target), + authoritativeSourceObservation: true, + skipDiagnostics: true, + }, + ); + } + + await assertLease(); + const checkpoint = saveRepoStateStatement(this.db, { + spaceUri: watch.spaceUri, + spaceGeneration: watch.generation, + repoDid, + pdsUrl: writerPds, + visibleWriterGeneration: writerGeneration, + rev: recovered.commit.rev, + ltHash: recovered.repo.setHash.state(), + commitHash: recovered.commit.hash, + removalObservations: 0, + }); + await this.db.batch([ + activateIsolatedPartitionStatement(this.db, { ...target, activate: true }), + ...rebuildIsolatedCountsStatements( + this.db, + this.config.projection, + this.scope(watch), + ), + checkpoint, + ]); + if (prior && prior.visibleWriterGeneration !== writerGeneration) { + await assertLease(); + await deleteIsolatedPartitionGeneration(this.db, this.config.projection, { + scope: this.scope(watch), + partition: repoDid, + generation: prior.visibleWriterGeneration, + }); + } + } + + private async deleteSpace(watch: SpaceWatch): Promise { + await hideDeletedSpace(this.db, watch); + await purgeSpaceGeneration(this.db, this.config.projection, watch); + await this.config.onInvalidate?.(watch.spaceUri); + } + + private async removeWriter( + watch: SpaceWatch, + state: SpaceRepoState, + ): Promise { + await this.db.batch([ + hideIsolatedPartitionStatement(this.db, this.scope(watch), state.repoDid), + ...rebuildIsolatedCountsStatements( + this.db, + this.config.projection, + this.scope(watch), + ), + ]); + await deleteRepoState(this.db, watch, state.repoDid); + await deleteIsolatedPartitionGeneration(this.db, this.config.projection, { + scope: this.scope(watch), + partition: state.repoDid, + generation: state.visibleWriterGeneration, + }); + await this.config.onInvalidate?.(watch.spaceUri); + } +} diff --git a/packages/contrail-spaces-alpha/src/uri.ts b/packages/contrail-spaces-alpha/src/uri.ts new file mode 100644 index 0000000..56d3021 --- /dev/null +++ b/packages/contrail-spaces-alpha/src/uri.ts @@ -0,0 +1,89 @@ +const DID = "did:[a-z0-9]+:[A-Za-z0-9._:%-]+"; +const NSID = "[A-Za-z][A-Za-z0-9.-]*[A-Za-z0-9]"; +const RKEY = "[A-Za-z0-9._~:-]+"; +const SPACE_PATTERN = new RegExp( + `^at://(${DID})/space/(${NSID})/(${RKEY})$`, +); + +export interface ParsedSpaceUri { + uri: string; + authorityDid: string; + type: string; + skey: string; +} + +export interface ParsedSpaceRecordUri extends ParsedSpaceUri { + spaceUri: string; + writerDid: string; + collection: string; + rkey: string; +} + +export function parseSpaceUri(value: unknown): ParsedSpaceUri { + if (typeof value !== "string") throw new TypeError("Space URI must be a string"); + const match = SPACE_PATTERN.exec(value); + if (!match) throw new TypeError(`Invalid Space URI: ${value}`); + return { + uri: value, + authorityDid: match[1], + type: match[2], + skey: match[3], + }; +} + +export function formatSpaceUri(input: { + authorityDid: string; + type: string; + skey: string; +}): string { + return parseSpaceUri( + `at://${input.authorityDid}/space/${input.type}/${input.skey}`, + ).uri; +} + +export function parseSpaceRecordUri(value: unknown): ParsedSpaceRecordUri { + if (typeof value !== "string") { + throw new TypeError("Space record URI must be a string"); + } + const parts = value.split("/"); + // at: // authority space type skey writer collection rkey + if (parts.length !== 9 || parts[3] !== "space") { + throw new TypeError(`Invalid Space record URI: ${value}`); + } + const spaceUri = parts.slice(0, 6).join("/"); + const space = parseSpaceUri(spaceUri); + const writerDid = parts[6]; + const collection = parts[7]; + const rkey = parts[8]; + if (!new RegExp(`^${DID}$`).test(writerDid)) { + throw new TypeError(`Invalid Space record writer: ${writerDid}`); + } + if (!new RegExp(`^${NSID}$`).test(collection)) { + throw new TypeError(`Invalid Space record collection: ${collection}`); + } + if (!new RegExp(`^${RKEY}$`).test(rkey)) { + throw new TypeError(`Invalid Space record key: ${rkey}`); + } + return { ...space, uri: value, spaceUri, writerDid, collection, rkey }; +} + +export function formatSpaceRecordUri(input: { + spaceUri: string; + writerDid: string; + collection: string; + rkey: string; +}): string { + parseSpaceUri(input.spaceUri); + const value = `${input.spaceUri}/${input.writerDid}/${input.collection}/${input.rkey}`; + parseSpaceRecordUri(value); + return value; +} + +/** Opaque key consumed by Contrail's protocol-neutral isolated projector. */ +export function spaceProjectionKey(spaceUri: string, generation: number): string { + parseSpaceUri(spaceUri); + if (!Number.isSafeInteger(generation) || generation < 0) { + throw new TypeError("Space generation must be a non-negative integer"); + } + return `${spaceUri}\u0000${generation}`; +} diff --git a/packages/contrail-spaces-alpha/src/worker.ts b/packages/contrail-spaces-alpha/src/worker.ts new file mode 100644 index 0000000..d516d77 --- /dev/null +++ b/packages/contrail-spaces-alpha/src/worker.ts @@ -0,0 +1,841 @@ +import { + bindRecordValidationLexicons, + createExactServiceAuthGate, + prepareRecordValidation, + queryIsolatedRecords, + resolveConfig, + validateConfig, + type ContrailConfig, + type Database, +} from "@atmo-dev/contrail"; +import { decodeJwtClaims } from "./crypto"; +import { + exchangeSpaceCredential, + SpaceProtocolError, +} from "./protocol"; +import { + ensureSpaceWatch, + getSpaceWatch, + hasAccessLease, + hideDeletedSpace, + initSpacesStorage, + purgeSpaceGeneration, + rediscoverSpace, + saveAccessLease, + saveCredential, + updateWatch, + type SpaceWatch, +} from "./storage"; +import { + SpacesSyncEngine, + type SpaceTypeConfig, +} from "./sync"; +import { parseSpaceUri, spaceProjectionKey } from "./uri"; + +export const AUTHORIZE_SPACE_METHOD = "authorizeSpace"; +export const SYNC_SPACE_METHOD = "syncSpace"; +export const LIST_SPACE_RECORDS_METHOD = "listSpaceRecords"; +export const GET_SPACE_RECORD_METHOD = "getSpaceRecord"; + +export interface SpaceAuthorizationInput { + userDid: string; + spaceUri: string; + action: "read" | "write"; + method: string; +} + +export interface SpacesWorkerEnv { + [key: string]: unknown; +} + +export interface SpacesWorkerOptions { + projection: ContrailConfig; + lexicons?: readonly object[]; + spaceTypes: Record; + service: { + endpoint: string; + audience: string; + /** Optional DID resolver for private networks and deterministic tests. */ + resolver?: NonNullable["resolver"]; + }; + bindings?: { + database?: string; + credentialEncryptionKey?: string; + queue?: string; + }; + authorization?: { + authorize( + input: SpaceAuthorizationInput, + context: { env: Env; db: Database }, + ): boolean | Promise; + }; + accessLeaseMs?: number; + reconcileIntervalMs?: number; + maxAtomicMutations?: number; + maxRepoCarBytes?: number; + protocol?: { + plcUrl?: string; + additionalAllowedHosts?: string[]; + fetch?: typeof globalThis.fetch; + }; + onInvalidate?: (spaceUri: string, env: Env) => void | Promise; +} + +type SyncMessage = + | { kind: "reconcile"; space: string; preferredRepo?: string } + | { kind: "repo"; space: string; repo: string; rev?: string; hash?: string }; + +function json( + value: unknown, + status = 200, + headers?: HeadersInit, +): Response { + return new Response(JSON.stringify(value), { + status, + headers: { + "content-type": "application/json; charset=utf-8", + ...headers, + }, + }); +} + +function privateJson(value: unknown, status = 200): Response { + return json(value, status, { "cache-control": "private, no-store" }); +} + +async function boundedJsonBody(request: Request, maximum = 64 * 1024): Promise { + const declaredHeader = request.headers.get("content-length"); + const declared = declaredHeader === null ? null : Number(declaredHeader); + if (declared !== null && Number.isFinite(declared) && declared > maximum) { + try { + await request.body?.cancel(); + } catch { + // The declared size is already sufficient to reject the request. + } + throw new TypeError("Request body is too large"); + } + const reader = request.body?.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (length + value.byteLength > maximum) { + try { + await reader.cancel("Request body is too large"); + } catch { + // Keep the stable request-size error if cancellation itself fails. + } + throw new TypeError("Request body is too large"); + } + chunks.push(value); + length += value.byteLength; + } + } finally { + reader.releaseLock(); + } + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return JSON.parse(new TextDecoder().decode(bytes)) as T; + } catch { + throw new TypeError("Request body must be valid JSON"); + } +} + +function errorResponse(error: unknown): Response { + if (error instanceof TypeError) { + return json({ error: "InvalidRequest", message: error.message }, 400); + } + if (error instanceof SpaceProtocolError) { + const status = error.code === "RepoTooLarge" + ? 413 + : error.deleted + ? 410 + : error.notFound + ? 404 + : error.accessDenied || error.code === "CredentialExpired" + ? 403 + : error.status >= 500 + ? 502 + : 400; + return json({ error: error.code, message: error.message }, status, { + "cache-control": "private, no-store", + }); + } + const message = error instanceof Error ? error.message : "Internal error"; + console.error("[spaces] request failed", error); + return json({ error: "InternalServerError", message }, 500, { + "cache-control": "no-store", + }); +} + +function xrpcMethod(pathname: string): string | null { + const prefix = "/xrpc/"; + return pathname.startsWith(prefix) ? decodeURIComponent(pathname.slice(prefix.length)) : null; +} + +function collectionMethod(namespace: string, short: string, suffix: string): string { + return `${namespace}.${short}.${suffix}`; +} + +function baseDid(value: string): string { + return value.split("#", 1)[0]; +} + +function validDid(value: unknown): value is string { + return typeof value === "string" && + /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/.test(value); +} + +function decodeBytes(value: unknown): Uint8Array | undefined { + const encoded = typeof value === "string" + ? value + : value && typeof value === "object" && typeof (value as { $bytes?: unknown }).$bytes === "string" + ? (value as { $bytes: string }).$bytes + : undefined; + if (!encoded) return undefined; + try { + const normalized = encoded.replaceAll("-", "+").replaceAll("_", "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)); + } catch { + return undefined; + } +} + +function encodeBytes(value: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < value.length; offset += 0x8000) { + binary += String.fromCharCode(...value.subarray(offset, offset + 0x8000)); + } + return btoa(binary); +} + +function principalDid(principal: { issuer?: unknown } | undefined): string { + if (typeof principal?.issuer !== "string") throw new Error("Verified token has no issuer"); + return baseDid(principal.issuer); +} + +function parsedRecord(row: Record): Record { + const record = row.record; + return { + ...row, + value: typeof record === "string" ? JSON.parse(record) : record, + record: undefined, + }; +} + +function buildProviderLexicons(config: ContrailConfig): object[] { + const namespace = config.namespace; + const recordOutput = { + type: "object", + required: ["records", "references"], + properties: { + records: { type: "array", items: { type: "unknown" } }, + cursor: { type: "string" }, + references: { type: "unknown" }, + }, + }; + return [ + { + lexicon: 1, + id: `${namespace}.${AUTHORIZE_SPACE_METHOD}`, + defs: { main: { + type: "procedure", + input: { encoding: "application/json", schema: { + type: "object", + required: ["space", "delegation"], + properties: { + space: { type: "string", format: "uri" }, + delegation: { type: "string" }, + rediscover: { type: "boolean" }, + }, + } }, + output: { encoding: "application/json", schema: { type: "object", required: ["space", "generation", "accessExpiresAt"], properties: { + space: { type: "string", format: "uri" }, + generation: { type: "integer", minimum: 0 }, + accessExpiresAt: { type: "string", format: "datetime" }, + } } }, + } }, + }, + { + lexicon: 1, + id: `${namespace}.${SYNC_SPACE_METHOD}`, + defs: { main: { + type: "procedure", + input: { encoding: "application/json", schema: { type: "object", required: ["space"], properties: { + space: { type: "string", format: "uri" }, + repo: { type: "string", format: "did" }, + } } }, + output: { encoding: "application/json", schema: { type: "object", required: ["queued"], properties: { + queued: { type: "boolean" }, + } } }, + } }, + }, + ...Object.entries(config.collections).flatMap(([short, collection]) => { + const parameters: Record = { + space: { type: "string", format: "uri" }, + did: { type: "string", format: "did" }, + limit: { type: "integer", minimum: 1, maximum: 200 }, + cursor: { type: "string" }, + search: { type: "string" }, + }; + for (const field of Object.keys(collection.queryable ?? {})) { + parameters[field] = { type: "string" }; + parameters[`${field}Min`] = { type: "string" }; + parameters[`${field}Max`] = { type: "string" }; + } + return [ + { + lexicon: 1, + id: collectionMethod(namespace, short, LIST_SPACE_RECORDS_METHOD), + defs: { main: { + type: "query", + parameters: { type: "params", required: ["space"], properties: parameters }, + output: { encoding: "application/json", schema: recordOutput }, + } }, + }, + { + lexicon: 1, + id: collectionMethod(namespace, short, GET_SPACE_RECORD_METHOD), + defs: { main: { + type: "query", + parameters: { type: "params", required: ["space", "uri"], properties: { + space: { type: "string", format: "uri" }, + uri: { type: "string", format: "uri" }, + } }, + output: { encoding: "application/json", schema: { type: "object", required: ["record", "references"], properties: { + record: { type: "unknown" }, + references: { type: "unknown" }, + } } }, + } }, + }, + ]; + }), + ]; +} + +function databaseFrom( + env: Env, + binding: string, +): Database { + const db = env[binding] as Database | undefined; + if (!db || typeof db.prepare !== "function" || typeof db.batch !== "function") { + throw new Error(`Missing database binding ${binding}`); + } + return db; +} + +function stringBinding(env: Env, binding: string): string { + const value = env[binding]; + if (typeof value !== "string" || !value) throw new Error(`Missing secret binding ${binding}`); + return value; +} + +export function createSpacesWorker( + options: SpacesWorkerOptions, +): ExportedHandler { + const projection = resolveConfig(options.projection); + validateConfig(projection); + if (options.accessLeaseMs !== undefined && + (!Number.isSafeInteger(options.accessLeaseMs) || options.accessLeaseMs < 1)) { + throw new TypeError("accessLeaseMs must be a positive integer"); + } + if (options.reconcileIntervalMs !== undefined && + (!Number.isSafeInteger(options.reconcileIntervalMs) || + options.reconcileIntervalMs < 1)) { + throw new TypeError("reconcileIntervalMs must be a positive integer"); + } + if (options.maxAtomicMutations !== undefined && + (!Number.isSafeInteger(options.maxAtomicMutations) || + options.maxAtomicMutations < 1 || options.maxAtomicMutations > 50)) { + throw new TypeError("maxAtomicMutations must be an integer from 1 through 50"); + } + if (options.maxRepoCarBytes !== undefined && + (!Number.isSafeInteger(options.maxRepoCarBytes) || options.maxRepoCarBytes < 1)) { + throw new TypeError("maxRepoCarBytes must be a positive integer"); + } + if (!options.lexicons?.length) { + throw new TypeError("Spaces providers require a pinned runtime Lexicon bundle"); + } + bindRecordValidationLexicons(projection, options.lexicons); + prepareRecordValidation(projection); + for (const [spaceType, type] of Object.entries(options.spaceTypes)) { + if (type.requireManagingApp !== false && !options.authorization) { + throw new TypeError( + `Space type ${spaceType} requires an authoritative managing-app authorizer`, + ); + } + for (const nsid of type.collections) { + const collection = Object.values(projection.collections).find( + (candidate) => candidate.collection === nsid, + ); + if (!collection) { + throw new TypeError(`Space type ${spaceType} allows unknown collection ${nsid}`); + } + if (collection.validate !== true) { + throw new TypeError( + `Space collection ${nsid} must enable Lexicon and CID validation`, + ); + } + } + } + const namespace = projection.namespace; + const dbBinding = options.bindings?.database ?? "DB"; + const encryptionBinding = + options.bindings?.credentialEncryptionKey ?? "SPACES_CREDENTIAL_ENCRYPTION_KEY"; + const queueBinding = options.bindings?.queue ?? "SPACES_QUEUE"; + const providerLexicons = buildProviderLexicons(projection); + const exactMethods = [ + `${namespace}.${AUTHORIZE_SPACE_METHOD}`, + `${namespace}.${SYNC_SPACE_METHOD}`, + "com.atproto.space.notifyWrite", + "com.atproto.space.notifySpaceDeleted", + "com.atproto.simplespace.checkUserAccess", + ...Object.keys(projection.collections).flatMap((short) => [ + collectionMethod(namespace, short, LIST_SPACE_RECORDS_METHOD), + collectionMethod(namespace, short, GET_SPACE_RECORD_METHOD), + ]), + ]; + const auth = createExactServiceAuthGate({ + audience: options.service.audience as never, + methods: exactMethods as never, + maxTokenAgeSeconds: 300, + resolver: options.service.resolver, + }); + const initialized = new WeakMap>(); + + const runtime = (env: Env) => { + const db = databaseFrom(env, dbBinding); + let init = initialized.get(db as object); + if (!init) { + init = initSpacesStorage(db, projection); + initialized.set(db as object, init); + } + const engine = new SpacesSyncEngine(db, { + projection, + spaceTypes: options.spaceTypes, + serviceAudience: options.service.audience, + credentialEncryptionKey: stringBinding(env, encryptionBinding), + reconcileIntervalMs: options.reconcileIntervalMs, + maxAtomicMutations: options.maxAtomicMutations, + maxRepoCarBytes: options.maxRepoCarBytes, + protocol: options.protocol, + onInvalidate: options.onInvalidate + ? (space) => options.onInvalidate!(space, env) + : undefined, + }); + return { db, init, engine }; + }; + + const authorizeRequest = async (request: Request, method: string) => { + const result = await auth.authorize(request, method as never); + if (result.response) return { response: result.response }; + return { did: principalDid(result.principal) }; + }; + + const enqueue = async ( + env: Env, + ctx: ExecutionContext, + message: SyncMessage, + fallback: () => Promise, + ) => { + const queue = env[queueBinding] as { send?: (body: SyncMessage) => Promise } | undefined; + if (queue?.send) await queue.send(message); + else ctx.waitUntil(fallback()); + }; + + const accessAllowed = async ( + env: Env, + db: Database, + watch: SpaceWatch, + input: SpaceAuthorizationInput, + ): Promise => { + if (options.authorization) { + return options.authorization.authorize(input, { env, db }); + } + return hasAccessLease(db, { + userDid: input.userDid, + spaceUri: input.spaceUri, + generation: watch.generation, + }); + }; + + const handle = async ( + request: Request, + env: Env, + ctx: ExecutionContext, + ): Promise => { + const url = new URL(request.url); + if (url.href.length > 16 * 1024) return json({ error: "RequestUriTooLong" }, 414); + if (url.pathname === "/.well-known/did.json" && request.method === "GET") { + return json({ + "@context": ["https://www.w3.org/ns/did/v1"], + id: auth.serviceDid, + service: [{ + id: options.service.audience, + type: "AtprotoSpaceService", + serviceEndpoint: options.service.endpoint, + }], + }); + } + if (url.pathname === "/lexicons" && request.method === "GET") { + return json(providerLexicons, 200, { + "cache-control": "public, max-age=300", + }); + } + if ( + url.pathname === "/.well-known/contrail-spaces-alpha" && + request.method === "GET" + ) { + return json({ + version: 1, + status: "experimental", + serviceDid: auth.serviceDid, + audience: auth.audience, + namespace, + methods: exactMethods, + lexicons: new URL("/lexicons", options.service.endpoint).href, + spacesAlpha: "0.0.0-spaces-alpha-20260818163953", + }); + } + + const method = xrpcMethod(url.pathname); + if (!method) return json({ error: "NotFound" }, 404); + const { db, init, engine } = runtime(env); + await init; + + if (method === `${namespace}.${AUTHORIZE_SPACE_METHOD}` && request.method === "POST") { + const verified = await authorizeRequest(request, method); + if (verified.response) return verified.response; + const body = await boundedJsonBody<{ + space?: unknown; + delegation?: unknown; + rediscover?: unknown; + }>(request); + const parsed = parseSpaceUri(body.space); + if (!options.spaceTypes[parsed.type]) { + return privateJson({ error: "UnsupportedSpace" }, 400); + } + if (typeof body.delegation !== "string" || !body.delegation) { + return privateJson({ error: "InvalidRequest", message: "delegation is required" }, 400); + } + const claims = decodeJwtClaims(body.delegation); + const delegationDid = typeof claims.iss === "string" + ? baseDid(claims.iss) + : typeof claims.sub === "string" + ? baseDid(claims.sub) + : null; + if (delegationDid !== verified.did) { + return privateJson({ error: "DelegationIssuerMismatch" }, 403); + } + if (typeof claims.sub === "string" && claims.sub !== parsed.uri) { + return privateJson({ error: "DelegationSpaceMismatch" }, 403); + } + if (typeof claims.exp === "number" && claims.exp * 1000 <= Date.now()) { + return privateJson({ error: "DelegationExpired" }, 401); + } + const existingWatch = await getSpaceWatch(db, parsed.uri); + if (existingWatch?.status === "hidden" && body.rediscover !== true) { + return privateJson({ error: "RediscoveryRequired" }, 409); + } + if (body.rediscover === true && existingWatch?.status !== "hidden") { + return privateJson({ error: "RediscoveryNotAllowed" }, 409); + } + const authorityPds = await engine.identities.resolvePds(parsed.authorityDid); + const transport = await exchangeSpaceCredential({ + authorityPds, + delegationToken: body.delegation, + spaceUri: parsed.uri, + fetch: options.protocol?.fetch, + }); + const provisionalWatch: SpaceWatch = existingWatch ?? { + spaceUri: parsed.uri, + authorityDid: parsed.authorityDid, + spaceType: parsed.type, + generation: 1, + status: "active", + registrationExpiresAt: null, + nextReconcileAt: Date.now(), + lastReconciledAt: null, + lastError: null, + }; + await engine.validateWatch(provisionalWatch, transport); + const watch = body.rediscover === true + ? await rediscoverSpace(db, parsed.uri) + : await ensureSpaceWatch(db, { spaceUri: parsed.uri }); + await saveCredential(db, { + spaceUri: parsed.uri, + generation: watch.generation, + viewerDid: verified.did!, + credential: transport.serialize(), + encryptionKey: stringBinding(env, encryptionBinding), + }); + const leaseExpiry = Math.min( + transport.expiresAt, + Date.now() + (options.accessLeaseMs ?? 15 * 60_000), + ); + await saveAccessLease(db, { + userDid: verified.did!, + spaceUri: parsed.uri, + generation: watch.generation, + expiresAt: leaseExpiry, + }); + await updateWatch(db, parsed.uri, { + status: "active", + error: null, + nextReconcileAt: Date.now(), + expectedGeneration: watch.generation, + }); + await enqueue(env, ctx, { kind: "reconcile", space: parsed.uri }, () => + engine.reconcileSpace(parsed.uri)); + return privateJson({ + space: parsed.uri, + generation: watch.generation, + accessExpiresAt: new Date(leaseExpiry).toISOString(), + }); + } + + if (method === `${namespace}.${SYNC_SPACE_METHOD}` && request.method === "POST") { + const verified = await authorizeRequest(request, method); + if (verified.response) return verified.response; + const body = await boundedJsonBody<{ space?: unknown; repo?: unknown }>(request); + const parsed = parseSpaceUri(body.space); + const watch = await getSpaceWatch(db, parsed.uri); + if (!watch || watch.status === "hidden") return privateJson({ error: "SpaceNotFound" }, 404); + if (!await accessAllowed(env, db, watch, { + userDid: verified.did!, spaceUri: parsed.uri, action: "read", method, + })) return privateJson({ error: "Forbidden" }, 403); + if (body.repo !== undefined && !validDid(body.repo)) { + return privateJson({ error: "InvalidRequest", message: "invalid repo DID" }, 400); + } + // Consumer-triggered sync always starts from the authority's complete + // writer list. The optional repo hint must never admit an unadvertised or + // previously removed writer; trusted authority notifications retain the + // targeted fast path below. + const preferredRepo = validDid(body.repo) ? body.repo : undefined; + await enqueue( + env, + ctx, + { kind: "reconcile", space: parsed.uri, preferredRepo }, + () => engine.reconcileSpace(parsed.uri, { preferredRepo }), + ); + return privateJson({ queued: true }, 202); + } + + if (method === "com.atproto.space.notifyWrite" && request.method === "POST") { + const verified = await authorizeRequest(request, method); + if (verified.response) return verified.response; + const body = await boundedJsonBody<{ + space?: unknown; + repo?: unknown; + rev?: unknown; + hash?: unknown; + }>(request, 16 * 1024); + const parsed = parseSpaceUri(body.space); + const hash = decodeBytes(body.hash); + if (!validDid(body.repo) || typeof body.rev !== "string" || + body.rev.length > 128 || !hash || hash.byteLength !== 32) { + return json({ error: "InvalidRequest" }, 400); + } + if (verified.did !== parsed.authorityDid) { + return json({ error: "InvalidIssuer" }, 403); + } + const repoDid = body.repo; + const revision = body.rev; + const watch = await getSpaceWatch(db, parsed.uri); + if (watch?.status === "active") { + await enqueue(env, ctx, { + kind: "repo", + space: parsed.uri, + repo: repoDid, + rev: revision, + hash: encodeBytes(hash), + }, () => engine.syncRepo(parsed.uri, repoDid, { + rev: revision, + hash, + })); + } + return json({ queued: Boolean(watch) }, 202); + } + + if ( + method === "com.atproto.space.notifySpaceDeleted" && + request.method === "POST" + ) { + const verified = await authorizeRequest(request, method); + if (verified.response) return verified.response; + const body = await boundedJsonBody<{ space?: unknown }>(request, 8 * 1024); + const parsed = parseSpaceUri(body.space); + if (verified.did !== parsed.authorityDid) return json({ error: "InvalidIssuer" }, 403); + const watch = await getSpaceWatch(db, parsed.uri); + if (watch) { + await hideDeletedSpace(db, watch); + ctx.waitUntil((async () => { + await purgeSpaceGeneration(db, projection, watch); + await options.onInvalidate?.(parsed.uri, env); + })()); + } + return json({}, 200); + } + + if ( + method === "com.atproto.simplespace.checkUserAccess" && + request.method === "GET" + ) { + const verified = await authorizeRequest(request, method); + if (verified.response) return verified.response; + const parsed = parseSpaceUri(url.searchParams.get("space")); + const userDid = url.searchParams.get("user"); + if (!userDid) return json({ error: "InvalidRequest" }, 400); + if (verified.did !== parsed.authorityDid) return json({ error: "InvalidIssuer" }, 403); + const watch = await getSpaceWatch(db, parsed.uri); + let authorized = false; + if (options.authorization) { + // Credential exchange may invoke policy before the first watch, or for + // a verified recreation while the old generation remains hidden. + authorized = await options.authorization.authorize({ + userDid, + spaceUri: parsed.uri, + action: "read", + method, + }, { env, db }); + } else if (watch && watch.status !== "hidden") { + authorized = await accessAllowed(env, db, watch, { + userDid, + spaceUri: parsed.uri, + action: "read", + method, + }); + } + return json({ authorized }); + } + + for (const [short, collection] of Object.entries(projection.collections)) { + const listMethod = collectionMethod(namespace, short, LIST_SPACE_RECORDS_METHOD); + const getMethod = collectionMethod(namespace, short, GET_SPACE_RECORD_METHOD); + if (method !== listMethod && method !== getMethod) continue; + if (request.method !== "GET") return json({ error: "MethodNotAllowed" }, 405); + const verified = await authorizeRequest(request, method); + if (verified.response) return verified.response; + const parsed = parseSpaceUri(url.searchParams.get("space")); + const watch = await getSpaceWatch(db, parsed.uri); + if (!watch || watch.status !== "active") { + return privateJson({ error: "SpaceUnavailable" }, 404); + } + if (!await accessAllowed(env, db, watch, { + userDid: verified.did!, + spaceUri: parsed.uri, + action: "read", + method, + })) return privateJson({ error: "Forbidden" }, 403); + + const filters: Record = {}; + const ranges: Record = {}; + for (const field of Object.keys(collection.queryable ?? {})) { + const exact = url.searchParams.get(field); + if (exact !== null) filters[field] = exact; + const minimum = url.searchParams.get(`${field}Min`); + const maximum = url.searchParams.get(`${field}Max`); + if (minimum !== null || maximum !== null) { + ranges[field] = { + ...(minimum === null ? {} : { min: minimum }), + ...(maximum === null ? {} : { max: maximum }), + }; + } + } + const requestedUri = url.searchParams.get("uri"); + if (method === getMethod && !requestedUri) { + return privateJson({ error: "InvalidRequest", message: "uri is required" }, 400); + } + const requestedLimit = Number(url.searchParams.get("limit") ?? 50); + if (method === listMethod && + (!Number.isSafeInteger(requestedLimit) || requestedLimit < 1)) { + return privateJson({ error: "InvalidRequest", message: "invalid limit" }, 400); + } + const result = await queryIsolatedRecords(db, projection, { + scope: { + kind: "isolated", + key: spaceProjectionKey(parsed.uri, watch.generation), + }, + collection: short, + ...(url.searchParams.get("did") ? { did: url.searchParams.get("did")! } : {}), + ...(method === getMethod + ? { uri: requestedUri!, limit: 1 } + : { + limit: requestedLimit, + cursor: url.searchParams.get("cursor") ?? undefined, + search: url.searchParams.get("search") ?? undefined, + }), + filters, + rangeFilters: ranges, + }); + if (method === getMethod) { + const record = result.records[0]; + if (!record) return privateJson({ error: "RecordNotFound" }, 404); + return privateJson({ + record: parsedRecord(record as unknown as Record), + references: Object.fromEntries( + Object.entries(result.references ?? {}).map(([uri, row]) => [ + uri, + parsedRecord(row as unknown as Record), + ]), + ), + }); + } + return privateJson({ + records: result.records.map((row) => + parsedRecord(row as unknown as Record)), + ...(result.cursor ? { cursor: result.cursor } : {}), + references: Object.fromEntries( + Object.entries(result.references ?? {}).map(([uri, row]) => [ + uri, + parsedRecord(row as unknown as Record), + ]), + ), + }); + } + + return json({ error: "MethodNotFound" }, 404); + }; + + return { + fetch(request, env, ctx) { + return handle(request, env, ctx).catch(errorResponse); + }, + async scheduled(_controller, env, ctx) { + const { init, engine } = runtime(env); + await init; + ctx.waitUntil(engine.reconcileDue({ deadline: Date.now() + 25_000, limit: 5 })); + }, + async queue(batch, env) { + const { init, engine } = runtime(env); + await init; + for (const message of batch.messages) { + try { + const body = message.body; + if (body.kind === "reconcile") { + await engine.reconcileSpace(body.space, { preferredRepo: body.preferredRepo }); + } else { + await engine.syncRepo(body.space, body.repo, { + rev: body.rev, + hash: body.hash ? decodeBytes(body.hash) : undefined, + }); + } + message.ack(); + } catch (error) { + console.error("[spaces] queue job failed", error); + message.retry(); + } + } + }, + }; +} diff --git a/packages/contrail-spaces-alpha/tests/consumer.test.ts b/packages/contrail-spaces-alpha/tests/consumer.test.ts new file mode 100644 index 0000000..9a45a85 --- /dev/null +++ b/packages/contrail-spaces-alpha/tests/consumer.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { + formatSpacePermissionScope, + spacesConsumerOAuthScopes, +} from "../src/consumer"; + +describe("consumer scopes", () => { + it("requests Space writes and only exact provider methods", () => { + const space = formatSpacePermissionScope({ + type: "garden.atmo.circle", + skey: "self", + collections: ["garden.atmo.circle.note"], + }); + expect(space).toContain("space:garden.atmo.circle?"); + expect(space).toContain("collection=garden.atmo.circle.note"); + const scopes = spacesConsumerOAuthScopes({ + audience: "did:web:spaces.atmo.garden#spaces", + namespace: "garden.atmo.circle", + collections: ["garden.atmo.circle.note"], + spaceType: "garden.atmo.circle", + skey: "self", + }); + expect(scopes[2]).toContain("aud=did:web:spaces.atmo.garden%23spaces"); + expect(scopes[2]).toContain("garden.atmo.circle.note.listSpaceRecords"); + expect(scopes[2]).not.toContain("notifyWrite"); + }); +}); diff --git a/packages/contrail-spaces-alpha/tests/crypto.test.ts b/packages/contrail-spaces-alpha/tests/crypto.test.ts new file mode 100644 index 0000000..74f1549 --- /dev/null +++ b/packages/contrail-spaces-alpha/tests/crypto.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { + decryptJson, + encryptJson, + generateCredentialEncryptionKey, +} from "../src/crypto"; + +describe("credential encryption", () => { + it("round trips with bound context and rejects another Space", async () => { + const key = await generateCredentialEncryptionKey(); + const encrypted = await encryptJson({ token: "secret" }, key, "space:a"); + await expect(decryptJson(encrypted, key, "space:a")).resolves.toEqual({ token: "secret" }); + await expect(decryptJson(encrypted, key, "space:b")).rejects.toThrow(); + expect(JSON.stringify(encrypted)).not.toContain("secret"); + }); +}); diff --git a/packages/contrail-spaces-alpha/tests/protocol.test.ts b/packages/contrail-spaces-alpha/tests/protocol.test.ts new file mode 100644 index 0000000..8dac3a8 --- /dev/null +++ b/packages/contrail-spaces-alpha/tests/protocol.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + getRepoCar, + type SpaceCredentialTransport, +} from "../src/protocol"; + +function transportFor(response: Response): SpaceCredentialTransport { + return { fetch: async () => response } as unknown as SpaceCredentialTransport; +} + +describe("bounded Space CAR download", () => { + it("assembles a streamed CAR within the configured limit", async () => { + const response = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.of(1, 2, 3)); + controller.enqueue(Uint8Array.of(4, 5)); + controller.close(); + }, + })); + const bytes = await getRepoCar(transportFor(response), "https://pds.example", { + space: "at://did:plc:alice/space/garden.atmo.circle/self", + repo: "did:plc:alice", + maxBytes: 5, + }); + expect([...bytes]).toEqual([1, 2, 3, 4, 5]); + }); + + it("cancels a chunked response as soon as it exceeds the limit", async () => { + let cancelled = false; + const response = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.of(1, 2, 3, 4)); + controller.enqueue(Uint8Array.of(5, 6, 7, 8)); + }, + cancel() { + cancelled = true; + }, + })); + await expect(getRepoCar(transportFor(response), "https://pds.example", { + space: "at://did:plc:alice/space/garden.atmo.circle/self", + repo: "did:plc:alice", + maxBytes: 6, + })).rejects.toMatchObject({ + status: 413, + code: "RepoTooLarge", + }); + expect(cancelled).toBe(true); + }); +}); diff --git a/packages/contrail-spaces-alpha/tests/storage.test.ts b/packages/contrail-spaces-alpha/tests/storage.test.ts new file mode 100644 index 0000000..2c8035f --- /dev/null +++ b/packages/contrail-spaces-alpha/tests/storage.test.ts @@ -0,0 +1,163 @@ +import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; +import { resolveConfig } from "@atmo-dev/contrail"; +import { describe, expect, it } from "vitest"; +import { generateCredentialEncryptionKey } from "../src/crypto"; +import { + acquireSyncLease, + ensureSpaceWatch, + getSpaceWatch, + hasAccessLease, + hideDeletedSpace, + initSpacesStorage, + loadCredential, + rediscoverSpace, + renewSyncLease, + saveAccessLease, + saveCredential, +} from "../src/storage"; + +const projection = resolveConfig({ + namespace: "garden.atmo.circle", + profiles: [], + collections: { + note: { collection: "garden.atmo.circle.note" }, + }, +}); + +const space = "at://did:plc:alice/space/garden.atmo.circle/self"; + +describe("Spaces storage boundaries", () => { + it("binds encrypted credentials and access leases to the Space generation", async () => { + const db = createSqliteDatabase(":memory:"); + await initSpacesStorage(db, projection); + const watch = await ensureSpaceWatch(db, { spaceUri: space }); + const key = await generateCredentialEncryptionKey(); + await saveCredential(db, { + spaceUri: space, + generation: watch.generation, + viewerDid: "did:plc:alice", + encryptionKey: key, + credential: { + token: "private-token", + privateJwk: { kty: "EC", d: "private" }, + expiresAt: Date.now() + 60_000, + }, + }); + await saveAccessLease(db, { + userDid: "did:plc:alice", + spaceUri: space, + generation: watch.generation, + expiresAt: Date.now() + 60_000, + }); + expect((await loadCredential(db, { + spaceUri: space, + generation: watch.generation, + encryptionKey: key, + }))?.credential.token).toBe("private-token"); + expect(await hasAccessLease(db, { + userDid: "did:plc:alice", + spaceUri: space, + generation: watch.generation, + })).toBe(true); + expect(await hasAccessLease(db, { + userDid: "did:plc:alice", + spaceUri: space, + generation: watch.generation + 1, + })).toBe(false); + + await hideDeletedSpace(db, watch); + expect((await getSpaceWatch(db, space))?.status).toBe("hidden"); + expect(await loadCredential(db, { + spaceUri: space, + generation: watch.generation, + encryptionKey: key, + })).toBeNull(); + expect(await hasAccessLease(db, { + userDid: "did:plc:alice", + spaceUri: space, + generation: watch.generation, + })).toBe(false); + }); + + it("allows only one unexpired sync-lease owner and fences stale owners", async () => { + const db = createSqliteDatabase(":memory:"); + await initSpacesStorage(db, projection); + const lease = { + spaceUri: space, + generation: 1, + repoDid: "did:plc:bob", + ttlMs: 60_000, + }; + expect(await acquireSyncLease(db, { ...lease, owner: "one" })).toBe(true); + expect(await acquireSyncLease(db, { ...lease, owner: "two" })).toBe(false); + expect(await renewSyncLease(db, { ...lease, owner: "one" })).toBe(true); + + await db.prepare( + `UPDATE spaces_sync_leases SET expires_at = 0 + WHERE space_uri = ? AND space_generation = ? AND repo_did = ?`, + ).bind(space, 1, "did:plc:bob").run(); + expect(await renewSyncLease(db, { ...lease, owner: "one" })).toBe(false); + expect(await acquireSyncLease(db, { ...lease, owner: "two" })).toBe(true); + expect(await renewSyncLease(db, { ...lease, owner: "one" })).toBe(false); + }); + + it("rediscovery is hidden-only and stale deletion cannot clear the new generation", async () => { + const db = createSqliteDatabase(":memory:"); + await initSpacesStorage(db, projection); + const oldWatch = await ensureSpaceWatch(db, { spaceUri: space }); + await expect(rediscoverSpace(db, space)).rejects.toThrow(/previously deleted/); + + await hideDeletedSpace(db, oldWatch); + const watch = await rediscoverSpace(db, space); + expect(watch.generation).toBe(oldWatch.generation + 1); + const key = await generateCredentialEncryptionKey(); + await saveCredential(db, { + spaceUri: space, + generation: watch.generation, + viewerDid: "did:plc:alice", + encryptionKey: key, + credential: { + token: "new-generation-token", + privateJwk: { kty: "EC", d: "private" }, + expiresAt: Date.now() + 60_000, + }, + }); + await saveAccessLease(db, { + userDid: "did:plc:alice", + spaceUri: space, + generation: watch.generation, + expiresAt: Date.now() + 60_000, + }); + expect(await acquireSyncLease(db, { + spaceUri: space, + generation: watch.generation, + repoDid: "did:plc:bob", + owner: "new-generation", + ttlMs: 60_000, + })).toBe(true); + + // Simulate delayed deletion work retaining the pre-rediscovery watch. + await hideDeletedSpace(db, oldWatch); + expect(await getSpaceWatch(db, space)).toMatchObject({ + status: "active", + generation: watch.generation, + }); + expect((await loadCredential(db, { + spaceUri: space, + generation: watch.generation, + encryptionKey: key, + }))?.credential.token).toBe("new-generation-token"); + expect(await hasAccessLease(db, { + userDid: "did:plc:alice", + spaceUri: space, + generation: watch.generation, + })).toBe(true); + expect(await acquireSyncLease(db, { + spaceUri: space, + generation: watch.generation, + repoDid: "did:plc:bob", + owner: "stale-contender", + ttlMs: 60_000, + })).toBe(false); + }); +}); diff --git a/packages/contrail-spaces-alpha/tests/uri.test.ts b/packages/contrail-spaces-alpha/tests/uri.test.ts new file mode 100644 index 0000000..ef7b00d --- /dev/null +++ b/packages/contrail-spaces-alpha/tests/uri.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + formatSpaceRecordUri, + formatSpaceUri, + parseSpaceRecordUri, + parseSpaceUri, + spaceProjectionKey, +} from "../src/uri"; + +describe("Space URIs", () => { + const space = "at://did:plc:alice/space/garden.atmo.circle/self"; + + it("round trips an exact Space and record URI", () => { + expect(formatSpaceUri({ + authorityDid: "did:plc:alice", + type: "garden.atmo.circle", + skey: "self", + })).toBe(space); + expect(parseSpaceUri(space)).toMatchObject({ + authorityDid: "did:plc:alice", + type: "garden.atmo.circle", + skey: "self", + }); + const record = formatSpaceRecordUri({ + spaceUri: space, + writerDid: "did:plc:bob", + collection: "garden.atmo.circle.note", + rkey: "3mtest", + }); + expect(parseSpaceRecordUri(record)).toMatchObject({ + spaceUri: space, + writerDid: "did:plc:bob", + collection: "garden.atmo.circle.note", + rkey: "3mtest", + }); + }); + + it("keeps recreated Space generations distinct", () => { + expect(spaceProjectionKey(space, 1)).not.toBe(spaceProjectionKey(space, 2)); + }); + + it("rejects ordinary and overlong record paths", () => { + expect(() => parseSpaceUri("at://did:plc:alice/garden.atmo.circle/self")).toThrow(); + expect(() => parseSpaceRecordUri(`${space}/did:plc:bob/garden.atmo.circle.note/a/extra`)).toThrow(); + }); +}); diff --git a/packages/contrail-spaces-alpha/tests/worker-query.test.ts b/packages/contrail-spaces-alpha/tests/worker-query.test.ts new file mode 100644 index 0000000..6dd025c --- /dev/null +++ b/packages/contrail-spaces-alpha/tests/worker-query.test.ts @@ -0,0 +1,171 @@ +import { Secp256k1PrivateKeyExportable } from "@atcute/crypto"; +import { createServiceJwt } from "@atcute/xrpc-server/auth"; +import { + bindRecordValidationLexicons, + createIngestEvent, + createIsolatedProjection, + ingestRecords, + resolveConfig, +} from "@atmo-dev/contrail"; +import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; +import { beforeAll, describe, expect, it } from "vitest"; +import { createSpacesWorker } from "../src/worker"; +import { ensureSpaceWatch, initSpacesStorage } from "../src/storage"; +import { spaceProjectionKey } from "../src/uri"; + +const issuer = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"; +const audience = "did:web:spaces.atmo.garden#spaces"; +const space = `${"at://did:plc:alice"}/space/garden.atmo.circle/self`; +const collection = "garden.atmo.circle.note"; +let keypair: Secp256k1PrivateKeyExportable; + +beforeAll(async () => { + keypair = await Secp256k1PrivateKeyExportable.createKeypair(); +}); + +const projection = resolveConfig({ + namespace: "garden.atmo.circle", + profiles: [], + collections: { + note: { collection, validate: true, searchable: ["text"] }, + }, + validation: { verifyCid: false }, +}); + +const lexicons = [{ + lexicon: 1, + id: collection, + defs: { + main: { + type: "record", + key: "tid", + record: { + type: "object", + required: ["text", "createdAt"], + properties: { + text: { type: "string", maxLength: 2000 }, + createdAt: { type: "string", format: "datetime" }, + }, + }, + }, + }, +}] as const; + +async function token(method: string) { + return createServiceJwt({ + keypair, + issuer: issuer as never, + audience: audience as never, + lxm: method as never, + }); +} + +function context(): ExecutionContext { + return { + waitUntil() {}, + passThroughOnException() {}, + props: {}, + } as unknown as ExecutionContext; +} + +describe("Spaces Worker private query boundary", () => { + it("requires a method token and returns only the named Space", async () => { + const db = createSqliteDatabase(":memory:"); + bindRecordValidationLexicons(projection, lexicons); + await initSpacesStorage(db, projection); + await ensureSpaceWatch(db, { spaceUri: space }); + const event = createIngestEvent({ + uri: `${space}/${issuer}/${collection}/3h4oqw2vvxpwz`, + did: issuer, + collection, + rkey: "3h4oqw2vvxpwz", + operation: "create", + cid: "bafy-test", + value: { + $type: collection, + text: "private hello", + createdAt: "2026-08-21T00:00:00Z", + }, + timeUs: 1_000_000, + source: { id: "space-test", revision: "1", time_us: 1_000_000 }, + }); + await ingestRecords(db, [event], projection, { + projection: createIsolatedProjection({ + scope: { kind: "isolated", key: spaceProjectionKey(space, 1) }, + partition: issuer, + generation: 1, + activate: true, + }), + skipDiagnostics: true, + }); + + const worker = createSpacesWorker({ + projection, + lexicons, + service: { + endpoint: "https://spaces.atmo.garden", + audience, + resolver: { + async resolve(did) { + return { + "@context": [], + id: did, + verificationMethod: [{ + id: `${did}#atproto`, + type: "Multikey", + controller: did, + publicKeyMultibase: await keypair.exportPublicKey("multikey"), + }], + }; + }, + }, + }, + spaceTypes: { + "garden.atmo.circle": { collections: [collection], skey: "self" }, + }, + authorization: { authorize: () => true }, + }); + const method = "garden.atmo.circle.note.listSpaceRecords"; + const url = `https://spaces.atmo.garden/xrpc/${method}?space=${encodeURIComponent(space)}`; + const queued: unknown[] = []; + const env = { + DB: db, + SPACES_CREDENTIAL_ENCRYPTION_KEY: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + SPACES_QUEUE: { send: async (message: unknown) => { queued.push(message); } }, + } as never; + + const anonymous = await worker.fetch!(new Request(url) as never, env, context()); + expect(anonymous.status).toBe(401); + + const request = new Request(url, { + headers: { authorization: `Bearer ${await token(method)}` }, + }); + const response = await worker.fetch!(request as never, env, context()); + expect(response.status).toBe(200); + const body = await response.json() as { records: Array<{ value: { text: string } }> }; + expect(body.records.map((record) => record.value.text)).toEqual(["private hello"]); + + const wrongMethod = new Request(url, { + headers: { + authorization: `Bearer ${await token("garden.atmo.circle.syncSpace")}`, + }, + }); + expect((await worker.fetch!(wrongMethod as never, env, context())).status).toBe(401); + + const syncMethod = "garden.atmo.circle.syncSpace"; + const targetedSync = new Request(`https://spaces.atmo.garden/xrpc/${syncMethod}`, { + method: "POST", + headers: { + authorization: `Bearer ${await token(syncMethod)}`, + "content-type": "application/json", + }, + body: JSON.stringify({ space, repo: "did:plc:unadvertised" }), + }); + expect((await worker.fetch!(targetedSync as never, env, context())).status).toBe(202); + expect(queued).toEqual([{ + kind: "reconcile", + space, + preferredRepo: "did:plc:unadvertised", + }]); + }); +}); diff --git a/packages/contrail-spaces-alpha/tsconfig.build.json b/packages/contrail-spaces-alpha/tsconfig.build.json new file mode 100644 index 0000000..b90fc83 --- /dev/null +++ b/packages/contrail-spaces-alpha/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"] +} diff --git a/packages/contrail-spaces-alpha/tsconfig.json b/packages/contrail-spaces-alpha/tsconfig.json new file mode 100644 index 0000000..f225e7f --- /dev/null +++ b/packages/contrail-spaces-alpha/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types", "node"] + }, + "include": ["src", "tests"] +} diff --git a/packages/contrail-spaces-alpha/tsup.config.ts b/packages/contrail-spaces-alpha/tsup.config.ts new file mode 100644 index 0000000..0bafc98 --- /dev/null +++ b/packages/contrail-spaces-alpha/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts", "src/worker.ts", "src/consumer.ts"], + format: ["esm"], + dts: true, + sourcemap: true, + splitting: false, + clean: true, + tsconfig: "tsconfig.build.json", + external: ["node:sqlite", "pg", "wrangler"], + removeNodeProtocol: false, +}); diff --git a/packages/contrail-spaces-alpha/vitest.config.ts b/packages/contrail-spaces-alpha/vitest.config.ts new file mode 100644 index 0000000..1937164 --- /dev/null +++ b/packages/contrail-spaces-alpha/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ test: { include: ["tests/**/*.test.ts"] } }); diff --git a/packages/contrail/src/core/ingest.ts b/packages/contrail/src/core/ingest.ts index 48527cf..c56ce97 100644 --- a/packages/contrail/src/core/ingest.ts +++ b/packages/contrail/src/core/ingest.ts @@ -99,6 +99,21 @@ export function recordTimeUs( return microseconds > fallbackUs ? fallbackUs : microseconds; } +export interface IngestProjection { + /** Select mutations newer than this projection's durable version state. */ + selectCurrent( + db: Database, + events: IngestEvent[], + ): Promise<{ applied: IngestEvent[]; superseded: number }>; + /** Commit admitted mutations and any trailing checkpoint statements atomically. */ + project( + db: Database, + events: IngestEvent[], + config: ContrailConfig, + options: IngestRecordsOptions & { sourceOrderingChecked: true }, + ): Promise<{ applied: IngestEvent[]; superseded: number }>; +} + export interface IngestRecordsOptions { skipReplayDetection?: boolean; skipFeedFanout?: boolean; @@ -111,6 +126,12 @@ export interface IngestRecordsOptions { knownDids?: ReadonlySet; /** Statements committed after projection in the same database batch. */ trailingStatements?: Statement[]; + /** Optional isolated projection implementation. Admission and validation stay + * in this canonical path while storage/query namespaces remain extension-owned. */ + projection?: IngestProjection; + /** Do not add this batch to the public ingest diagnostic counters. Private + * extensions should maintain operator-only diagnostics of their own. */ + skipDiagnostics?: boolean; /** The source response is a current authoritative snapshot, so it supersedes * durable observations without a redundant version lookup. */ authoritativeSourceObservation?: boolean; @@ -281,7 +302,9 @@ export async function ingestRecords( // actors in this batch. The winning versions are persisted with projection. const ordered = options.authoritativeSourceObservation ? selectAuthoritativeMutations(accepted) - : await selectCurrentMutations(db, accepted); + : options.projection + ? await options.projection.selectCurrent(db, accepted) + : await selectCurrentMutations(db, accepted); dropped.superseded += ordered.superseded; const projectionExclusions = ordered.applied.filter((event) => @@ -345,7 +368,7 @@ export async function ingestRecords( unknown_subject: dropped.unknownSubject, superseded: dropped.superseded, }; - const diagnostics = options.aggregateDiagnostics + const diagnostics = options.aggregateDiagnostics || options.skipDiagnostics ? null : ingestDiagnosticsStatement(db, diagnosticCounts); const trailingStatements = [ @@ -353,11 +376,21 @@ export async function ingestRecords( ...(options.trailingStatements ?? []), ]; if (projectionEvents.length > 0) { - await projectEvents(db, projectionEvents, config, { + const projectionOptions = { ...options, trailingStatements, - sourceOrderingChecked: true, - }); + sourceOrderingChecked: true as const, + }; + if (options.projection) { + await options.projection.project( + db, + projectionEvents, + config, + projectionOptions, + ); + } else { + await projectEvents(db, projectionEvents, config, projectionOptions); + } } else if (trailingStatements.length > 0) { await db.batch(trailingStatements); } diff --git a/packages/contrail/src/core/isolated-projection.ts b/packages/contrail/src/core/isolated-projection.ts new file mode 100644 index 0000000..ac19168 --- /dev/null +++ b/packages/contrail/src/core/isolated-projection.ts @@ -0,0 +1,1259 @@ +import type { + ContrailConfig, + Database, + IngestEvent, + RecordRow, + RelationConfig, + ResolvedContrailConfig, + Statement, +} from "./types"; +import { + countColumnName, + getNestedValue, + getRelationField, + groupedCountColumnName, + nsidForShortName, + recordsTableName, + resolveCollectionKey, + shortNameForNsid, +} from "./types"; +import type { IngestProjection, IngestRecordsOptions } from "./ingest"; +import { + compareRecordVersions, + type MutationSelection, + type RecordVersionInfo, + type SortOption, +} from "./db/records"; +import { + buildFtsSchema, + getDialect, +} from "./dialect"; +import { + buildFtsContent, + getSearchableFields, +} from "./search"; + +/** A protocol-neutral projection boundary. Core deliberately does not interpret + * isolated keys; an extension maps its authority/generation identity to one. */ +export type ProjectionScope = + | { kind: "public" } + | { kind: "isolated"; key: string }; + +/** A mutation target inside an isolated scope. Partition generations allow a + * complete source partition to be staged and atomically made visible. */ +export interface IsolatedProjectionTarget { + scope: { kind: "isolated"; key: string }; + partition: string; + generation: number; + /** Switch this partition's visible pointer in the projection transaction. */ + activate?: boolean; +} + +export interface IsolatedQueryOptions { + scope: { kind: "isolated"; key: string }; + collection: string; + did?: string; + uri?: string; + limit?: number; + cursor?: string; + filters?: Record; + rangeFilters?: Record; + countFilters?: Record; + sort?: SortOption; + search?: string; +} + +export interface IsolatedQueryResult { + records: Array }>; + cursor?: string; + /** Exact-Space forward-reference targets keyed by URI. */ + references?: Record; +} + +const MAX_BINDINGS = 100; +const SAFE_KEY = /^[a-zA-Z0-9_.]+$/; + +function safeSuffix(short: string): string { + return short.replace(/[^a-zA-Z0-9]/g, "_"); +} + +export function isolatedRecordsTableName(short: string): string { + return `isolated_${recordsTableName(short)}`; +} + +export function isolatedFtsTableName(short: string): string { + return `isolated_fts_${safeSuffix(short)}`; +} + +export function isolatedFtsRowsTableName(short: string): string { + return `${isolatedFtsTableName(short)}_rows`; +} + +function assertTarget(target: IsolatedProjectionTarget): void { + if (!target.scope.key) throw new TypeError("isolated scope key must not be empty"); + if (!target.partition) throw new TypeError("isolated partition must not be empty"); + if (!Number.isSafeInteger(target.generation) || target.generation < 0) { + throw new TypeError("isolated partition generation must be a non-negative integer"); + } +} + +function countColumns(config: ContrailConfig, short: string): Array<{ + type: string; + column: string; +}> { + const result = new Map(); + const collection = config.collections[short]; + if (!collection) return []; + const mappings = (config as ResolvedContrailConfig)._resolved?.relations[short] ?? {}; + for (const [name, relation] of Object.entries(collection.relations ?? {})) { + if (relation.count === false) continue; + result.set(countColumnName(relation.collection), relation.collection); + for (const [group, token] of Object.entries(mappings[name]?.groups ?? {})) { + result.set(groupedCountColumnName(relation.collection, group), token); + } + } + return [...result].map(([column, type]) => ({ type, column })); +} + +async function isolatedTableColumns( + db: Database, + table: string, +): Promise> { + if (!/^[a-zA-Z0-9_]+$/.test(table)) throw new TypeError(`invalid table name: ${table}`); + const dialect = getDialect(db); + if (dialect.ftsStrategy === "virtual-table") { + const rows = await db.prepare(`PRAGMA table_info(${table})`).all<{ name: string }>(); + return new Set((rows.results ?? []).map((row) => String(row.name))); + } + const rows = await db.prepare( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = ?`, + ).bind(table).all<{ column_name: string }>(); + return new Set((rows.results ?? []).map((row) => String(row.column_name))); +} + +async function ensureIsolatedCountColumns( + db: Database, + table: string, + columns: readonly string[], +): Promise { + if (columns.length === 0) return; + let existing = await isolatedTableColumns(db, table); + for (const column of columns) { + if (!/^[a-zA-Z0-9_]+$/.test(column)) { + throw new TypeError(`invalid isolated count column: ${column}`); + } + if (existing.has(column)) continue; + try { + await db.prepare( + `ALTER TABLE ${table} ADD COLUMN ${column} INTEGER NOT NULL DEFAULT 0`, + ).run(); + } catch (error) { + // Concurrent Worker initialization can race the same additive migration. + // Suppress only when a fresh schema read proves the column now exists. + existing = await isolatedTableColumns(db, table); + if (!existing.has(column)) throw error; + continue; + } + existing.add(column); + } +} + +function countColumnForType( + config: ContrailConfig, + short: string, + type: string, +): string | null { + return countColumns(config, short).find((item) => item.type === type)?.column ?? null; +} + +/** Initialize only the extension-owned isolated namespace. Public-only Contrail + * installs never call this and therefore receive no private tables. */ +export async function initIsolatedProjection( + db: Database, + config: ContrailConfig, +): Promise { + const dialect = getDialect(db); + const statements: string[] = [ + `CREATE TABLE IF NOT EXISTS isolated_projection_partitions ( + scope_key TEXT NOT NULL, + partition_key TEXT NOT NULL, + visible_generation INTEGER NOT NULL, + updated_at ${dialect.bigintType} NOT NULL, + PRIMARY KEY (scope_key, partition_key) + )`, + `CREATE TABLE IF NOT EXISTS isolated_record_versions ( + scope_key TEXT NOT NULL, + partition_key TEXT NOT NULL, + partition_generation INTEGER NOT NULL, + uri TEXT NOT NULL, + did TEXT NOT NULL, + collection TEXT NOT NULL, + rkey TEXT NOT NULL, + operation TEXT NOT NULL, + cid TEXT, + source_id TEXT NOT NULL, + source_epoch TEXT, + source_revision TEXT, + source_time_us ${dialect.bigintType} NOT NULL, + source_cursor TEXT, + indexed_at ${dialect.bigintType} NOT NULL, + PRIMARY KEY (scope_key, partition_key, partition_generation, uri) + )`, + `CREATE INDEX IF NOT EXISTS idx_isolated_versions_uri + ON isolated_record_versions(scope_key, uri)`, + ]; + + const relationIndexes: string[] = []; + const countMigrations: Array<{ table: string; columns: string[] }> = []; + for (const [short, collection] of Object.entries(config.collections)) { + const table = isolatedRecordsTableName(short); + const requiredCountColumns = countColumns(config, short).map(({ column }) => column); + countMigrations.push({ table, columns: requiredCountColumns }); + const extraColumns = requiredCountColumns + .map((column) => `${column} INTEGER NOT NULL DEFAULT 0`) + .join(",\n "); + statements.push( + `CREATE TABLE IF NOT EXISTS ${table} ( + scope_key TEXT NOT NULL, + partition_key TEXT NOT NULL, + partition_generation INTEGER NOT NULL, + uri TEXT NOT NULL, + did TEXT NOT NULL, + rkey TEXT NOT NULL, + cid TEXT, + record ${dialect.recordColumnType} NOT NULL, + time_us ${dialect.bigintType} NOT NULL, + indexed_at ${dialect.bigintType} NOT NULL${extraColumns ? `,\n ${extraColumns}` : ""}, + PRIMARY KEY (scope_key, partition_key, partition_generation, uri) + )`, + `CREATE INDEX IF NOT EXISTS idx_${table}_scope_time + ON ${table}(scope_key, time_us DESC, uri)`, + `CREATE INDEX IF NOT EXISTS idx_${table}_scope_did + ON ${table}(scope_key, did, time_us DESC, uri)`, + ); + + for (const field of Object.keys(collection.queryable ?? {})) { + if (!SAFE_KEY.test(field)) throw new TypeError(`invalid isolated query field: ${field}`); + statements.push( + `CREATE INDEX IF NOT EXISTS idx_${table}_${safeSuffix(field)} + ON ${table}(scope_key, ${dialect.indexExpression(dialect.jsonExtract("record", field))})`, + ); + } + for (const relation of Object.values(collection.relations ?? {})) { + const child = isolatedRecordsTableName(relation.collection); + const field = getRelationField(relation); + relationIndexes.push( + `CREATE INDEX IF NOT EXISTS idx_${child}_${safeSuffix(field)} + ON ${child}(scope_key, ${dialect.indexExpression(dialect.jsonExtract("record", field))})`, + ); + } + + const fields = getSearchableFields(short, collection); + if (fields?.length) { + if (dialect.ftsStrategy === "virtual-table") { + const rows = isolatedFtsRowsTableName(short); + const fts = isolatedFtsTableName(short); + statements.push( + `CREATE TABLE IF NOT EXISTS ${rows} ( + id INTEGER PRIMARY KEY, + scope_key TEXT NOT NULL, + partition_key TEXT NOT NULL, + partition_generation INTEGER NOT NULL, + uri TEXT NOT NULL, + UNIQUE(scope_key, partition_key, partition_generation, uri) + )`, + `CREATE INDEX IF NOT EXISTS idx_${rows}_scope_uri ON ${rows}(scope_key, uri)`, + `CREATE VIRTUAL TABLE IF NOT EXISTS ${fts} USING fts5(content)`, + ); + } else { + statements.push(...buildFtsSchema(dialect, table, fields)); + } + } + } + + statements.push(...relationIndexes); + for (const sql of statements) { + try { + await db.prepare(sql).run(); + } catch (error) { + // SQLite builds without FTS5 keep canonical isolated records available, + // matching Contrail's existing public-schema behavior. + if (!/VIRTUAL TABLE|fts5/i.test(sql)) throw error; + } + } + for (const migration of countMigrations) { + await ensureIsolatedCountColumns(db, migration.table, migration.columns); + } +} + +function versionForEvent(event: IngestEvent): RecordVersionInfo { + const source = event.source ?? { + id: "legacy-caller", + epoch: null, + time_us: event.indexed_at, + revision: null, + cursor: null, + }; + return { + uri: event.uri, + did: event.did, + collection: event.collection, + rkey: event.rkey, + operation: event.operation, + cid: event.cid, + source_id: source.id, + source_epoch: source.epoch ?? null, + source_revision: source.revision, + source_time_us: source.time_us, + source_cursor: source.cursor, + indexed_at: event.indexed_at, + }; +} + +async function isolatedVersions( + db: Database, + target: IsolatedProjectionTarget, + uris: Iterable, +): Promise> { + const values = [...new Set(uris)]; + const result = new Map(); + for (let offset = 0; offset < values.length; offset += 40) { + const chunk = values.slice(offset, offset + 40); + const placeholders = chunk.map(() => "?").join(","); + const rows = await db + .prepare( + `SELECT uri, did, collection, rkey, operation, cid, source_id, + source_epoch, source_revision, source_time_us, source_cursor, indexed_at + FROM isolated_record_versions + WHERE scope_key = ? AND partition_key = ? AND partition_generation = ? + AND uri IN (${placeholders})`, + ) + .bind(target.scope.key, target.partition, target.generation, ...chunk) + .all(); + for (const row of rows.results ?? []) result.set(row.uri, row); + } + return result; +} + +function mutationWinners( + events: IngestEvent[], + durable: ReadonlyMap, +): MutationSelection { + const winners = new Map(); + let superseded = 0; + for (let index = 0; index < events.length; index++) { + const event = events[index]; + const version = versionForEvent(event); + const previous = winners.get(event.uri); + const current = previous?.version ?? durable.get(event.uri); + if (current && compareRecordVersions(version, current) <= 0) { + superseded++; + continue; + } + if (previous) superseded++; + winners.set(event.uri, { event, version, index }); + } + return { + applied: [...winners.values()] + .sort((left, right) => left.index - right.index) + .map(({ event }) => event), + superseded, + }; +} + +interface Existing { + cid: string | null; + record: string | null; +} + +async function existingRecords( + db: Database, + target: IsolatedProjectionTarget, + events: IngestEvent[], + config: ContrailConfig, +): Promise> { + const result = new Map(); + const byCollection = new Map(); + for (const event of events) { + const short = resolveCollectionKey(config, event.collection); + if (!short) continue; + const uris = byCollection.get(short) ?? []; + uris.push(event.uri); + byCollection.set(short, uris); + } + for (const [short, uris] of byCollection) { + const table = isolatedRecordsTableName(short); + for (let offset = 0; offset < uris.length; offset += 40) { + const chunk = uris.slice(offset, offset + 40); + const placeholders = chunk.map(() => "?").join(","); + const rows = await db + .prepare( + `SELECT uri, cid, record FROM ${table} + WHERE scope_key = ? AND partition_key = ? AND partition_generation = ? + AND uri IN (${placeholders})`, + ) + .bind(target.scope.key, target.partition, target.generation, ...chunk) + .all<{ uri: string; cid: string | null; record: string | null }>(); + for (const row of rows.results ?? []) result.set(row.uri, row); + } + } + return result; +} + +function recordStatements( + db: Database, + target: IsolatedProjectionTarget, + events: IngestEvent[], + config: ContrailConfig, +): Statement[] { + const result: Statement[] = []; + const grouped = new Map(); + for (const event of events) { + const short = resolveCollectionKey(config, event.collection); + if (!short) continue; + const table = isolatedRecordsTableName(short); + const group = grouped.get(table) ?? { upserts: [], deletes: [] }; + (event.operation === "delete" ? group.deletes : group.upserts).push(event); + grouped.set(table, group); + } + const upsertRows = Math.floor(MAX_BINDINGS / 10); + for (const [table, group] of grouped) { + for (let offset = 0; offset < group.upserts.length; offset += upsertRows) { + const chunk = group.upserts.slice(offset, offset + upsertRows); + result.push( + db.prepare( + `INSERT INTO ${table} + (scope_key, partition_key, partition_generation, uri, did, rkey, cid, record, time_us, indexed_at) + VALUES ${chunk.map(() => "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").join(",")} + ON CONFLICT(scope_key, partition_key, partition_generation, uri) + DO UPDATE SET did = excluded.did, rkey = excluded.rkey, cid = excluded.cid, + record = excluded.record, time_us = excluded.time_us, + indexed_at = excluded.indexed_at`, + ).bind(...chunk.flatMap((event) => [ + target.scope.key, + target.partition, + target.generation, + event.uri, + event.did, + event.rkey, + event.cid, + event.record, + event.time_us, + event.indexed_at, + ])), + ); + } + for (let offset = 0; offset < group.deletes.length; offset += 40) { + const chunk = group.deletes.slice(offset, offset + 40); + const placeholders = chunk.map(() => "?").join(","); + result.push( + db.prepare( + `DELETE FROM ${table} + WHERE scope_key = ? AND partition_key = ? AND partition_generation = ? + AND uri IN (${placeholders})`, + ).bind( + target.scope.key, + target.partition, + target.generation, + ...chunk.map(({ uri }) => uri), + ), + ); + } + } + return result; +} + +function versionStatements( + db: Database, + target: IsolatedProjectionTarget, + events: IngestEvent[], + existing: ReadonlyMap, +): Statement[] { + const result: Statement[] = []; + const rowsPerStatement = Math.floor(MAX_BINDINGS / 15); + for (let offset = 0; offset < events.length; offset += rowsPerStatement) { + const chunk = events.slice(offset, offset + rowsPerStatement); + result.push( + db.prepare( + `INSERT INTO isolated_record_versions + (scope_key, partition_key, partition_generation, uri, did, collection, + rkey, operation, cid, source_id, source_epoch, source_revision, + source_time_us, source_cursor, indexed_at) + VALUES ${chunk.map(() => "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").join(",")} + ON CONFLICT(scope_key, partition_key, partition_generation, uri) + DO UPDATE SET did = excluded.did, collection = excluded.collection, + rkey = excluded.rkey, operation = excluded.operation, cid = excluded.cid, + source_id = excluded.source_id, source_epoch = excluded.source_epoch, + source_revision = excluded.source_revision, + source_time_us = excluded.source_time_us, + source_cursor = excluded.source_cursor, indexed_at = excluded.indexed_at`, + ).bind(...chunk.flatMap((event) => { + const version = versionForEvent(event); + const cid = event.operation === "delete" + ? (version.cid ?? existing.get(event.uri)?.cid ?? null) + : version.cid; + return [ + target.scope.key, + target.partition, + target.generation, + version.uri, + version.did, + version.collection, + version.rkey, + version.operation, + cid, + version.source_id, + version.source_epoch, + version.source_revision, + version.source_time_us, + version.source_cursor, + version.indexed_at, + ]; + })), + ); + } + return result; +} + +export function activateIsolatedPartitionStatement( + db: Database, + target: IsolatedProjectionTarget, +): Statement { + assertTarget(target); + return db.prepare( + `INSERT INTO isolated_projection_partitions + (scope_key, partition_key, visible_generation, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(scope_key, partition_key) + DO UPDATE SET visible_generation = excluded.visible_generation, + updated_at = excluded.updated_at`, + ).bind( + target.scope.key, + target.partition, + target.generation, + Date.now() * 1000, + ); +} + +function ftsStatements( + db: Database, + target: IsolatedProjectionTarget, + event: IngestEvent, + config: ContrailConfig, +): Statement[] { + if (getDialect(db).ftsStrategy === "generated-column") return []; + const short = resolveCollectionKey(config, event.collection); + if (!short) return []; + const fields = getSearchableFields(short, config.collections[short]); + if (!fields?.length) return []; + const fts = isolatedFtsTableName(short); + const rows = isolatedFtsRowsTableName(short); + const bindings = [ + target.scope.key, + target.partition, + target.generation, + event.uri, + ] as const; + const deleteContent = () => db.prepare( + `DELETE FROM ${fts} WHERE rowid = ( + SELECT id FROM ${rows} + WHERE scope_key = ? AND partition_key = ? AND partition_generation = ? AND uri = ? + )`, + ).bind(...bindings); + const deleteMapping = () => db.prepare( + `DELETE FROM ${rows} + WHERE scope_key = ? AND partition_key = ? AND partition_generation = ? AND uri = ?`, + ).bind(...bindings); + if (event.operation === "delete") return [deleteContent(), deleteMapping()]; + const record = event.record ? JSON.parse(event.record) : null; + const content = record ? buildFtsContent(record, fields) : ""; + if (!content) return [deleteContent(), deleteMapping()]; + return [ + db.prepare( + getDialect(db).insertOrIgnore( + `INSERT INTO ${rows} + (scope_key, partition_key, partition_generation, uri) + VALUES (?, ?, ?, ?)`, + ), + ).bind(...bindings), + deleteContent(), + db.prepare( + `INSERT INTO ${fts} (rowid, content) + SELECT id, ? FROM ${rows} + WHERE scope_key = ? AND partition_key = ? AND partition_generation = ? AND uri = ?`, + ).bind(content, ...bindings), + ]; +} + +type CountTarget = { + parent: string; + relationName: string; + relation: RelationConfig; + value: string; +}; + +function countTargetKey(target: CountTarget): string { + return `${target.parent}\0${target.relationName}\0${target.value}`; +} + +function collectCountTargets( + targetMap: Map, + event: IngestEvent, + existing: Existing | undefined, + config: ContrailConfig, +): void { + const child = resolveCollectionKey(config, event.collection); + if (!child) return; + const current = event.record ? JSON.parse(event.record) : null; + const previous = existing?.record ? JSON.parse(existing.record) : null; + for (const [parent, parentConfig] of Object.entries(config.collections)) { + for (const [relationName, relation] of Object.entries(parentConfig.relations ?? {})) { + if (relation.collection !== child || relation.count === false) continue; + const values = new Set(); + for (const record of [current, previous]) { + const value = record ? getNestedValue(record, getRelationField(relation)) : null; + if (typeof value === "string") values.add(value); + } + for (const value of values) { + const target = { parent, relationName, relation, value }; + targetMap.set(countTargetKey(target), target); + } + } + } + if (event.operation !== "delete") { + const parent = child; + for (const [relationName, relation] of Object.entries( + config.collections[parent]?.relations ?? {}, + )) { + if (relation.count === false) continue; + const value = relation.match === "did" ? event.did : event.uri; + const target = { parent, relationName, relation, value }; + targetMap.set(countTargetKey(target), target); + } + } +} + +function visibleJoin(alias: string, visibleAlias: string): string { + return `JOIN isolated_projection_partitions ${visibleAlias} + ON ${visibleAlias}.scope_key = ${alias}.scope_key + AND ${visibleAlias}.partition_key = ${alias}.partition_key + AND ${visibleAlias}.visible_generation = ${alias}.partition_generation`; +} + +function countStatementsForTargets( + db: Database, + scopeKey: string, + config: ContrailConfig, + targets: Iterable, +): Statement[] { + const dialect = getDialect(db); + const statements: Statement[] = []; + for (const { parent, relationName, relation, value } of targets) { + const parentTable = isolatedRecordsTableName(parent); + const childTable = isolatedRecordsTableName(relation.collection); + const childTarget = dialect.jsonExtract("child.record", getRelationField(relation)); + const parentColumn = relation.match === "did" ? "did" : "uri"; + const distinct = relation.countDistinct; + const distinctExpression = distinct + ? ["uri", "did", "rkey"].includes(distinct) + ? `child.${distinct}` + : dialect.jsonExtract("child.record", distinct) + : null; + const aggregate = distinctExpression + ? `COUNT(DISTINCT ${distinctExpression})` + : "COUNT(*)"; + const sets: string[] = [ + `${countColumnName(relation.collection)} = ( + SELECT ${aggregate} FROM ${childTable} child + ${visibleJoin("child", "child_visible")} + WHERE child.scope_key = ? AND ${childTarget} = ? + )`, + ]; + const bindings: Array = [scopeKey, value]; + const mapping = (config as ResolvedContrailConfig)._resolved?.relations[parent]?.[ + relationName + ]; + if (relation.groupBy && mapping?.groups) { + const groupExpression = dialect.jsonExtract("child.record", relation.groupBy); + for (const [group, token] of Object.entries(mapping.groups)) { + sets.push( + `${groupedCountColumnName(relation.collection, group)} = ( + SELECT ${aggregate} FROM ${childTable} child + ${visibleJoin("child", "child_visible")} + WHERE child.scope_key = ? AND ${childTarget} = ? AND ${groupExpression} = ? + )`, + ); + bindings.push(scopeKey, value, token); + } + } + statements.push( + db.prepare( + `UPDATE ${parentTable} AS parent SET ${sets.join(", ")} + WHERE parent.scope_key = ? AND parent.${parentColumn} = ? + AND EXISTS ( + SELECT 1 FROM isolated_projection_partitions parent_visible + WHERE parent_visible.scope_key = parent.scope_key + AND parent_visible.partition_key = parent.partition_key + AND parent_visible.visible_generation = parent.partition_generation + )`, + ).bind(...bindings, scopeKey, value), + ); + } + return statements; +} + +/** Recompute every materialized relation count in one isolated scope. Intended + * for the final transaction of a staged full-partition recovery, immediately + * after its visibility pointer switches. */ +export function rebuildIsolatedCountsStatements( + db: Database, + config: ContrailConfig, + scope: { kind: "isolated"; key: string }, +): Statement[] { + const dialect = getDialect(db); + const statements: Statement[] = []; + for (const [parentShort, parentConfig] of Object.entries(config.collections)) { + const parentTable = isolatedRecordsTableName(parentShort); + for (const [relationName, relation] of Object.entries(parentConfig.relations ?? {})) { + if (relation.count === false) continue; + const childTable = isolatedRecordsTableName(relation.collection); + const childTarget = dialect.jsonExtract("child.record", getRelationField(relation)); + const parentTarget = relation.match === "did" ? "parent.did" : "parent.uri"; + const distinct = relation.countDistinct; + const distinctExpression = distinct + ? ["uri", "did", "rkey"].includes(distinct) + ? `child.${distinct}` + : dialect.jsonExtract("child.record", distinct) + : null; + const aggregate = distinctExpression + ? `COUNT(DISTINCT ${distinctExpression})` + : "COUNT(*)"; + const sets = [ + `${countColumnName(relation.collection)} = ( + SELECT ${aggregate} FROM ${childTable} child + ${visibleJoin("child", "child_visible")} + WHERE child.scope_key = parent.scope_key AND ${childTarget} = ${parentTarget} + )`, + ]; + const bindings: string[] = []; + const mapping = (config as ResolvedContrailConfig)._resolved?.relations[ + parentShort + ]?.[relationName]; + if (relation.groupBy && mapping?.groups) { + const groupExpression = dialect.jsonExtract("child.record", relation.groupBy); + for (const [group, token] of Object.entries(mapping.groups)) { + sets.push( + `${groupedCountColumnName(relation.collection, group)} = ( + SELECT ${aggregate} FROM ${childTable} child + ${visibleJoin("child", "child_visible")} + WHERE child.scope_key = parent.scope_key + AND ${childTarget} = ${parentTarget} + AND ${groupExpression} = ? + )`, + ); + bindings.push(token); + } + } + const statement = db.prepare( + `UPDATE ${parentTable} AS parent SET ${sets.join(", ")} + WHERE parent.scope_key = ? + AND EXISTS ( + SELECT 1 FROM isolated_projection_partitions parent_visible + WHERE parent_visible.scope_key = parent.scope_key + AND parent_visible.partition_key = parent.partition_key + AND parent_visible.visible_generation = parent.partition_generation + )`, + ); + statements.push(statement.bind(...bindings, scope.key)); + } + } + return statements; +} + +class IsolatedProjector implements IngestProjection { + constructor(readonly target: IsolatedProjectionTarget) { + assertTarget(target); + } + + async selectCurrent(db: Database, events: IngestEvent[]): Promise { + const durable = await isolatedVersions(db, this.target, events.map(({ uri }) => uri)); + return mutationWinners(events, durable); + } + + async project( + db: Database, + input: IngestEvent[], + config: ContrailConfig, + options: IngestRecordsOptions & { sourceOrderingChecked: true }, + ): Promise { + let selection: MutationSelection = { applied: input, superseded: 0 }; + if (!options.sourceOrderingChecked) { + selection = await this.selectCurrent(db, input); + } + const events = selection.applied; + if (events.length === 0) { + if (options.trailingStatements?.length) await db.batch(options.trailingStatements); + return selection; + } + const existing = await existingRecords(db, this.target, events, config); + const batch: Statement[] = [ + ...recordStatements(db, this.target, events, config), + ...versionStatements(db, this.target, events, existing), + ]; + if (this.target.activate) { + batch.push(activateIsolatedPartitionStatement(db, this.target)); + } + if (!options.skipDerivedProjections) { + for (const event of events) { + batch.push(...ftsStatements(db, this.target, event, config)); + } + const targets = new Map(); + for (const event of events) { + collectCountTargets(targets, event, existing.get(event.uri), config); + } + batch.push( + ...countStatementsForTargets(db, this.target.scope.key, config, targets.values()), + ); + } + if (options.trailingStatements?.length) batch.push(...options.trailingStatements); + await db.batch(batch); + return selection; + } +} + +export function createIsolatedProjection( + target: IsolatedProjectionTarget, +): IngestProjection { + return new IsolatedProjector(target); +} + +interface CursorPayload { + t: number; + u: string; + v?: string | number | null; + k: string; +} + +function encodeBase64Url(value: string): string { + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (let index = 0; index < bytes.length; index += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000)); + } + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); +} + +function decodeBase64Url(value: string): string { + const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); + const bytes = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)); + return new TextDecoder().decode(bytes); +} + +function encodeCursor(value: CursorPayload): string { + return encodeBase64Url(JSON.stringify(value)); +} + +function decodeCursor(value: string): CursorPayload | null { + try { + const parsed = JSON.parse(decodeBase64Url(value)); + return typeof parsed?.t === "number" && typeof parsed?.u === "string" && + typeof parsed?.k === "string" ? parsed : null; + } catch { + return null; + } +} + +function sortKind(sort?: SortOption): string { + if (sort?.recordField) return `field:${sort.recordField}`; + if (sort?.countType) return `count:${sort.countType}`; + return "time"; +} + +function isolatedSearchClause(db: Database, short: string): { + join: string; + condition: string; + order: string; + direction: "asc" | "desc"; +} { + if (getDialect(db).ftsStrategy === "generated-column") { + return { + join: "", + condition: "r.search_vector @@ plainto_tsquery('english', ?)", + order: "ts_rank(r.search_vector, plainto_tsquery('english', ?))", + direction: "desc", + }; + } + const rows = isolatedFtsRowsTableName(short); + const fts = isolatedFtsTableName(short); + return { + join: `JOIN ${rows} fts_rows + ON fts_rows.scope_key = r.scope_key + AND fts_rows.partition_key = r.partition_key + AND fts_rows.partition_generation = r.partition_generation + AND fts_rows.uri = r.uri + JOIN ${fts} fts ON fts.rowid = fts_rows.id`, + condition: "fts.content MATCH ?", + order: "fts.rank", + direction: "asc", + }; +} + +/** Query only rows reachable through one exact scope's visible partition map. */ +export async function queryIsolatedRecords( + db: Database, + config: ContrailConfig, + options: IsolatedQueryOptions, +): Promise { + if (!options.scope.key) throw new TypeError("isolated query requires a scope key"); + const short = config.collections[options.collection] + ? options.collection + : shortNameForNsid(config, options.collection) ?? options.collection; + const collection = config.collections[short]; + if (!collection) throw new TypeError(`unknown collection: ${options.collection}`); + const table = isolatedRecordsTableName(short); + const dialect = getDialect(db); + const limit = Math.min(Math.max(1, options.limit ?? 50), 200); + const conditions = ["r.scope_key = ?"]; + const bindings: Array = [options.scope.key]; + if (options.did) { + conditions.push("r.did = ?"); + bindings.push(options.did); + } + if (options.uri) { + conditions.push("r.uri = ?"); + bindings.push(options.uri); + } + for (const [field, value] of Object.entries(options.filters ?? {})) { + conditions.push(`${dialect.jsonExtract("r.record", field)} = ?`); + bindings.push(value); + } + for (const [field, range] of Object.entries(options.rangeFilters ?? {})) { + if (range.min !== undefined) { + conditions.push(`${dialect.jsonExtract("r.record", field)} >= ?`); + bindings.push(range.min); + } + if (range.max !== undefined) { + conditions.push(`${dialect.jsonExtract("r.record", field)} <= ?`); + bindings.push(range.max); + } + } + for (const [type, minimum] of Object.entries(options.countFilters ?? {})) { + const column = countColumnForType(config, short, type); + if (!column) continue; + conditions.push(`r.${column} >= ?`); + bindings.push(minimum); + } + + let searchClause: ReturnType | null = null; + if (options.search && getSearchableFields(short, collection)?.length) { + searchClause = isolatedSearchClause(db, short); + conditions.push(searchClause.condition); + bindings.push(options.search); + } + const kind = options.sort ? sortKind(options.sort) : searchClause ? "search" : "time"; + if (options.cursor) { + const cursor = decodeCursor(options.cursor); + if (cursor?.k === kind) { + const tail = "(r.time_us < ? OR (r.time_us = ? AND r.uri > ?))"; + if (options.sort?.recordField && cursor.v !== undefined) { + const expression = dialect.jsonExtract("r.record", options.sort.recordField); + const comparator = options.sort.direction === "desc" ? "<" : ">"; + conditions.push( + cursor.v === null + ? `(${expression} IS NULL AND ${tail})` + : `(${expression} ${comparator} ? OR (${expression} = ? AND ${tail}) OR ${expression} IS NULL)`, + ); + if (cursor.v !== null) bindings.push(cursor.v, cursor.v); + bindings.push(cursor.t, cursor.t, cursor.u); + } else if (options.sort?.countType) { + const column = countColumnForType(config, short, options.sort.countType); + if (!column) throw new TypeError(`unknown count type: ${options.sort.countType}`); + const comparator = options.sort.direction === "desc" ? "<" : ">"; + const value = Number(cursor.v ?? 0); + conditions.push( + `(r.${column} ${comparator} ? OR (r.${column} = ? AND ${tail}))`, + ); + bindings.push(value, value, cursor.t, cursor.t, cursor.u); + } else if (searchClause && typeof cursor.v === "number") { + const comparator = searchClause.direction === "desc" ? "<" : ">"; + conditions.push( + `(${searchClause.order} ${comparator} ? OR (${searchClause.order} = ? AND ${tail}))`, + ); + if (dialect.ftsStrategy === "generated-column") { + bindings.push( + options.search!, cursor.v, options.search!, cursor.v, + cursor.t, cursor.t, cursor.u, + ); + } else { + bindings.push(cursor.v, cursor.v, cursor.t, cursor.t, cursor.u); + } + } else if (!searchClause) { + conditions.push(tail); + bindings.push(cursor.t, cursor.t, cursor.u); + } + } + } + + const counts = countColumns(config, short); + const countSelect = counts.length + ? `, ${counts.map(({ column }) => `r.${column}`).join(", ")}` + : ""; + const selectBindings: Array = []; + const fieldSelect = options.sort?.recordField + ? `, ${dialect.jsonExtract("r.record", options.sort.recordField)} AS __sort_value` + : ""; + const rankSelect = searchClause + ? `, ${searchClause.order} AS __search_rank` + : ""; + if (searchClause && dialect.ftsStrategy === "generated-column") { + selectBindings.push(options.search!); + } + + let orderBy = "r.time_us DESC, r.uri ASC"; + const orderBindings: Array = []; + if (options.sort?.recordField) { + const direction = options.sort.direction === "desc" ? "DESC" : "ASC"; + orderBy = `${dialect.jsonExtract("r.record", options.sort.recordField)} ${direction} NULLS LAST, r.time_us DESC, r.uri ASC`; + } else if (options.sort?.countType) { + const column = countColumnForType(config, short, options.sort.countType); + if (!column) throw new TypeError(`unknown count type: ${options.sort.countType}`); + orderBy = `r.${column} ${options.sort.direction.toUpperCase()}, r.time_us DESC, r.uri ASC`; + } else if (searchClause) { + orderBy = `${searchClause.order} ${searchClause.direction.toUpperCase()}, r.time_us DESC, r.uri ASC`; + if (dialect.ftsStrategy === "generated-column") orderBindings.push(options.search!); + } + + const sql = `SELECT r.uri, r.did, r.rkey, r.cid, r.record, r.time_us, r.indexed_at + ${countSelect}${fieldSelect}${rankSelect} + FROM ${table} r + ${visibleJoin("r", "visible")} + ${searchClause?.join ?? ""} + WHERE ${conditions.join(" AND ")} + ORDER BY ${orderBy} + LIMIT ?`; + const response = await db.prepare(sql) + .bind(...selectBindings, ...bindings, ...orderBindings, limit) + .all>(); + const nsid = nsidForShortName(config, short) ?? short; + const rawRows = response.results ?? []; + const records = rawRows.map((row) => { + const record: RecordRow & { counts?: Record } = { + uri: String(row.uri), + did: String(row.did), + collection: nsid, + rkey: String(row.rkey), + cid: row.cid === null ? null : String(row.cid), + record: row.record === null + ? null + : typeof row.record === "string" + ? row.record + : JSON.stringify(row.record), + time_us: Number(row.time_us), + indexed_at: Number(row.indexed_at), + }; + const values: Record = {}; + for (const { type, column } of counts) { + const value = Number(row[column] ?? 0); + if (value) values[type] = value; + } + if (Object.keys(values).length) record.counts = values; + return record; + }); + + let nextCursor: string | undefined; + if (records.length === limit) { + const record = records[records.length - 1]; + const raw = rawRows[rawRows.length - 1]; + let value: string | number | null | undefined; + if (options.sort?.recordField) { + const candidate = raw.__sort_value; + value = typeof candidate === "string" || typeof candidate === "number" + ? candidate + : null; + } else if (options.sort?.countType) { + value = record.counts?.[options.sort.countType] ?? 0; + } else if (searchClause) { + value = Number(raw.__search_rank); + } + nextCursor = encodeCursor({ + t: record.time_us, + u: record.uri, + ...(value === undefined ? {} : { v: value }), + k: kind, + }); + } + + const references = await loadIsolatedReferences( + db, + config, + options.scope, + short, + records, + ); + return { + records, + ...(nextCursor ? { cursor: nextCursor } : {}), + ...(Object.keys(references).length ? { references } : {}), + }; +} + +async function loadIsolatedReferences( + db: Database, + config: ContrailConfig, + scope: { kind: "isolated"; key: string }, + sourceShort: string, + records: RecordRow[], +): Promise> { + const references = config.collections[sourceShort]?.references ?? {}; + const targets = new Map>(); + for (const record of records) { + if (!record.record) continue; + const value = JSON.parse(record.record); + for (const reference of Object.values(references)) { + const uri = getNestedValue(value, reference.field); + if (typeof uri !== "string") continue; + const values = targets.get(reference.collection) ?? new Set(); + values.add(uri); + targets.set(reference.collection, values); + } + } + const result: Record = {}; + for (const [short, uris] of targets) { + const table = isolatedRecordsTableName(short); + const values = [...uris]; + for (let offset = 0; offset < values.length; offset += 40) { + const chunk = values.slice(offset, offset + 40); + const placeholders = chunk.map(() => "?").join(","); + const rows = await db.prepare( + `SELECT r.uri, r.did, r.rkey, r.cid, r.record, r.time_us, r.indexed_at + FROM ${table} r ${visibleJoin("r", "visible")} + WHERE r.scope_key = ? AND r.uri IN (${placeholders})`, + ).bind(scope.key, ...chunk).all>(); + const nsid = nsidForShortName(config, short) ?? short; + for (const row of rows.results ?? []) { + result[String(row.uri)] = { + uri: String(row.uri), + did: String(row.did), + collection: nsid, + rkey: String(row.rkey), + cid: row.cid === null ? null : String(row.cid), + record: typeof row.record === "string" ? row.record : JSON.stringify(row.record), + time_us: Number(row.time_us), + indexed_at: Number(row.indexed_at), + }; + } + } + } + return result; +} + +/** Remove one non-serving or retired partition generation from every isolated + * canonical/derived table. Calling code must first move or remove visibility. */ +export async function deleteIsolatedPartitionGeneration( + db: Database, + config: ContrailConfig, + input: { + scope: { kind: "isolated"; key: string }; + partition: string; + generation: number; + }, +): Promise { + const batch: Statement[] = []; + for (const [short, collection] of Object.entries(config.collections)) { + if ( + getDialect(db).ftsStrategy === "virtual-table" && + getSearchableFields(short, collection)?.length + ) { + const rows = isolatedFtsRowsTableName(short); + const fts = isolatedFtsTableName(short); + batch.push( + db.prepare( + `DELETE FROM ${fts} WHERE rowid IN ( + SELECT id FROM ${rows} + WHERE scope_key = ? AND partition_key = ? AND partition_generation = ? + )`, + ).bind(input.scope.key, input.partition, input.generation), + db.prepare( + `DELETE FROM ${rows} + WHERE scope_key = ? AND partition_key = ? AND partition_generation = ?`, + ).bind(input.scope.key, input.partition, input.generation), + ); + } + batch.push( + db.prepare( + `DELETE FROM ${isolatedRecordsTableName(short)} + WHERE scope_key = ? AND partition_key = ? AND partition_generation = ?`, + ).bind(input.scope.key, input.partition, input.generation), + ); + } + batch.push( + db.prepare( + `DELETE FROM isolated_record_versions + WHERE scope_key = ? AND partition_key = ? AND partition_generation = ?`, + ).bind(input.scope.key, input.partition, input.generation), + ); + if (batch.length) await db.batch(batch); +} + +/** Purge every canonical, version, FTS, and visibility row in an isolated + * scope. Callers should hide the scope first so this may run asynchronously. */ +export async function deleteIsolatedScope( + db: Database, + config: ContrailConfig, + scope: { kind: "isolated"; key: string }, +): Promise { + const batch: Statement[] = []; + for (const [short, collection] of Object.entries(config.collections)) { + if ( + getDialect(db).ftsStrategy === "virtual-table" && + getSearchableFields(short, collection)?.length + ) { + const rows = isolatedFtsRowsTableName(short); + const fts = isolatedFtsTableName(short); + batch.push( + db.prepare( + `DELETE FROM ${fts} WHERE rowid IN ( + SELECT id FROM ${rows} WHERE scope_key = ? + )`, + ).bind(scope.key), + db.prepare(`DELETE FROM ${rows} WHERE scope_key = ?`).bind(scope.key), + ); + } + batch.push( + db.prepare( + `DELETE FROM ${isolatedRecordsTableName(short)} WHERE scope_key = ?`, + ).bind(scope.key), + ); + } + batch.push( + db.prepare(`DELETE FROM isolated_record_versions WHERE scope_key = ?`).bind(scope.key), + db.prepare(`DELETE FROM isolated_projection_partitions WHERE scope_key = ?`).bind(scope.key), + ); + if (batch.length) await db.batch(batch); +} + +export function hideIsolatedPartitionStatement( + db: Database, + scope: { kind: "isolated"; key: string }, + partition: string, +): Statement { + return db.prepare( + `DELETE FROM isolated_projection_partitions + WHERE scope_key = ? AND partition_key = ?`, + ).bind(scope.key, partition); +} + +/** Hide one partition immediately. Record cleanup can follow asynchronously. */ +export async function hideIsolatedPartition( + db: Database, + scope: { kind: "isolated"; key: string }, + partition: string, +): Promise { + await hideIsolatedPartitionStatement(db, scope, partition).run(); +} diff --git a/packages/contrail/src/core/service-auth.ts b/packages/contrail/src/core/service-auth.ts index 7d9c7f4..81048bd 100644 --- a/packages/contrail/src/core/service-auth.ts +++ b/packages/contrail/src/core/service-auth.ts @@ -74,6 +74,20 @@ export interface ServiceAuthResult { response?: Response; } +export interface ExactServiceAuthConfig { + audience: AtprotoAudience; + methods: readonly Nsid[]; + maxTokenAgeSeconds?: number; + resolver?: DidDocumentResolver; +} + +export interface ExactServiceAuthGate { + readonly serviceDid: AtprotoDid; + readonly audience: AtprotoAudience; + protects(method: Nsid): boolean; + authorize(request: Request, method: Nsid): Promise; +} + export interface ServiceAuthGate { readonly serviceDid: AtprotoDid; readonly audience: AtprotoAudience; @@ -94,14 +108,16 @@ function defaultResolver(): DidDocumentResolver { return DEFAULT_RESOLVER; } -/** Create the shared verifier used by protected built-in routes. Tokens remain - * bound to the exact service audience and XRPC method. */ -export function createServiceAuthGate( - config: ContrailConfig, -): ServiceAuthGate | null { - if (!config.serviceAuth) return null; - const serviceAuth = config.serviceAuth; +/** Create a verifier for arbitrary exact XRPC method IDs. This is the extension + * seam used by independently versioned private services; every token remains + * bound to both the fragmented audience and the method being invoked. */ +export function createExactServiceAuthGate( + serviceAuth: ExactServiceAuthConfig, +): ExactServiceAuthGate { const protectedMethods = new Set(serviceAuth.methods); + if (protectedMethods.size !== serviceAuth.methods.length) { + throw new TypeError("service auth methods must be unique"); + } const { serviceDid, audience } = parseServiceAudience(serviceAuth.audience); const verifier = new ServiceJwtVerifier({ acceptAudiences: [audience], @@ -116,6 +132,9 @@ export function createServiceAuthGate( return protectedMethods.has(method); }, async authorize(request, method) { + if (!protectedMethods.has(method)) { + throw new TypeError(`service auth method is not configured: ${method}`); + } try { const principal = await verifier.verifyRequest(request, { lxm: method, @@ -129,3 +148,32 @@ export function createServiceAuthGate( }, }; } + +/** Create the shared verifier used by protected built-in routes. */ +export function createServiceAuthGate( + config: ContrailConfig, +): ServiceAuthGate | null { + if (!config.serviceAuth) return null; + const serviceAuth = config.serviceAuth; + const exact = createExactServiceAuthGate({ + audience: serviceAuth.audience, + methods: serviceAuth.methods.map((method) => + `${config.namespace}.${method}` as Nsid + ), + maxTokenAgeSeconds: serviceAuth.maxTokenAgeSeconds, + resolver: serviceAuth.resolver, + }); + const protectedMethods = new Set(serviceAuth.methods); + + return { + serviceDid: exact.serviceDid, + audience: exact.audience, + protects(method) { + return protectedMethods.has(method); + }, + authorize(request, method) { + // Built-in routing has already selected the exact configured method. + return exact.authorize(request, method); + }, + }; +} diff --git a/packages/contrail/src/index.ts b/packages/contrail/src/index.ts index bd254c2..5d0ee08 100644 --- a/packages/contrail/src/index.ts +++ b/packages/contrail/src/index.ts @@ -3,6 +3,7 @@ export type { LexiconDoc } from "@atcute/lexicon-doc"; export { Contrail } from "./contrail"; export type { AppOptions, ContrailOptions } from "./contrail"; export * from "./public-service"; +export * from "./service-auth-contract"; // Configuration, storage, identity, and dialects. export * from "./core/types"; @@ -29,8 +30,10 @@ export * from "./core/persistent"; export * from "./core/backfill"; export * from "./core/status"; export * from "./core/diagnostics"; +export * from "./core/service-auth"; export * from "./core/validation"; export * from "./core/search"; +export * from "./core/isolated-projection"; export * from "./core/constellation"; // Database. diff --git a/packages/contrail/tests/isolated-projection.test.ts b/packages/contrail/tests/isolated-projection.test.ts new file mode 100644 index 0000000..515d04e --- /dev/null +++ b/packages/contrail/tests/isolated-projection.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import { + createIngestEvent, + createIsolatedProjection, + hideIsolatedPartitionStatement, + ingestRecords, + initIsolatedProjection, + initSchema, + queryIsolatedRecords, + queryRecords, + rebuildIsolatedCountsStatements, + resolveConfig, +} from "../src/index"; +import { createTestDb } from "./helpers"; + +const config = resolveConfig({ + namespace: "garden.atmo.circle", + profiles: [], + collections: { + note: { + collection: "garden.atmo.circle.note", + searchable: ["text"], + queryable: { createdAt: { type: "range" } }, + relations: { + reactions: { collection: "reaction", field: "subject.uri" }, + }, + }, + reaction: { + collection: "garden.atmo.circle.reaction", + references: { + note: { collection: "note", field: "subject.uri" }, + }, + }, + }, +}); + +const scope = (space: string, generation = 1) => ({ + kind: "isolated" as const, + key: `${space}\u0000${generation}`, +}); + +function event(input: { + space: string; + did?: string; + collection?: "note" | "reaction"; + rkey?: string; + value?: Record; + operation?: "create" | "update" | "delete"; + revision?: string; +}) { + const did = input.did ?? "did:plc:alice"; + const short = input.collection ?? "note"; + const collection = `garden.atmo.circle.${short}`; + const rkey = input.rkey ?? "one"; + return createIngestEvent({ + uri: `${input.space}/${did}/${collection}/${rkey}`, + did, + collection, + rkey, + operation: input.operation ?? "create", + cid: input.operation === "delete" ? null : `bafy-${input.revision ?? "1"}`, + value: input.value ?? { text: "private hello", createdAt: "2026-08-21T00:00:00Z" }, + timeUs: 1_000_000, + source: { + id: `space:${input.space}`, + revision: input.revision ?? "1", + time_us: 1_000_000, + }, + }); +} + +async function project( + db: ReturnType, + target: Parameters[0], + events: ReturnType[], + authoritativeSourceObservation = false, +) { + return ingestRecords(db, events, config, { + projection: createIsolatedProjection(target), + authoritativeSourceObservation, + skipDiagnostics: true, + }); +} + +describe("isolated projection", () => { + it("keeps public and colliding Space rows physically isolated", async () => { + const db = createTestDb(); + await initSchema(db, config); + await initIsolatedProjection(db, config); + const alice = "at://did:plc:alice/space/garden.atmo.circle/self"; + const carol = "at://did:plc:carol/space/garden.atmo.circle/self"; + + await project(db, { + scope: scope(alice), partition: "did:plc:alice", generation: 1, activate: true, + }, [event({ space: alice })]); + await project(db, { + scope: scope(carol), partition: "did:plc:alice", generation: 1, activate: true, + }, [event({ space: carol, value: { text: "other secret" } })]); + + const aliceRows = await queryIsolatedRecords(db, config, { + scope: scope(alice), collection: "note", + }); + const carolRows = await queryIsolatedRecords(db, config, { + scope: scope(carol), collection: "note", + }); + expect(aliceRows.records).toHaveLength(1); + expect(JSON.parse(aliceRows.records[0].record!)).toMatchObject({ text: "private hello" }); + expect(JSON.parse(carolRows.records[0].record!)).toMatchObject({ text: "other secret" }); + expect((await queryRecords(db, config, { collection: "note" })).records).toEqual([]); + }); + + it("searches, resolves references, and counts only visible writers in one Space", async () => { + const db = createTestDb(); + await initIsolatedProjection(db, config); + const space = "at://did:plc:alice/space/garden.atmo.circle/self"; + const note = event({ space }); + await project(db, { + scope: scope(space), partition: "did:plc:alice", generation: 1, activate: true, + }, [note]); + await project(db, { + scope: scope(space), partition: "did:plc:bob", generation: 1, activate: true, + }, [event({ + space, + did: "did:plc:bob", + collection: "reaction", + value: { subject: { uri: note.uri, cid: note.cid }, createdAt: "2026-08-21T00:00:01Z" }, + })]); + + const notes = await queryIsolatedRecords(db, config, { + scope: scope(space), collection: "note", search: "hello", + }); + expect(notes.records[0].counts).toEqual({ reaction: 1 }); + + const reactions = await queryIsolatedRecords(db, config, { + scope: scope(space), collection: "reaction", + }); + expect(reactions.references?.[note.uri]?.uri).toBe(note.uri); + + await db.batch([ + hideIsolatedPartitionStatement(db, scope(space), "did:plc:bob"), + ...rebuildIsolatedCountsStatements(db, config, scope(space)), + ]); + const afterRemoval = await queryIsolatedRecords(db, config, { + scope: scope(space), collection: "note", + }); + expect(afterRemoval.records[0].counts).toBeUndefined(); + }); + + it("additively migrates relation-count columns on an existing schema", async () => { + const db = createTestDb(); + const initial = resolveConfig({ + namespace: "garden.atmo.circle", + profiles: [], + collections: { + note: { collection: "garden.atmo.circle.note" }, + reaction: { collection: "garden.atmo.circle.reaction" }, + }, + }); + await initIsolatedProjection(db, initial); + await initIsolatedProjection(db, config); + + const space = "at://did:plc:alice/space/garden.atmo.circle/self"; + const note = event({ space }); + await project(db, { + scope: scope(space), partition: "did:plc:alice", generation: 1, activate: true, + }, [note]); + await project(db, { + scope: scope(space), partition: "did:plc:bob", generation: 1, activate: true, + }, [event({ + space, + did: "did:plc:bob", + collection: "reaction", + value: { subject: { uri: note.uri }, createdAt: "2026-08-21T00:00:01Z" }, + })]); + + const result = await queryIsolatedRecords(db, config, { + scope: scope(space), collection: "note", + }); + expect(result.records[0].counts).toEqual({ reaction: 1 }); + }); + + it("atomically switches a recovered writer generation", async () => { + const db = createTestDb(); + await initIsolatedProjection(db, config); + const space = "at://did:plc:alice/space/garden.atmo.circle/self"; + await project(db, { + scope: scope(space), partition: "did:plc:alice", generation: 1, activate: true, + }, [event({ space, revision: "1", value: { text: "old" } })]); + await project(db, { + scope: scope(space), partition: "did:plc:alice", generation: 2, activate: true, + }, [event({ space, revision: "2", value: { text: "recovered" } })], true); + + const result = await queryIsolatedRecords(db, config, { + scope: scope(space), collection: "note", + }); + expect(result.records).toHaveLength(1); + expect(JSON.parse(result.records[0].record!)).toMatchObject({ text: "recovered" }); + }); +}); diff --git a/packages/contrail/tests/service-auth.test.ts b/packages/contrail/tests/service-auth.test.ts index b58b559..50fdc8a 100644 --- a/packages/contrail/tests/service-auth.test.ts +++ b/packages/contrail/tests/service-auth.test.ts @@ -8,7 +8,10 @@ import { createServiceJwt } from "@atcute/xrpc-server/auth"; import { beforeAll, describe, expect, it } from "vitest"; import { createSqliteDatabase } from "../src/adapters/sqlite"; import { createApp } from "../src/core/router"; -import { createCachedDidDocumentResolver } from "../src/core/service-auth"; +import { + createCachedDidDocumentResolver, + createExactServiceAuthGate, +} from "../src/core/service-auth"; import { initSchema, resolveConfig, @@ -107,6 +110,25 @@ function authorized(url: string, jwt: string, init: RequestInit = {}) { } describe("AT Protocol service auth", () => { + it("verifies arbitrary configured extension method IDs", async () => { + const method = "garden.atmo.circle.note.listSpaceRecords" as Nsid; + const gate = createExactServiceAuthGate({ + audience, + methods: [method], + resolver: config().serviceAuth!.resolver, + }); + const request = authorized( + "https://api.example.com/xrpc/garden.atmo.circle.note.listSpaceRecords", + await token(method), + ); + const result = await gate.authorize(request, method); + expect(result.response).toBeUndefined(); + expect(result.principal?.issuer).toBe(issuer); + await expect( + gate.authorize(request, "garden.atmo.circle.syncSpace" as Nsid), + ).rejects.toThrow("not configured"); + }); + it("requires exact audience and method-bound tokens", async () => { const { app } = await setup(); const url = `https://api.example.com/xrpc/com.example.getFeed?feed=network&actor=${issuer}`; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a97826f..2e0db38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,6 +100,62 @@ importers: specifier: ^6.0.3 version: 6.0.3 + apps/spaces-demo: + dependencies: + '@atmo-dev/contrail-spaces-alpha': + specifier: workspace:* + version: link:../../packages/contrail-spaces-alpha + '@svelte-atproto/oauth': + specifier: ^0.3.0 + version: 0.3.0(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@sveltejs/kit@2.70.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.6)))(svelte@5.56.8(@typescript-eslint/types@8.66.0))(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.6)))(svelte@5.56.8(@typescript-eslint/types@8.66.0))(typescript@6.0.3) + devDependencies: + '@cloudflare/workers-types': + specifier: ^5.20260804.1 + version: 5.20260804.1 + '@sveltejs/adapter-cloudflare': + specifier: ^7.2.9 + version: 7.2.9(@sveltejs/kit@2.70.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.6)))(svelte@5.56.8(@typescript-eslint/types@8.66.0))(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.6)))(wrangler@4.118.0(@cloudflare/workers-types@5.20260804.1)) + '@sveltejs/kit': + specifier: ^2.70.2 + version: 2.70.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.6)))(svelte@5.56.8(@typescript-eslint/types@8.66.0))(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.6)) + '@sveltejs/vite-plugin-svelte': + specifier: ^7.2.0 + version: 7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.6)) + svelte: + specifier: ^5.56.8 + version: 5.56.8(@typescript-eslint/types@8.66.0) + svelte-check: + specifier: ^4.7.4 + version: 4.7.4(picomatch@4.0.5)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^8.2.0 + version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.6) + wrangler: + specifier: ^4.118.0 + version: 4.118.0(@cloudflare/workers-types@5.20260804.1) + + apps/spaces-demo-provider: + dependencies: + '@atmo-dev/contrail': + specifier: workspace:* + version: link:../../packages/contrail + '@atmo-dev/contrail-spaces-alpha': + specifier: workspace:* + version: link:../../packages/contrail-spaces-alpha + devDependencies: + '@cloudflare/workers-types': + specifier: ^5.20260804.1 + version: 5.20260804.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + wrangler: + specifier: ^4.118.0 + version: 4.118.0(@cloudflare/workers-types@5.20260804.1) + apps/sqlite: dependencies: '@atmo-dev/contrail': @@ -319,6 +375,52 @@ importers: specifier: ^4.118.0 version: 4.118.0(@cloudflare/workers-types@5.20260804.1) + packages/contrail-spaces-alpha: + dependencies: + '@atmo-dev/contrail': + specifier: workspace:* + version: link:../contrail + '@atproto/common-web': + specifier: 0.0.0-spaces-alpha-20260818163953 + version: 0.0.0-spaces-alpha-20260818163953 + '@atproto/identity': + specifier: 0.0.0-spaces-alpha-20260818163953 + version: 0.0.0-spaces-alpha-20260818163953 + '@atproto/jwk-jose': + specifier: 0.2.4 + version: 0.2.4 + '@atproto/lex-data': + specifier: 0.1.7 + version: 0.1.7 + '@atproto/space': + specifier: 0.0.0-spaces-alpha-20260818163953 + version: 0.0.0-spaces-alpha-20260818163953 + devDependencies: + '@atcute/crypto': + specifier: 2.4.4 + version: 2.4.4 + '@atcute/xrpc-server': + specifier: ^2.0.2 + version: 2.0.2(@atcute/cid@2.4.2)(@atcute/lexicons@2.0.3)(typescript@6.0.3) + '@cloudflare/workers-types': + specifier: ^5.20260804.1 + version: 5.20260804.1 + '@types/node': + specifier: ^26.1.2 + version: 26.1.2 + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.25)(tsx@4.23.6)(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.2)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.6)) + wrangler: + specifier: ^4.118.0 + version: 4.118.0(@cloudflare/workers-types@5.20260804.1) + packages: '@atcute/atproto@3.1.12': @@ -416,6 +518,12 @@ packages: '@atcute/multibase@1.2.5': resolution: {integrity: sha512-cReTONgYpQo/VHD3ZmzPNoyBKJgSk1J4h//cvvdVVJBMar+SjlQ/sUXeTjQfuyfmDKv+TLKhmutLcvbMcQ9Rvw==} + '@atcute/oauth-browser-client@4.0.1': + resolution: {integrity: sha512-k5GGDFWUqTifbi7bMk4LhwG12XWNJop9atZ3ocBpzHJvXboTqp7E2zaD/arhJWxHXp5+0A1R3EKXdHsVbWjHyA==} + peerDependencies: + '@atcute/identity-resolver': ^2.0.0 + '@atcute/lexicons': ^2.0.0 + '@atcute/oauth-crypto@1.0.1': resolution: {integrity: sha512-ghC8ceFx0r6pi5X3dNoET1K6PNg8afeAOZLD4PKLhrpVYdRtRrgY4uqAe3ojtSIjoS6GXmVm063CoJ2xtg/MoQ==} @@ -461,6 +569,50 @@ packages: peerDependencies: '@atcute/lexicons': ^2.0.0 + '@atproto/common-web@0.0.0-spaces-alpha-20260818163953': + resolution: {integrity: sha512-9D7IJ5HAsQxFWibfL1+F26envBEoDR24e1q8diXrA8LfzVoRLP3zUlPWDG02zaumxgAPIIO5Yr5yuhzHYusEUg==} + engines: {node: '>=22'} + + '@atproto/common@0.0.0-spaces-alpha-20260818163953': + resolution: {integrity: sha512-lzXomMvIHMFbmXPpsnuNUok4yZB4E67FJyNL/D3RVqxzL+OgkyPilsOYPIVzCRLZFzrVdy8Lah7kfLdxaHptgQ==} + engines: {node: '>=22'} + + '@atproto/crypto@0.0.0-spaces-alpha-20260818163953': + resolution: {integrity: sha512-Cjqoa3BKUFaqkdZkiZy+aQJTPxFGFv0yNmbsF21zh+gK3Hvkjdgxg7TjlaFasLIy6RKY1k9weD3SztVKKMoZzw==} + engines: {node: '>=22'} + + '@atproto/identity@0.0.0-spaces-alpha-20260818163953': + resolution: {integrity: sha512-Ozc2nPVAc93AsGMmcfUNuWfeT6pIwptDmftVu1XbZOcoODbemHk0pJfz7n27pHjWgghjMUkCj6KDq6TogAX/jg==} + engines: {node: '>=22'} + + '@atproto/jwk-jose@0.2.4': + resolution: {integrity: sha512-gzDoA0JTwnc0ZJOBLM7WX9xFxtynRS2K1Bofb8epzoMWDQvyvfbPcfkdPrKFM7NXCFUVpGpBnsCB8KFPTf1rCg==} + engines: {node: '>=22'} + + '@atproto/jwk@0.7.4': + resolution: {integrity: sha512-tq7TUDmNfe1yDfpRgdGQMJdl9TUlJmREQNCag9yg5w8Evu+TOiFiLgiOCbo7X4ouRPSgd1DpOzXbUa8UyKKMZA==} + engines: {node: '>=22'} + + '@atproto/lex-cbor@0.1.6': + resolution: {integrity: sha512-PxLQy7jzV8u9zTGURNF8ZczLPk+ZH+WNx/2z/z7RacJbu7sWNpx26U2pC7cIkWtt4jyDaruaLhssw3w3atLotA==} + engines: {node: '>=22'} + + '@atproto/lex-data@0.1.7': + resolution: {integrity: sha512-kW/dPLqo/WgCLV+XESR4JKwV6c1rZWJGOfuPupZGTjEDAKoBbKXdaEzX9/1vKQYbZ9U3j0DS/n7OFFK7wBugyQ==} + engines: {node: '>=22'} + + '@atproto/lex-json@0.1.6': + resolution: {integrity: sha512-mvrAd0lbyuecIHjyld8QN6MN6CBf4j0GCxLzegsvLh0SvDf+GbYWklkcQqmITL44yFQOwmA/QNIQj0Uvh7+R/g==} + engines: {node: '>=22'} + + '@atproto/space@0.0.0-spaces-alpha-20260818163953': + resolution: {integrity: sha512-0TQwGc7tD+ctccBD7ZD52BJ8RKUKR7hN2MtzDztJbTJSLIY4GYdr6XTnwdlsJBfDBvIqhpFp/9oHdELZLecuyw==} + engines: {node: '>=22'} + + '@atproto/syntax@0.0.0-spaces-alpha-20260818163953': + resolution: {integrity: sha512-Xl2fpD/NcfVOWpHR86HKym2XNDYHmgiqThZXoYMwh9XUo+/LgVZYGVpSecfKRBcQ5tNGiRivE1Fvio5YDfJ3lA==} + engines: {node: '>=22'} + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} @@ -1565,6 +1717,14 @@ packages: os: [linux] libc: [glibc] + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@noble/secp256k1@3.1.0': resolution: {integrity: sha512-+F7iS7tUMaNGXcc9X3PjmjvuQnXEuSjCRNzVVA2xAcKXgCaP0dHYz4SFyt4FKNHef7sOP//xihowcySSS7PK9g==} @@ -1599,6 +1759,9 @@ packages: '@oxc-project/types@0.142.0': resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1852,6 +2015,13 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@svelte-atproto/oauth@0.3.0': + resolution: {integrity: sha512-Tijm4GGMg15NrA2EH715GoGE0yHxPBL4dk8h+Zmb3L7ea/TLKayjVso3OgNg5drvBjrgrthO0oI3BYSudTbT0A==} + hasBin: true + peerDependencies: + '@sveltejs/kit': ^2.0.0 + svelte: ^5.0.0 + '@sveltejs/acorn-typescript@1.0.11': resolution: {integrity: sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==} peerDependencies: @@ -2376,6 +2546,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -2420,6 +2594,10 @@ packages: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + cborg@4.5.8: + resolution: {integrity: sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw==} + hasBin: true + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -2798,10 +2976,16 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + iso-datestring-validator@2.2.2: + resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -3082,6 +3266,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + multiformats@13.4.2: + resolution: {integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -3114,6 +3301,10 @@ packages: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3230,6 +3421,16 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -3382,6 +3583,9 @@ packages: engines: {node: '>=14'} hasBin: true + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + prosemirror-changeset@2.4.1: resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==} @@ -3431,6 +3635,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + rangetouch@2.0.1: resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==} @@ -3442,6 +3649,13 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + regexparam@3.0.0: resolution: {integrity: sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==} engines: {node: '>=8'} @@ -3498,6 +3712,10 @@ packages: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -3540,6 +3758,9 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3668,6 +3889,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -3761,6 +3986,9 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + uint8arrays@5.1.1: + resolution: {integrity: sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==} + undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -3775,6 +4003,9 @@ packages: unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unicode-segmenter@0.14.5: + resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==} + unicode-segmenter@0.17.3: resolution: {integrity: sha512-hKZwqBjJDmqNrq1+LjDxck1qLzJFcLLJM2Xq92ORRHOuau6GSHeELmn+uyk6zNH1CK9mogrUJGP9WJCJUQXMAw==} @@ -3799,6 +4030,9 @@ packages: typescript: optional: true + varint@6.0.0: + resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==} + vite@8.2.0: resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4008,6 +4242,9 @@ packages: zimmerframe@1.1.4: resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + snapshots: '@atcute/atproto@3.1.12(@atcute/lexicons@2.0.3)': @@ -4150,6 +4387,18 @@ snapshots: dependencies: '@atcute/uint8array': 1.1.5 + '@atcute/oauth-browser-client@4.0.1(@atcute/identity-resolver@2.0.1(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicons@2.0.3)(typescript@6.0.3)': + dependencies: + '@atcute/client': 5.1.1(@atcute/lexicons@2.0.3)(typescript@6.0.3) + '@atcute/identity-resolver': 2.0.1(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicons@2.0.3)(typescript@6.0.3) + '@atcute/lexicons': 2.0.3 + '@atcute/multibase': 1.2.5 + '@atcute/oauth-crypto': 1.0.1(typescript@6.0.3) + '@atcute/oauth-types': 1.0.1(typescript@6.0.3) + nanoid: 5.1.16 + transitivePeerDependencies: + - typescript + '@atcute/oauth-crypto@1.0.1(typescript@6.0.3)': dependencies: '@atcute/multibase': 1.2.5 @@ -4234,6 +4483,76 @@ snapshots: - '@atcute/cid' - typescript + '@atproto/common-web@0.0.0-spaces-alpha-20260818163953': + dependencies: + '@atproto/lex-data': 0.1.7 + '@atproto/lex-json': 0.1.6 + '@atproto/syntax': 0.0.0-spaces-alpha-20260818163953 + zod: 3.25.76 + + '@atproto/common@0.0.0-spaces-alpha-20260818163953': + dependencies: + '@atproto/common-web': 0.0.0-spaces-alpha-20260818163953 + '@atproto/lex-cbor': 0.1.6 + '@atproto/lex-data': 0.1.7 + multiformats: 13.4.2 + pino: 10.3.1 + varint: 6.0.0 + + '@atproto/crypto@0.0.0-spaces-alpha-20260818163953': + dependencies: + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + uint8arrays: 5.1.1 + + '@atproto/identity@0.0.0-spaces-alpha-20260818163953': + dependencies: + '@atproto/common-web': 0.0.0-spaces-alpha-20260818163953 + '@atproto/crypto': 0.0.0-spaces-alpha-20260818163953 + + '@atproto/jwk-jose@0.2.4': + dependencies: + '@atproto/jwk': 0.7.4 + jose: 5.10.0 + + '@atproto/jwk@0.7.4': + dependencies: + multiformats: 13.4.2 + zod: 3.25.76 + + '@atproto/lex-cbor@0.1.6': + dependencies: + '@atproto/lex-data': 0.1.7 + cborg: 4.5.8 + tslib: 2.8.1 + + '@atproto/lex-data@0.1.7': + dependencies: + multiformats: 13.4.2 + tslib: 2.8.1 + unicode-segmenter: 0.14.5 + + '@atproto/lex-json@0.1.6': + dependencies: + '@atproto/lex-data': 0.1.7 + tslib: 2.8.1 + + '@atproto/space@0.0.0-spaces-alpha-20260818163953': + dependencies: + '@atproto/common': 0.0.0-spaces-alpha-20260818163953 + '@atproto/crypto': 0.0.0-spaces-alpha-20260818163953 + '@atproto/jwk': 0.7.4 + '@atproto/lex-cbor': 0.1.6 + '@atproto/lex-data': 0.1.7 + '@noble/hashes': 1.8.0 + jose: 5.10.0 + zod: 3.25.76 + + '@atproto/syntax@0.0.0-spaces-alpha-20260818163953': + dependencies: + iso-datestring-validator: 2.2.2 + tslib: 2.8.1 + '@babel/runtime@7.29.7': {} '@changesets/apply-release-plan@7.1.1': @@ -5114,6 +5433,12 @@ snapshots: '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + '@noble/secp256k1@3.1.0': {} '@nodelib/fs.scandir@2.1.5': @@ -5144,6 +5469,8 @@ snapshots: '@oxc-project/types@0.142.0': {} + '@pinojs/redact@0.4.0': {} + '@polka/url@1.0.0-next.29': {} '@poppinss/colors@4.1.6': @@ -5283,6 +5610,22 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@svelte-atproto/oauth@0.3.0(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@sveltejs/kit@2.70.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.6)))(svelte@5.56.8(@typescript-eslint/types@8.66.0))(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.6)))(svelte@5.56.8(@typescript-eslint/types@8.66.0))(typescript@6.0.3)': + dependencies: + '@atcute/atproto': 4.0.4(@atcute/lexicons@2.0.3) + '@atcute/bluesky': 4.0.17(@atcute/lexicons@2.0.3) + '@atcute/client': 5.1.1(@atcute/lexicons@2.0.3)(typescript@6.0.3) + '@atcute/identity-resolver': 2.0.1(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicons@2.0.3)(typescript@6.0.3) + '@atcute/lexicons': 2.0.3 + '@atcute/oauth-browser-client': 4.0.1(@atcute/identity-resolver@2.0.1(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicons@2.0.3)(typescript@6.0.3) + '@atcute/oauth-node-client': 2.0.1(@atcute/identity-resolver@2.0.1(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicons@2.0.3)(typescript@6.0.3) + '@atcute/tid': 1.1.4 + '@sveltejs/kit': 2.70.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.6)))(svelte@5.56.8(@typescript-eslint/types@8.66.0))(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.6)) + svelte: 5.56.8(@typescript-eslint/types@8.66.0) + transitivePeerDependencies: + - '@atcute/identity' + - typescript + '@sveltejs/acorn-typescript@1.0.11(acorn@8.18.0)': dependencies: acorn: 8.18.0 @@ -5822,6 +6165,8 @@ snapshots: assertion-error@2.0.1: {} + atomic-sleep@1.0.0: {} + axobject-query@4.1.0: {} balanced-match@4.0.4: {} @@ -5862,6 +6207,8 @@ snapshots: cac@7.0.0: {} + cborg@4.5.8: {} + chai@6.2.2: {} chardet@2.2.0: {} @@ -6283,8 +6630,12 @@ snapshots: isexe@2.0.0: {} + iso-datestring-validator@2.2.2: {} + jiti@2.7.0: {} + jose@5.10.0: {} + joycon@3.1.1: {} js-yaml@3.15.1: @@ -6511,6 +6862,8 @@ snapshots: ms@2.1.3: {} + multiformats@13.4.2: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -6533,6 +6886,8 @@ snapshots: obug@2.1.4: {} + on-exit-leak-free@2.1.2: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -6631,6 +6986,26 @@ snapshots: pify@4.0.1: {} + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + pirates@4.0.7: {} pkg-types@1.3.1: @@ -6708,6 +7083,8 @@ snapshots: prettier@3.9.6: {} + process-warning@5.1.0: {} + prosemirror-changeset@2.4.1: dependencies: prosemirror-transform: 1.12.0 @@ -6788,6 +7165,8 @@ snapshots: queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + rangetouch@2.0.1: {} read-yaml-file@1.1.0: @@ -6799,6 +7178,10 @@ snapshots: readdirp@4.1.2: {} + real-require@0.2.0: {} + + real-require@1.0.0: {} + regexparam@3.0.0: {} resolve-from@5.0.0: {} @@ -6891,6 +7274,8 @@ snapshots: dependencies: mri: 1.2.0 + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} semver@7.8.5: {} @@ -6978,6 +7363,10 @@ snapshots: slash@3.0.0: {} + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map@0.7.6: {} @@ -7116,6 +7505,10 @@ snapshots: dependencies: any-promise: 1.3.0 + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -7209,6 +7602,10 @@ snapshots: ufo@1.6.4: {} + uint8arrays@5.1.1: + dependencies: + multiformats: 13.4.2 + undici-types@8.3.0: {} undici@7.24.8: {} @@ -7219,6 +7616,8 @@ snapshots: dependencies: pathe: 2.0.3 + unicode-segmenter@0.14.5: {} + unicode-segmenter@0.17.3: {} universalify@0.1.2: {} @@ -7235,6 +7634,8 @@ snapshots: optionalDependencies: typescript: 6.0.3 + varint@6.0.0: {} + vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.6): dependencies: lightningcss: 1.33.0 @@ -7419,3 +7820,5 @@ snapshots: youch-core: 0.3.3 zimmerframe@1.1.4: {} + + zod@3.25.76: {} -- 2.54.0