From 0130fa70ca50c22e69b23e93e7c06190cec51002 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:34:30 +0200 Subject: [PATCH] refactor: integrate Spaces runtime with application --- apps/spaces-demo-provider/.dev.vars.example | 1 - apps/spaces-demo-provider/.gitignore | 3 - apps/spaces-demo-provider/README.md | 38 - apps/spaces-demo-provider/package.json | 22 - apps/spaces-demo-provider/src/constants.ts | 6 - apps/spaces-demo-provider/tsconfig.json | 7 - apps/spaces-demo-provider/wrangler.jsonc | 35 - apps/spaces-demo/.dev.vars.example | 4 + apps/spaces-demo/.gitignore | 3 + apps/spaces-demo/README.md | 45 +- apps/spaces-demo/package.json | 8 +- .../scripts/publish-lexicons.ts | 2 +- apps/spaces-demo/src/app.d.ts | 6 + .../src => spaces-demo/src/lib}/lexicons.ts | 0 apps/spaces-demo/src/lib/server/auth.ts | 22 +- .../src/lib/server/spaces.ts} | 16 +- apps/spaces-demo/src/routes/+page.server.ts | 108 +-- apps/spaces-demo/src/routes/+page.svelte | 13 +- .../routes/api/space-subscription/+server.ts | 37 +- apps/spaces-demo/svelte.config.js | 2 +- apps/spaces-demo/tsconfig.json | 3 +- apps/spaces-demo/vite.config.ts | 5 +- apps/spaces-demo/worker.ts | 37 + apps/spaces-demo/wrangler.jsonc | 37 +- apps/spaces-demo/wrangler.svelte.jsonc | 11 + docs/experimental-spaces-alpha.md | 16 +- packages/contrail-spaces-alpha/README.md | 10 +- .../contrail-spaces-alpha/src/consumer.ts | 30 +- packages/contrail-spaces-alpha/src/sync.ts | 30 +- packages/contrail-spaces-alpha/src/worker.ts | 702 +++++++++++------- .../tests/consumer.test.ts | 9 + .../tests/worker-query.test.ts | 36 + patches/@atcute__multibase@1.2.5.patch | 35 + patches/@svelte-atproto__oauth@0.3.0.patch | 38 + pnpm-lock.yaml | 54 +- pnpm-workspace.yaml | 4 + 36 files changed, 885 insertions(+), 550 deletions(-) delete mode 100644 apps/spaces-demo-provider/.dev.vars.example delete mode 100644 apps/spaces-demo-provider/.gitignore delete mode 100644 apps/spaces-demo-provider/README.md delete mode 100644 apps/spaces-demo-provider/package.json delete mode 100644 apps/spaces-demo-provider/src/constants.ts delete mode 100644 apps/spaces-demo-provider/tsconfig.json delete mode 100644 apps/spaces-demo-provider/wrangler.jsonc create mode 100644 apps/spaces-demo/.dev.vars.example rename apps/{spaces-demo-provider => spaces-demo}/scripts/publish-lexicons.ts (97%) rename apps/{spaces-demo-provider/src => spaces-demo/src/lib}/lexicons.ts (100%) rename apps/{spaces-demo-provider/src/worker.ts => spaces-demo/src/lib/server/spaces.ts} (76%) create mode 100644 apps/spaces-demo/worker.ts create mode 100644 apps/spaces-demo/wrangler.svelte.jsonc create mode 100644 patches/@atcute__multibase@1.2.5.patch create mode 100644 patches/@svelte-atproto__oauth@0.3.0.patch diff --git a/apps/spaces-demo-provider/.dev.vars.example b/apps/spaces-demo-provider/.dev.vars.example deleted file mode 100644 index 7460896..0000000 --- a/apps/spaces-demo-provider/.dev.vars.example +++ /dev/null @@ -1 +0,0 @@ -SPACES_CREDENTIAL_ENCRYPTION_KEY=replace-with-32-byte-base64-key diff --git a/apps/spaces-demo-provider/.gitignore b/apps/spaces-demo-provider/.gitignore deleted file mode 100644 index 153b333..0000000 --- a/apps/spaces-demo-provider/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.wrangler -node_modules -.dev.vars diff --git a/apps/spaces-demo-provider/README.md b/apps/spaces-demo-provider/README.md deleted file mode 100644 index 93baa9c..0000000 --- a/apps/spaces-demo-provider/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# 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. Circles use the protocol's native `memberListPolicy`; the authority PDS owns membership and decides whether it will issue delegation and Space credentials. Contrail does not project or author a parallel member list. Successful PDS authorization creates a two-minute query lease, which the consumer renews on demand so removals take effect without a provider-owned entitlement source. - -A hibernating Durable Object per Space fans out projection invalidations over one-time-ticket WebSockets. Events contain only the Space URI and timestamp; authorized clients refetch records through the private query API. - -## 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 D1 migration command is needed for this first alpha schema. Wrangler applies the `SpaceSubscriptionHub` Durable Object migration declared in `wrangler.jsonc`. diff --git a/apps/spaces-demo-provider/package.json b/apps/spaces-demo-provider/package.json deleted file mode 100644 index c4402f1..0000000 --- a/apps/spaces-demo-provider/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "contrail-spaces-demo-provider", - "version": "0.0.1", - "private": true, - "type": "module", - "scripts": { - "dev": "wrangler dev", - "deploy": "wrangler deploy", - "publish-lexicons": "tsx scripts/publish-lexicons.ts", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@atmo-dev/contrail": "workspace:*", - "@atmo-dev/contrail-spaces-alpha": "workspace:*" - }, - "devDependencies": { - "@cloudflare/workers-types": "^5.20260804.1", - "tsx": "^4.21.0", - "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 deleted file mode 100644 index 6bbecbd..0000000 --- a/apps/spaces-demo-provider/src/constants.ts +++ /dev/null @@ -1,6 +0,0 @@ -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/tsconfig.json b/apps/spaces-demo-provider/tsconfig.json deleted file mode 100644 index 61fe203..0000000 --- a/apps/spaces-demo-provider/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "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 deleted file mode 100644 index 94221f9..0000000 --- a/apps/spaces-demo-provider/wrangler.jsonc +++ /dev/null @@ -1,35 +0,0 @@ -{ - "$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 } - ], - "d1_databases": [ - { - "binding": "DB", - "database_name": "contrail-spaces-demo", - "database_id": "4d0d01ba-8164-4fba-8526-472de88c5f19" - } - ], - "durable_objects": { - "bindings": [ - { "name": "SPACE_SUBSCRIPTIONS", "class_name": "SpaceSubscriptionHub" } - ] - }, - "migrations": [ - { "tag": "spaces-subscriptions-v1", "new_sqlite_classes": ["SpaceSubscriptionHub"] } - ], - "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/.dev.vars.example b/apps/spaces-demo/.dev.vars.example new file mode 100644 index 0000000..b28fd55 --- /dev/null +++ b/apps/spaces-demo/.dev.vars.example @@ -0,0 +1,4 @@ +OAUTH_PUBLIC_URL=http://127.0.0.1:5173 +COOKIE_SECRET=replace-with-at-least-32-random-characters +CLIENT_ASSERTION_KEY={"kty":"EC","crv":"P-256","x":"...","y":"...","d":"..."} +SPACES_CREDENTIAL_ENCRYPTION_KEY=replace-with-32-random-bytes-as-base64 diff --git a/apps/spaces-demo/.gitignore b/apps/spaces-demo/.gitignore index ef9c1aa..769b8cb 100644 --- a/apps/spaces-demo/.gitignore +++ b/apps/spaces-demo/.gitignore @@ -3,4 +3,7 @@ node_modules .env .env.* +.dev.vars +.dev.vars.* +!.dev.vars.example !.env.example diff --git a/apps/spaces-demo/README.md b/apps/spaces-demo/README.md index 0519599..d6130f6 100644 --- a/apps/spaces-demo/README.md +++ b/apps/spaces-demo/README.md @@ -1,41 +1,44 @@ # Atmo Circle demo -Small SvelteKit Cloudflare Worker demonstrating a separately deployed Contrail Spaces provider with PDS-native membership. +An integrated SvelteKit + Contrail Spaces-alpha application with PDS-native membership. One Cloudflare Worker owns the OAuth sessions, private projection, synchronization jobs, and browser delivery path. -- 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; -- owners add or remove members by handle through `com.atproto.simplespace`; -- short provider query leases are renewed on demand against the native PDS policy; -- an authenticated provider query lists circles the viewer owns or has connected through unexpired delegation evidence; -- writes go directly to each user's permissioned PDS repo; -- reads come from `https://spaces.atmo.garden`; and -- a short-lived provider ticket opens a WebSocket for projection invalidations, with a 30-second foreground refresh fallback. +- OAuth sessions live in the application's KV namespaces through `@svelte-atproto/oauth`. +- Trusted server code passes `locals.did` directly to the in-process Spaces runtime; user-facing calls do not mint AT service-auth JWTs. +- Owners manage members through native `com.atproto.simplespace` procedures. +- Successful delegation creates a short query lease against the PDS-owned policy. +- Writes go directly to each user's permissioned PDS repo and schedule nonfatal projection synchronization in-process. +- Reads query the Worker-bound D1 projection directly. +- A short-lived ticket opens a Durable Object WebSocket for invalidations, with a 30-second foreground refresh fallback. +- Standard PDS `notifyWrite` and deletion callbacks remain service-authenticated at `spaces.atmo.garden`. -Only Spaces-compatible PDS accounts can use the alpha. Clients refetch authorized query results after an invalidation; private records never travel in WebSocket events. +Only Spaces-compatible PDS accounts can use the alpha. Browser invalidations never contain private records. -## Provision and deploy +## Local development -Create two KV namespaces and put their IDs into `wrangler.jsonc`: +Copy `.dev.vars.example` to `.dev.vars`, generate the OAuth secrets, and generate a 32-byte base64 credential-encryption key. Then run: ```sh -wrangler kv namespace create OAUTH_SESSIONS -wrangler kv namespace create OAUTH_STATES +pnpm --filter @atmo-dev/contrail-spaces-alpha build +pnpm --filter contrail-spaces-demo dev ``` -Install OAuth secrets: +Wrangler runs local KV, D1, Queue, and Durable Object bindings. Consumer writes call the integrated synchronization API, so a publicly reachable notification callback is not required. PDS notification registration is best-effort and periodic reconciliation remains the recovery path. + +## Production + +`wrangler.jsonc` binds the application to the production D1 database, Queue, Durable Object, and OAuth KV namespaces. Install these 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 +pnpm exec atproto-oauth secret | pnpm exec wrangler secret put COOKIE_SECRET +pnpm exec atproto-oauth keygen | pnpm exec wrangler secret put CLIENT_ASSERTION_KEY +openssl rand -base64 32 | pnpm exec wrangler secret put SPACES_CREDENTIAL_ENCRYPTION_KEY ``` -Deploy the provider first, then: +Deploy the integrated Worker: ```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`. +The application is served at `https://circle.atmo.garden`; `https://spaces.atmo.garden` remains the resolvable service identity for standard PDS callbacks. diff --git a/apps/spaces-demo/package.json b/apps/spaces-demo/package.json index 4fef499..2215138 100644 --- a/apps/spaces-demo/package.json +++ b/apps/spaces-demo/package.json @@ -4,14 +4,17 @@ "private": true, "type": "module", "scripts": { - "dev": "vite dev", + "dev": "pnpm build && wrangler dev --port 5173", + "dev:vite": "vite dev", "build": "vite build", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", + "publish-lexicons": "tsx scripts/publish-lexicons.ts", "typecheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "deploy": "wrangler deploy" + "deploy": "pnpm build && wrangler deploy" }, "dependencies": { + "@atmo-dev/contrail": "workspace:*", "@atmo-dev/contrail-spaces-alpha": "workspace:*", "@svelte-atproto/oauth": "^0.3.0" }, @@ -22,6 +25,7 @@ "@sveltejs/vite-plugin-svelte": "^7.2.0", "svelte": "^5.56.8", "svelte-check": "^4.7.4", + "tsx": "^4.21.0", "typescript": "^6.0.3", "vite": "^8.2.0", "wrangler": "^4.118.0" diff --git a/apps/spaces-demo-provider/scripts/publish-lexicons.ts b/apps/spaces-demo/scripts/publish-lexicons.ts similarity index 97% rename from apps/spaces-demo-provider/scripts/publish-lexicons.ts rename to apps/spaces-demo/scripts/publish-lexicons.ts index 814613f..1b3f90a 100644 --- a/apps/spaces-demo-provider/scripts/publish-lexicons.ts +++ b/apps/spaces-demo/scripts/publish-lexicons.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { networkLexicons } from "../src/lexicons"; +import { networkLexicons } from "../src/lib/lexicons"; const collection = "com.atproto.lexicon.schema"; diff --git a/apps/spaces-demo/src/app.d.ts b/apps/spaces-demo/src/app.d.ts index 68a9f33..3de9c61 100644 --- a/apps/spaces-demo/src/app.d.ts +++ b/apps/spaces-demo/src/app.d.ts @@ -1,4 +1,5 @@ import type { AuthenticatedPdsSession } from "@atmo-dev/contrail-spaces-alpha/consumer"; +import type { IntegratedSpacesRuntime } from "@atmo-dev/contrail-spaces-alpha/worker"; declare global { namespace App { @@ -13,6 +14,11 @@ declare global { OAUTH_PUBLIC_URL: string; COOKIE_SECRET: string; CLIENT_ASSERTION_KEY: string; + DB: D1Database; + SPACES_QUEUE: Queue; + SPACES_CREDENTIAL_ENCRYPTION_KEY: string; + SPACE_SUBSCRIPTIONS: DurableObjectNamespace; + SPACES_RUNTIME: IntegratedSpacesRuntime; }; context: ExecutionContext; caches: CacheStorage; diff --git a/apps/spaces-demo-provider/src/lexicons.ts b/apps/spaces-demo/src/lib/lexicons.ts similarity index 100% rename from apps/spaces-demo-provider/src/lexicons.ts rename to apps/spaces-demo/src/lib/lexicons.ts diff --git a/apps/spaces-demo/src/lib/server/auth.ts b/apps/spaces-demo/src/lib/server/auth.ts index 7de2dac..20e8c48 100644 --- a/apps/spaces-demo/src/lib/server/auth.ts +++ b/apps/spaces-demo/src/lib/server/auth.ts @@ -1,21 +1,25 @@ -import { env } from "$env/dynamic/private"; +import { env } from "cloudflare:workers"; 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 { spacesIntegratedOAuthScopes } from "@atmo-dev/contrail-spaces-alpha/consumer"; import { COLLECTIONS, - PROVIDER_AUDIENCE, SPACE_SKEY, SPACE_TYPE, } from "$lib/constants"; +const bindings = env as unknown as App.Platform["env"]; +const oauthOrigin = new URL(bindings.OAUTH_PUBLIC_URL); +const localDevelopment = oauthOrigin.hostname === "localhost" || + oauthOrigin.hostname === "127.0.0.1" || oauthOrigin.hostname === "[::1]"; + 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, + origin: localDevelopment ? undefined : oauthOrigin.href.replace(/\/$/, ""), + dev: localDevelopment, + devPort: Number(oauthOrigin.port || 5173), + cookieSecret: bindings.COOKIE_SECRET, + clientAssertionKey: bindings.CLIENT_ASSERTION_KEY, + scope: spacesIntegratedOAuthScopes({ collections: COLLECTIONS, spaceType: SPACE_TYPE, skey: SPACE_SKEY, diff --git a/apps/spaces-demo-provider/src/worker.ts b/apps/spaces-demo/src/lib/server/spaces.ts similarity index 76% rename from apps/spaces-demo-provider/src/worker.ts rename to apps/spaces-demo/src/lib/server/spaces.ts index 90e6448..6f7cf84 100644 --- a/apps/spaces-demo-provider/src/worker.ts +++ b/apps/spaces-demo/src/lib/server/spaces.ts @@ -1,6 +1,5 @@ import type { ContrailConfig } from "@atmo-dev/contrail"; import { createSpacesWorker } from "@atmo-dev/contrail-spaces-alpha/worker"; -export { SpaceSubscriptionHub } from "@atmo-dev/contrail-spaces-alpha/worker"; import { NOTE_COLLECTION, PROVIDER_AUDIENCE, @@ -8,15 +7,16 @@ import { REACTION_COLLECTION, SPACE_SKEY, SPACE_TYPE, -} from "./constants"; -import { recordLexicons } from "./lexicons"; +} from "$lib/constants"; +import { recordLexicons } from "$lib/lexicons"; -interface Env { +export interface SpacesDemoEnv { [key: string]: unknown; DB: D1Database; SPACES_QUEUE: Queue; SPACES_CREDENTIAL_ENCRYPTION_KEY: string; SPACE_SUBSCRIPTIONS: DurableObjectNamespace; + OAUTH_PUBLIC_URL?: string; } const projection: ContrailConfig = { @@ -48,7 +48,7 @@ const projection: ContrailConfig = { constellation: false, }; -export default createSpacesWorker({ +export const spaces = createSpacesWorker({ projection, lexicons: recordLexicons, service: { @@ -64,5 +64,11 @@ export default createSpacesWorker({ }, subscriptions: { binding: "SPACE_SUBSCRIPTIONS" }, accessLeaseMs: 2 * 60_000, + notificationRegistration: (env) => { + const hostname = new URL(env.OAUTH_PUBLIC_URL ?? PROVIDER_ENDPOINT).hostname; + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" + ? "disabled" + : "best-effort"; + }, reconcileIntervalMs: 5 * 60_000, }); diff --git a/apps/spaces-demo/src/routes/+page.server.ts b/apps/spaces-demo/src/routes/+page.server.ts index c511828..26bf58e 100644 --- a/apps/spaces-demo/src/routes/+page.server.ts +++ b/apps/spaces-demo/src/routes/+page.server.ts @@ -1,17 +1,15 @@ import { fail, redirect } from "@sveltejs/kit"; import { - SpacesProviderClient, addSimpleSpaceMember, createSpace, createSpaceRecord, formatSpaceUri, + getDelegationToken, listSimpleSpaceMembers, removeSimpleSpaceMember, } from "@atmo-dev/contrail-spaces-alpha/consumer"; import { NOTE_COLLECTION, - PROVIDER_AUDIENCE, - PROVIDER_ENDPOINT, REACTION_COLLECTION, SPACE_SKEY, SPACE_TYPE, @@ -32,20 +30,28 @@ interface NoteRecord { interface CircleMember { did: string; - handle?: 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 spacesRuntime(platform: App.Platform | undefined) { + if (!platform) throw new Error("The integrated Spaces runtime requires a platform"); + return platform.env.SPACES_RUNTIME; +} + +function notifySpaceWrite( + platform: App.Platform | undefined, + userDid: string, + space: string, +): void { + if (!platform) throw new Error("The integrated Spaces runtime requires a platform"); + platform.context.waitUntil( + platform.env.SPACES_RUNTIME + .syncSpace({ userDid, space, repo: userDid }) + .catch((error) => console.warn("Could not notify the Spaces projection", error)), + ); } function signedIn(locals: App.Locals) { @@ -62,7 +68,7 @@ function ownerFrom(form: FormData, fallback: string): string { return validDid(owner) ? owner : fallback; } -async function resolveMember(value: unknown): Promise<{ did: string; handle?: string }> { +async function resolveMember(value: unknown): Promise<{ did: string }> { if (validDid(value)) return { did: value }; const handle = typeof value === "string" ? value.trim().replace(/^@/, "").toLowerCase() @@ -81,24 +87,7 @@ async function resolveMember(value: unknown): Promise<{ did: string; handle?: st if (!response.ok) throw new Error(`Could not resolve @${handle}`); const result = await response.json() as { did?: unknown }; if (!validDid(result.did)) throw new Error(`Could not resolve @${handle}`); - return { did: result.did, handle }; -} - -async function profileHandle(did: string): Promise { - const url = new URL("/xrpc/app.bsky.actor.getProfile", "https://public.api.bsky.app"); - url.searchParams.set("actor", did); - try { - const response = await fetch(url, { - headers: { accept: "application/json" }, - redirect: "manual", - signal: AbortSignal.timeout(5_000), - }); - if (!response.ok) return undefined; - const profile = await response.json() as { handle?: unknown }; - return typeof profile.handle === "string" ? profile.handle : undefined; - } catch { - return undefined; - } + return { did: result.did }; } async function nativeMembers( @@ -112,14 +101,10 @@ async function nativeMembers( dids.push(...page.members.map((member) => member.did)); cursor = page.cursor; } while (cursor && dids.length < 1_000); - const members = await Promise.all(dids.map(async (did) => ({ - did, - handle: await profileHandle(did), - }))); - return members; + return dids.map((did) => ({ did })); } -export const load: PageServerLoad = async ({ locals, url }) => { +export const load: PageServerLoad = async ({ locals, platform, url }) => { if (!locals.session || !locals.did) { return { signedIn: false as const, @@ -133,15 +118,19 @@ export const load: PageServerLoad = async ({ locals, url }) => { const ownerParam = url.searchParams.get("owner"); const owner = validDid(ownerParam) ? ownerParam : locals.did; const space = circleUri(owner); - const client = provider(locals.session); - const available = await client.listSpaces({ limit: 200 }).catch(() => ({ + const runtime = spacesRuntime(platform); + const availablePromise = runtime.listSpaces({ + userDid: locals.did, + limit: 200, + }).catch(() => ({ spaces: [] as Array<{ uri: string; authorityDid: string; type: string }>, truncated: false, })); - const members = owner === locals.did - ? await nativeMembers(locals.session, space).catch(() => [] as CircleMember[]) - : [] as CircleMember[]; - const queryNotes = () => client.listSpaceRecords({ + const membersPromise = owner === locals.did + ? nativeMembers(locals.session, space).catch(() => [] as CircleMember[]) + : Promise.resolve([] as CircleMember[]); + const queryNotes = () => runtime.listSpaceRecords({ + userDid: locals.did!, space, collection: NOTE_COLLECTION, limit: 100, @@ -152,11 +141,16 @@ export const load: PageServerLoad = async ({ locals, url }) => { try { notes = await queryNotes(); } catch { - // Native PDS policy remains authoritative. Renew the short provider + // Native PDS policy remains authoritative. Renew the short projection // lease only when cached access expires; removed members fail here. - await client.authorizeSpace(space); + await runtime.authorizeSpace({ + userDid: locals.did, + space, + delegation: await getDelegationToken(locals.session, space), + }); notes = await queryNotes(); } + const [available, members] = await Promise.all([availablePromise, membersPromise]); return { signedIn: true as const, owner, @@ -169,6 +163,7 @@ export const load: PageServerLoad = async ({ locals, url }) => { needsAuthorization: false, }; } catch (error) { + const [available, members] = await Promise.all([availablePromise, membersPromise]); return { signedIn: true as const, owner, @@ -185,7 +180,7 @@ export const load: PageServerLoad = async ({ locals, url }) => { }; export const actions: Actions = { - async create({ locals }) { + async create({ locals, platform }) { const { session, did } = signedIn(locals); try { const created = await createSpace(session, { @@ -193,7 +188,11 @@ export const actions: Actions = { skey: SPACE_SKEY, policy: { kind: "member-list" }, }); - await provider(session).authorizeSpace(created.uri); + await spacesRuntime(platform).authorizeSpace({ + userDid: did, + space: created.uri, + delegation: await getDelegationToken(session, created.uri), + }); } catch (error) { return fail(400, { action: "create", @@ -203,12 +202,17 @@ export const actions: Actions = { throw redirect(303, `/?owner=${encodeURIComponent(did)}`); }, - async authorize({ request, locals }) { + async authorize({ request, locals, platform }) { const { session, did } = signedIn(locals); const form = await request.formData(); const owner = ownerFrom(form, did); try { - await provider(session).authorizeSpace(circleUri(owner)); + const space = circleUri(owner); + await spacesRuntime(platform).authorizeSpace({ + userDid: did, + space, + delegation: await getDelegationToken(session, space), + }); } catch (error) { return fail(403, { action: "authorize", @@ -264,7 +268,7 @@ export const actions: Actions = { throw redirect(303, `/?owner=${encodeURIComponent(owner)}`); }, - async post({ request, locals }) { + async post({ request, locals, platform }) { const { session, did } = signedIn(locals); const form = await request.formData(); const owner = ownerFrom(form, did); @@ -289,7 +293,7 @@ export const actions: Actions = { : {}), }, }); - await provider(session).syncSpace(space, did); + notifySpaceWrite(platform, did, space); } catch (error) { return fail(400, { action: "post", @@ -299,7 +303,7 @@ export const actions: Actions = { throw redirect(303, `/?owner=${encodeURIComponent(owner)}`); }, - async react({ request, locals }) { + async react({ request, locals, platform }) { const { session, did } = signedIn(locals); const form = await request.formData(); const owner = ownerFrom(form, did); @@ -317,7 +321,7 @@ export const actions: Actions = { createdAt: new Date().toISOString(), }, }); - await provider(session).syncSpace(space, did); + notifySpaceWrite(platform, did, space); } catch (error) { return fail(400, { action: "react", diff --git a/apps/spaces-demo/src/routes/+page.svelte b/apps/spaces-demo/src/routes/+page.svelte index c3e49c2..a22159d 100644 --- a/apps/spaces-demo/src/routes/+page.svelte +++ b/apps/spaces-demo/src/routes/+page.svelte @@ -112,7 +112,11 @@ headers: { "content-type": "application/json" }, body: JSON.stringify({ handle: loginHandle }) }); - const body = await response.json(); + const body = await response.json() as { + url?: string; + message?: string; + error?: string; + }; if (!response.ok || !body.url) throw new Error(body.message ?? body.error ?? "Login failed"); window.location.href = body.url; } catch (error) { @@ -210,7 +214,7 @@

Connect this circle

-

Ask the circle owner to add your handle to the PDS-native member list. The PDS must authorize your delegation before the provider will serve this circle.

+

Ask the circle owner to add your handle to the PDS-native member list. The PDS must authorize your delegation before this application will serve the circle.

{#if data.error}
{data.error}
{/if}
@@ -227,8 +231,7 @@ {#each data.members as member (member.did)}
- {member.handle ? `@${member.handle}` : member.did} - {#if member.handle}{member.did}{/if} + {member.did}
@@ -281,7 +284,7 @@
{:else} -
No synchronized notes yet. New writes may take a few seconds to arrive through the provider queue.
+
No synchronized notes yet. New writes may take a moment to arrive through the projection queue.
{/each} {/if} diff --git a/apps/spaces-demo/src/routes/api/space-subscription/+server.ts b/apps/spaces-demo/src/routes/api/space-subscription/+server.ts index 6ad8bca..197976e 100644 --- a/apps/spaces-demo/src/routes/api/space-subscription/+server.ts +++ b/apps/spaces-demo/src/routes/api/space-subscription/+server.ts @@ -1,21 +1,16 @@ import { json } from "@sveltejs/kit"; import { - SpacesProviderClient, formatSpaceUri, + getDelegationToken, } from "@atmo-dev/contrail-spaces-alpha/consumer"; -import { - PROVIDER_AUDIENCE, - PROVIDER_ENDPOINT, - SPACE_SKEY, - SPACE_TYPE, -} from "$lib/constants"; +import { SPACE_SKEY, SPACE_TYPE } from "$lib/constants"; import type { RequestHandler } from "./$types"; function validDid(value: unknown): value is string { return typeof value === "string" && /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/.test(value); } -export const POST: RequestHandler = async ({ locals, request }) => { +export const POST: RequestHandler = async ({ locals, platform, request, url }) => { if (!locals.session || !locals.did) { return json({ error: "Unauthorized" }, { status: 401 }); } @@ -29,18 +24,26 @@ export const POST: RequestHandler = async ({ locals, request }) => { skey: SPACE_SKEY, }); try { - const client = new SpacesProviderClient({ - endpoint: PROVIDER_ENDPOINT, - audience: PROVIDER_AUDIENCE, - namespace: SPACE_TYPE, - session: locals.session, - }); + if (!platform) throw new Error("The integrated Spaces runtime requires a platform"); + const runtime = platform.env.SPACES_RUNTIME; let subscription; try { - subscription = await client.subscribeSpace(space); + subscription = await runtime.subscribeSpace({ + userDid: locals.did, + space, + endpoint: url.origin, + }); } catch { - await client.authorizeSpace(space); - subscription = await client.subscribeSpace(space); + await runtime.authorizeSpace({ + userDid: locals.did, + space, + delegation: await getDelegationToken(locals.session, space), + }); + subscription = await runtime.subscribeSpace({ + userDid: locals.did, + space, + endpoint: url.origin, + }); } return json(subscription, { headers: { "cache-control": "private, no-store" }, diff --git a/apps/spaces-demo/svelte.config.js b/apps/spaces-demo/svelte.config.js index 4bc6a09..ef73561 100644 --- a/apps/spaces-demo/svelte.config.js +++ b/apps/spaces-demo/svelte.config.js @@ -3,5 +3,5 @@ import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; export default { preprocess: vitePreprocess(), - kit: { adapter: adapter() }, + kit: { adapter: adapter({ config: "wrangler.svelte.jsonc" }) }, }; diff --git a/apps/spaces-demo/tsconfig.json b/apps/spaces-demo/tsconfig.json index 4344710..bedf8d5 100644 --- a/apps/spaces-demo/tsconfig.json +++ b/apps/spaces-demo/tsconfig.json @@ -9,6 +9,7 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types"] } } diff --git a/apps/spaces-demo/vite.config.ts b/apps/spaces-demo/vite.config.ts index 7fd75b9..f61fcd8 100644 --- a/apps/spaces-demo/vite.config.ts +++ b/apps/spaces-demo/vite.config.ts @@ -1,4 +1,7 @@ import { sveltekit } from "@sveltejs/kit/vite"; import { defineConfig } from "vite"; -export default defineConfig({ plugins: [sveltekit()] }); +export default defineConfig({ + plugins: [sveltekit()], + build: { rolldownOptions: { external: ["cloudflare:workers"] } }, +}); diff --git a/apps/spaces-demo/worker.ts b/apps/spaces-demo/worker.ts new file mode 100644 index 0000000..dc40d48 --- /dev/null +++ b/apps/spaces-demo/worker.ts @@ -0,0 +1,37 @@ +// Generated by `vite build` before Wrangler bundles this entrypoint. +// @ts-expect-error SvelteKit's Cloudflare adapter writes this module at build time. +import app from "./.svelte-kit/cloudflare/_worker.js"; +import { SpaceSubscriptionHub as BaseSpaceSubscriptionHub } from "@atmo-dev/contrail-spaces-alpha/worker"; +import { spaces, type SpacesDemoEnv } from "./src/lib/server/spaces"; + +export class SpaceSubscriptionHub extends BaseSpaceSubscriptionHub {} + +function isSpacesProtocolRequest(request: Request): boolean { + const url = new URL(request.url); + if (url.pathname === "/subscribe") return true; + if ( + url.pathname === "/.well-known/did.json" || + url.pathname === "/.well-known/contrail-spaces-alpha" || + url.pathname === "/lexicons" + ) return true; + return url.pathname === "/xrpc/com.atproto.space.notifyWrite" || + url.pathname === "/xrpc/com.atproto.space.notifySpaceDeleted"; +} + +export default { + fetch(request: Request, env: SpacesDemoEnv, ctx: ExecutionContext) { + if (isSpacesProtocolRequest(request)) { + return spaces.fetch!(request, env, ctx); + } + const integratedEnv = Object.assign(Object.create(env), { + SPACES_RUNTIME: spaces.integrated(env, ctx), + }); + return app.fetch(request, integratedEnv, ctx); + }, + queue(batch, env, ctx) { + return spaces.queue!(batch, env, ctx); + }, + scheduled(controller, env, ctx) { + return spaces.scheduled!(controller, env, ctx); + }, +} satisfies ExportedHandler; diff --git a/apps/spaces-demo/wrangler.jsonc b/apps/spaces-demo/wrangler.jsonc index bb8a1ed..8848bd0 100644 --- a/apps/spaces-demo/wrangler.jsonc +++ b/apps/spaces-demo/wrangler.jsonc @@ -1,7 +1,7 @@ { "$schema": "node_modules/wrangler/config-schema.json", - "name": "contrail-spaces-demo", - "main": ".svelte-kit/cloudflare/_worker.js", + "name": "contrail-spaces-demo-provider", + "main": "worker.ts", "compatibility_date": "2026-08-04", "compatibility_flags": ["nodejs_compat"], "assets": { @@ -10,13 +10,38 @@ }, "observability": { "enabled": true }, "routes": [ - { "pattern": "circle.atmo.garden", "custom_domain": true } + { "pattern": "circle.atmo.garden", "custom_domain": true }, + { "pattern": "spaces.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" } - ] + { "binding": "OAUTH_SESSIONS", "id": "d17f1d2f045f47259824805cfef9b508" }, + { "binding": "OAUTH_STATES", "id": "a13d034179504723a5ff54b028c20109" } + ], + "d1_databases": [ + { + "binding": "DB", + "database_name": "contrail-spaces-demo", + "database_id": "4d0d01ba-8164-4fba-8526-472de88c5f19" + } + ], + "durable_objects": { + "bindings": [ + { "name": "SPACE_SUBSCRIPTIONS", "class_name": "SpaceSubscriptionHub" } + ] + }, + "migrations": [ + { "tag": "spaces-subscriptions-v1", "new_sqlite_classes": ["SpaceSubscriptionHub"] } + ], + "queues": { + "producers": [ + { "binding": "SPACES_QUEUE", "queue": "contrail-spaces-demo" } + ], + "consumers": [ + { "queue": "contrail-spaces-demo", "max_batch_size": 10, "max_batch_timeout": 1 } + ] + }, + "triggers": { "crons": ["*/5 * * * *"] } } diff --git a/apps/spaces-demo/wrangler.svelte.jsonc b/apps/spaces-demo/wrangler.svelte.jsonc new file mode 100644 index 0000000..4ea4230 --- /dev/null +++ b/apps/spaces-demo/wrangler.svelte.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "contrail-spaces-demo-svelte-build", + "main": ".svelte-kit/cloudflare/_worker.js", + "compatibility_date": "2026-08-04", + "compatibility_flags": ["nodejs_compat"], + "assets": { + "binding": "ASSETS", + "directory": ".svelte-kit/cloudflare" + } +} diff --git a/docs/experimental-spaces-alpha.md b/docs/experimental-spaces-alpha.md index 27ac4e1..e5b751a 100644 --- a/docs/experimental-spaces-alpha.md +++ b/docs/experimental-spaces-alpha.md @@ -4,10 +4,10 @@ Contrail's first Spaces-alpha implementation lives in the optional, unpublished 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. +1. a DPoP-bound Space credential allows the runtime to synchronize one Space; and +2. the integrated application's authenticated session principal plus exact-Space authorization allows a caller to query its cached projection. -A successful sync credential is never treated as caller authorization. +A successful sync credential is never treated as caller authorization. An optional standalone adapter can still replace the trusted application session with exact method-bound AT service JWTs. ## Storage isolation @@ -19,8 +19,8 @@ A Space incarnation maps to one opaque projection scope. Each writer repo is sta `createSpacesWorker` composes: -- authenticated delegation authorization; -- exact-Space private collection routes; +- trusted in-process delegation authorization and private queries; +- optional exact-service-authenticated standalone routes; - native public/member-list policy validation and optional managing-app access checks; - signed write/deletion notifications; - Queue consumption; @@ -28,12 +28,12 @@ A Space incarnation maps to one opaque projection scope. Each writer repo is sta - renewable, owner-fenced per-repo leases; and - optional hibernating Durable Object WebSocket invalidations. -Consumer-triggered synchronization always reconciles the authority's complete `listRepos` result; only service-authenticated authority notifications use the targeted writer path. Public and member-list policies remain PDS-owned: successful delegation exchange creates a short-lived provider access lease, while managing-app policies use the configured application callback. `listSpaces` reports owned or currently connected provider watches; the alpha protocol has no inverse global Space-membership query. Rediscovery is accepted only for a watch already hidden by a verified deletion, so an ordinary read-authorized caller cannot rotate an active generation. +Integrated application synchronization always reconciles the authority's complete `listRepos` result; only service-authenticated authority notifications use the targeted writer path. Public and member-list policies remain PDS-owned: successful delegation exchange creates a short-lived provider access lease, while managing-app policies use the configured application callback. `listSpaces` reports owned or currently connected provider watches; the alpha protocol has no inverse global Space-membership query. Rediscovery is accepted only for a watch already hidden by a verified deletion, so an ordinary read-authorized caller cannot rotate an active generation. -PDS 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. After the verified projection commit, an optional per-Space Durable Object broadcasts an invalidation—not record contents—to browsers holding a short-lived, one-time ticket issued through an authorized exact-method XRPC call. CAR bodies are size-limited while streaming, including responses without `Content-Length`. Relation-count columns use additive initialization migrations when projection configuration evolves. +PDS 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. After the verified projection commit, an optional per-Space Durable Object broadcasts an invalidation—not record contents—to browsers holding a short-lived, one-time ticket issued through the authenticated application session. 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. +See `apps/spaces-demo` for the integrated deployment. diff --git a/packages/contrail-spaces-alpha/README.md b/packages/contrail-spaces-alpha/README.md index 81aeb1d..05788e7 100644 --- a/packages/contrail-spaces-alpha/README.md +++ b/packages/contrail-spaces-alpha/README.md @@ -12,20 +12,20 @@ Pinned compatibility tuple: ## What it provides - delegation-token to DPoP-bound Space credential exchange; -- encrypted, expiring provider credential storage; +- encrypted, expiring Space 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; +- exact method-bound service authentication for PDS callbacks and optional standalone APIs; - delegation access leases or an authoritative application callback; - explicit support for public, native member-list, and managing-app user policies; - paginated discovery of owned or currently connected Spaces; - exact-Space list/get/search/reference/relation-count queries over Contrail's isolated projection seam; and - optional one-time-ticket, hibernating Durable Object WebSocket invalidations. -Public Contrail tables and anonymous methods are unchanged. Installing the package does not create isolated tables; `createSpacesWorker` initializes them only in a provider deployment. +Public Contrail tables and anonymous methods are unchanged. Installing the package does not create isolated tables; `createSpacesWorker` initializes them only inside a configured application or standalone deployment. ## Entry points @@ -39,7 +39,7 @@ import { } 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. +The primary deployment is integrated: the application owns OAuth refresh tokens and passes its trusted session principal to `handler.integrated(env, ctx)`. Contrail receives only one-time delegation evidence, stores an encrypted short-lived Space credential, verifies permissioned repos, and serves private projections from the same process. Set `standaloneUserApi: true` only when an independent exact-service-authenticated provider is deliberately required. ## Provider requirements @@ -64,4 +64,4 @@ Credentials and DPoP private keys are AES-256-GCM encrypted with Space-generatio - 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. +See [`apps/spaces-demo`](../../apps/spaces-demo) for the complete integrated Worker + SvelteKit example. diff --git a/packages/contrail-spaces-alpha/src/consumer.ts b/packages/contrail-spaces-alpha/src/consumer.ts index 640a2d3..17d0b68 100644 --- a/packages/contrail-spaces-alpha/src/consumer.ts +++ b/packages/contrail-spaces-alpha/src/consumer.ts @@ -35,12 +35,29 @@ export function formatSpacePermissionScope(input: SpacePermissionScopeInput): st return `space:${input.type}?${params.toString()}`; } -export function spacesConsumerOAuthScopes(input: { - audience: string; - namespace: string; +export interface SpacesOAuthScopeInput { collections: readonly string[]; spaceType: string; skey?: string; +} + +/** OAuth scopes for an integrated application. User-facing Contrail calls are + * authenticated by the application's own session rather than AT service JWTs. */ +export function spacesIntegratedOAuthScopes(input: SpacesOAuthScopeInput): string[] { + return [ + "atproto", + formatSpacePermissionScope({ + type: input.spaceType, + skey: input.skey, + collections: input.collections, + }), + ]; +} + +/** OAuth scopes for the optional standalone service-auth adapter. */ +export function spacesConsumerOAuthScopes(input: SpacesOAuthScopeInput & { + audience: string; + namespace: string; }): string[] { const methods = [ `${input.namespace}.authorizeSpace`, @@ -56,12 +73,7 @@ export function spacesConsumerOAuthScopes(input: { }), ]; return [ - "atproto", - formatSpacePermissionScope({ - type: input.spaceType, - skey: input.skey, - collections: input.collections, - }), + ...spacesIntegratedOAuthScopes(input), formatServiceOAuthScope(input.audience as never, methods), ]; } diff --git a/packages/contrail-spaces-alpha/src/sync.ts b/packages/contrail-spaces-alpha/src/sync.ts index ea49d89..6bfe45c 100644 --- a/packages/contrail-spaces-alpha/src/sync.ts +++ b/packages/contrail-spaces-alpha/src/sync.ts @@ -65,6 +65,7 @@ export interface SpacesSyncConfig { serviceAudience: string; credentialEncryptionKey: string; reconcileIntervalMs?: number; + notificationRegistration?: "required" | "best-effort" | "disabled"; registrationRenewalWindowMs?: number; /** Maximum mutations admitted with one verified incremental checkpoint. * Larger commits recover through a staged full CAR instead (default 10). */ @@ -215,19 +216,28 @@ export class SpacesSyncEngine { const transport = await this.transport(watch); await this.validateWatch(watch, transport); const authorityPds = await this.identities.resolvePds(watch.authorityDid); + const registrationMode = this.config.notificationRegistration ?? "best-effort"; const renewalWindow = this.config.registrationRenewalWindowMs ?? 60 * 60_000; - if ( + if (registrationMode !== "disabled" && ( !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, - }); + )) { + try { + 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, + }); + } catch (error) { + if (registrationMode === "required") throw error; + this.logger.warn( + "[spaces] notification registration failed; reconciliation continues", + error, + ); + } } const advertised = new Map(); diff --git a/packages/contrail-spaces-alpha/src/worker.ts b/packages/contrail-spaces-alpha/src/worker.ts index bb6f0f9..7688200 100644 --- a/packages/contrail-spaces-alpha/src/worker.ts +++ b/packages/contrail-spaces-alpha/src/worker.ts @@ -51,6 +51,67 @@ export interface SpacesWorkerEnv { [key: string]: unknown; } +export interface IntegratedSpacesRuntime { + authorizeSpace(input: { + userDid: string; + space: string; + delegation: string; + rediscover?: boolean; + }): Promise<{ space: string; generation: number; accessExpiresAt: string }>; + syncSpace(input: { + userDid: string; + space: string; + repo?: string; + }): Promise<{ queued: boolean }>; + listSpaces(input: { + userDid: string; + cursor?: string; + limit?: number; + }): Promise<{ + spaces: Array<{ uri: string; authorityDid: string; type: string }>; + cursor?: string; + truncated: boolean; + }>; + subscribeSpace(input: { + userDid: string; + space: string; + /** Public application origin used for the browser WebSocket. */ + endpoint?: string; + }): Promise<{ url: string; expiresAt: string }>; + listSpaceRecords>(input: { + userDid: string; + space: string; + collection: string; + limit?: number; + cursor?: string; + search?: string; + did?: string; + filters?: Record; + rangeFilters?: Record; + }): Promise<{ + records: T[]; + cursor?: string; + references: Record>; + }>; + getSpaceRecord>(input: { + userDid: string; + space: string; + collection: string; + uri: string; + }): Promise<{ + record: T; + references: Record>; + }>; +} + +export interface SpacesWorkerHandler< + Env extends SpacesWorkerEnv = SpacesWorkerEnv, +> extends ExportedHandler { + /** Trusted in-process API for an application that already authenticated the + * user. This avoids minting service-auth JWTs for calls inside one Worker. */ + integrated(env: Env, ctx: ExecutionContext): IntegratedSpacesRuntime; +} + export interface SpacesWorkerOptions { projection: ContrailConfig; lexicons?: readonly object[]; @@ -61,6 +122,9 @@ export interface SpacesWorkerOptions["resolver"]; }; + /** Expose user-facing query/sync methods through exact AT service auth. + * Integrated applications should use `handler.integrated(...)` instead. */ + standaloneUserApi?: boolean; bindings?: { database?: string; credentialEncryptionKey?: string; @@ -80,6 +144,13 @@ export interface SpacesWorkerOptions "required" | "best-effort" | "disabled"); maxAtomicMutations?: number; maxRepoCarBytes?: number; protocol?: { @@ -265,7 +336,21 @@ async function boundedJsonBody(request: Request, maximum = 64 * 1024): Promis } } +class SpacesRuntimeError extends Error { + constructor( + readonly code: string, + readonly status: number, + message = code, + ) { + super(message); + this.name = "SpacesRuntimeError"; + } +} + function errorResponse(error: unknown): Response { + if (error instanceof SpacesRuntimeError) { + return privateJson({ error: error.code, message: error.message }, error.status); + } if (error instanceof TypeError) { return json({ error: "InvalidRequest", message: error.message }, 400); } @@ -496,7 +581,7 @@ function stringBinding(env: Env, binding: string): export function createSpacesWorker( options: SpacesWorkerOptions, -): ExportedHandler { +): SpacesWorkerHandler { const projection = resolveConfig(options.projection); validateConfig(projection); if (options.accessLeaseMs !== undefined && @@ -561,21 +646,26 @@ export function createSpacesWorker [ + collectionMethod(namespace, short, LIST_SPACE_RECORDS_METHOD), + collectionMethod(namespace, short, GET_SPACE_RECORD_METHOD), + ]), + ] : []), "com.atproto.space.notifyWrite", "com.atproto.space.notifySpaceDeleted", ...(managingAppEnabled ? ["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, @@ -632,6 +722,9 @@ export function createSpacesWorker { + const { db, init, engine } = runtime(env); + const ready = async () => { + await init; + return { db, engine }; + }; + const activeWatch = async (userDid: string, space: string, method: string) => { + const parsed = parseSpaceUri(space); + const watch = await getSpaceWatch(db, parsed.uri); + if (!watch || watch.status !== "active") { + throw new SpacesRuntimeError("SpaceUnavailable", 404, "Space unavailable"); + } + if (!await accessAllowed(env, db, watch, { + userDid, + spaceUri: parsed.uri, + action: "read", + method, + })) throw new SpacesRuntimeError("Forbidden", 403, "Space access denied"); + return { parsed, watch }; + }; + const references = (rows: Record | undefined) => + Object.fromEntries(Object.entries(rows ?? {}).map(([uri, row]) => [ + uri, + parsedRecord(row as Record), + ])); + + return { + async authorizeSpace(input) { + await ready(); + if (!validDid(input.userDid)) throw new TypeError("invalid user DID"); + const parsed = parseSpaceUri(input.space); + if (!options.spaceTypes[parsed.type]) { + throw new SpacesRuntimeError("UnsupportedSpace", 400, "Unsupported Space"); + } + if (!input.delegation) throw new TypeError("delegation is required"); + const claims = decodeJwtClaims(input.delegation); + const delegationDid = typeof claims.iss === "string" + ? baseDid(claims.iss) + : typeof claims.sub === "string" + ? baseDid(claims.sub) + : null; + if (delegationDid !== input.userDid) { + throw new SpacesRuntimeError("DelegationIssuerMismatch", 403, "Delegation issuer mismatch"); + } + if (typeof claims.sub === "string" && claims.sub !== parsed.uri) { + throw new SpacesRuntimeError("DelegationSpaceMismatch", 403, "Delegation Space mismatch"); + } + if (typeof claims.exp === "number" && claims.exp * 1000 <= Date.now()) { + throw new SpacesRuntimeError("DelegationExpired", 401, "Delegation expired"); + } + const existingWatch = await getSpaceWatch(db, parsed.uri); + if (existingWatch?.status === "hidden" && input.rediscover !== true) { + throw new SpacesRuntimeError("RediscoveryRequired", 409, "Explicit rediscovery is required"); + } + if (input.rediscover === true && existingWatch?.status !== "hidden") { + throw new SpacesRuntimeError( + "RediscoveryNotAllowed", + 409, + "Rediscovery is not allowed for this Space", + ); + } + const authorityPds = await engine.identities.resolvePds(parsed.authorityDid); + const transport = await exchangeSpaceCredential({ + authorityPds, + delegationToken: input.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 = input.rediscover === true + ? await rediscoverSpace(db, parsed.uri) + : await ensureSpaceWatch(db, { spaceUri: parsed.uri }); + await saveCredential(db, { + spaceUri: parsed.uri, + generation: watch.generation, + viewerDid: input.userDid, + credential: transport.serialize(), + encryptionKey: stringBinding(env, encryptionBinding), + }); + const leaseExpiry = Math.min( + transport.expiresAt, + Date.now() + (options.accessLeaseMs ?? 15 * 60_000), + ); + await saveAccessLease(db, { + userDid: input.userDid, + 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 { + space: parsed.uri, + generation: watch.generation, + accessExpiresAt: new Date(leaseExpiry).toISOString(), + }; + }, + + async syncSpace(input) { + await ready(); + const method = `${namespace}.${SYNC_SPACE_METHOD}`; + const { parsed } = await activeWatch(input.userDid, input.space, method); + if (input.repo !== undefined && !validDid(input.repo)) { + throw new TypeError("invalid repo DID"); + } + const preferredRepo = validDid(input.repo) ? input.repo : undefined; + await enqueue( + env, + ctx, + { kind: "reconcile", space: parsed.uri, preferredRepo }, + () => engine.reconcileSpace(parsed.uri, { preferredRepo }), + ); + return { queued: true }; + }, + + async listSpaces(input) { + await ready(); + const limit = input.limit ?? 100; + if (!Number.isInteger(limit) || limit < 1 || limit > 200 || + (input.cursor?.length ?? 0) > 2_048) { + throw new TypeError("invalid list options"); + } + const indexed = await listConnectedSpaceWatches(db, { + userDid: input.userDid, + cursor: input.cursor, + limit: limit + 1, + }); + const watches = new Map(indexed.map((watch) => [watch.spaceUri, watch])); + let customTruncated = false; + if (options.authorization?.listSpaces) { + const listed = await options.authorization.listSpaces(input.userDid, { env, db }); + customTruncated = listed.length > 1_000; + for (const spaceUri of listed.slice(0, 1_000)) { + if (typeof spaceUri !== "string" || spaceUri <= (input.cursor ?? "")) continue; + try { + parseSpaceUri(spaceUri); + } catch { + continue; + } + const watch = await getSpaceWatch(db, spaceUri); + if (!watch || watch.status !== "active") continue; + if (await accessAllowed(env, db, watch, { + userDid: input.userDid, + spaceUri, + action: "read", + method: `${namespace}.${LIST_SPACES_METHOD}`, + })) watches.set(spaceUri, watch); + } + } + const ordered = [...watches.values()].sort((left, right) => + left.spaceUri.localeCompare(right.spaceUri)); + const truncated = customTruncated || ordered.length > limit; + const page = ordered.slice(0, limit); + return { + spaces: page.map((watch) => ({ + uri: watch.spaceUri, + authorityDid: watch.authorityDid, + type: watch.spaceType, + })), + truncated, + ...(truncated && page.length ? { cursor: page.at(-1)!.spaceUri } : {}), + }; + }, + + async subscribeSpace(input) { + await ready(); + if (!subscriptionsEnabled) { + throw new SpacesRuntimeError("SubscriptionsDisabled", 404); + } + const method = `${namespace}.${SUBSCRIBE_SPACE_METHOD}`; + const { parsed } = await activeWatch(input.userDid, input.space, method); + const ticket = crypto.randomUUID().replaceAll("-", ""); + const expiresAt = Date.now() + subscriptionTicketTtlMs; + const issued = await subscriptionHub(env, parsed.uri).fetch( + new Request("https://subscriptions.internal/issue", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ticket, expiresAt }), + }), + ); + if (!issued.ok) { + throw new SpacesRuntimeError("SubscriptionUnavailable", 503); + } + const socketUrl = new URL("/subscribe", input.endpoint ?? options.service.endpoint); + socketUrl.protocol = socketUrl.protocol === "http:" ? "ws:" : "wss:"; + socketUrl.searchParams.set("space", parsed.uri); + socketUrl.searchParams.set("ticket", ticket); + return { url: socketUrl.href, expiresAt: new Date(expiresAt).toISOString() }; + }, + + async listSpaceRecords>(input: { + userDid: string; + space: string; + collection: string; + limit?: number; + cursor?: string; + search?: string; + did?: string; + filters?: Record; + rangeFilters?: Record; + }) { + await ready(); + const entry = Object.entries(projection.collections).find( + ([, candidate]) => candidate.collection === input.collection, + ); + if (!entry) throw new TypeError("unknown collection"); + const [short] = entry; + const method = collectionMethod(namespace, short, LIST_SPACE_RECORDS_METHOD); + const { parsed, watch } = await activeWatch(input.userDid, input.space, method); + const limit = input.limit ?? 50; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) { + throw new TypeError("invalid limit"); + } + const result = await queryIsolatedRecords(db, projection, { + scope: { + kind: "isolated", + key: spaceProjectionKey(parsed.uri, watch.generation), + }, + collection: short, + ...(input.did ? { did: input.did } : {}), + limit, + cursor: input.cursor, + search: input.search, + filters: input.filters ?? {}, + rangeFilters: input.rangeFilters ?? {}, + }); + return { + records: result.records.map((row) => + parsedRecord(row as unknown as Record)) as T[], + ...(result.cursor ? { cursor: result.cursor } : {}), + references: references(result.references as unknown as Record), + }; + }, + + async getSpaceRecord>(input: { + userDid: string; + space: string; + collection: string; + uri: string; + }) { + await ready(); + const entry = Object.entries(projection.collections).find( + ([, candidate]) => candidate.collection === input.collection, + ); + if (!entry) throw new TypeError("unknown collection"); + const [short] = entry; + const method = collectionMethod(namespace, short, GET_SPACE_RECORD_METHOD); + const { parsed, watch } = await activeWatch(input.userDid, input.space, method); + const result = await queryIsolatedRecords(db, projection, { + scope: { + kind: "isolated", + key: spaceProjectionKey(parsed.uri, watch.generation), + }, + collection: short, + uri: input.uri, + limit: 1, + }); + const record = result.records[0]; + if (!record) throw new SpacesRuntimeError("RecordNotFound", 404); + return { + record: parsedRecord(record as unknown as Record) as T, + references: references(result.references as unknown as Record), + }; + }, + }; + }; + const handle = async ( request: Request, env: Env, @@ -729,17 +1109,23 @@ export function createSpacesWorker(request); - const parsed = parseSpaceUri(body.space); - if (!options.spaceTypes[parsed.type]) { - return privateJson({ error: "UnsupportedSpace" }, 400); - } - if (typeof body.delegation !== "string" || !body.delegation) { + if (typeof body.delegation !== "string") { 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, { + return privateJson(await integrated(env, ctx).authorizeSpace({ 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(), - }); + space: String(body.space ?? ""), + delegation: body.delegation, + rediscover: body.rediscover === true, + })); } - if (method === `${namespace}.${SYNC_SPACE_METHOD}` && request.method === "POST") { + if (standaloneUserApi && + 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); + const result = await integrated(env, ctx).syncSpace({ + userDid: verified.did!, + space: String(body.space ?? ""), + ...(body.repo === undefined ? {} : { repo: String(body.repo) }), + }); + return privateJson(result, 202); } - if (method === `${namespace}.${LIST_SPACES_METHOD}` && request.method === "GET") { + if (standaloneUserApi && + method === `${namespace}.${LIST_SPACES_METHOD}` && request.method === "GET") { const verified = await authorizeRequest(request, method); if (verified.response) return verified.response; - const requestedLimit = Number(url.searchParams.get("limit") ?? 100); - const cursor = url.searchParams.get("cursor") ?? undefined; - if (!Number.isInteger(requestedLimit) || requestedLimit < 1 || - requestedLimit > 200 || (cursor?.length ?? 0) > 2_048) { - return privateJson({ error: "InvalidRequest" }, 400); - } - const indexed = await listConnectedSpaceWatches(db, { + return privateJson(await integrated(env, ctx).listSpaces({ userDid: verified.did!, - cursor, - limit: requestedLimit + 1, - }); - const watches = new Map(indexed.map((watch) => [watch.spaceUri, watch])); - let customTruncated = false; - if (options.authorization?.listSpaces) { - const listed = await options.authorization.listSpaces(verified.did!, { env, db }); - customTruncated = listed.length > 1_000; - for (const spaceUri of listed.slice(0, 1_000)) { - if (typeof spaceUri !== "string" || spaceUri <= (cursor ?? "")) continue; - try { - parseSpaceUri(spaceUri); - } catch { - continue; - } - const watch = await getSpaceWatch(db, spaceUri); - if (!watch || watch.status !== "active") continue; - if (await accessAllowed(env, db, watch, { - userDid: verified.did!, - spaceUri, - action: "read", - method, - })) watches.set(spaceUri, watch); - } - } - const ordered = [...watches.values()].sort((a, b) => - a.spaceUri.localeCompare(b.spaceUri) - ); - const truncated = customTruncated || ordered.length > requestedLimit; - const page = ordered.slice(0, requestedLimit); - const spaces = page.map((watch) => ({ - uri: watch.spaceUri, - authorityDid: watch.authorityDid, - type: watch.spaceType, + cursor: url.searchParams.get("cursor") ?? undefined, + limit: Number(url.searchParams.get("limit") ?? 100), })); - return privateJson({ - spaces, - truncated, - ...(truncated && page.length ? { cursor: page.at(-1)!.spaceUri } : {}), - }); } if ( - subscriptionsEnabled && + standaloneUserApi && subscriptionsEnabled && method === `${namespace}.${SUBSCRIBE_SPACE_METHOD}` && request.method === "POST" ) { const verified = await authorizeRequest(request, method); if (verified.response) return verified.response; const body = await boundedJsonBody<{ space?: 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); - const ticket = crypto.randomUUID().replaceAll("-", ""); - const expiresAt = Date.now() + subscriptionTicketTtlMs; - const issued = await subscriptionHub(env, parsed.uri).fetch( - new Request("https://subscriptions.internal/issue", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ticket, expiresAt }), - }), - ); - if (!issued.ok) return privateJson({ error: "SubscriptionUnavailable" }, 503); - const socketUrl = new URL("/subscribe", options.service.endpoint); - socketUrl.protocol = socketUrl.protocol === "http:" ? "ws:" : "wss:"; - socketUrl.searchParams.set("space", parsed.uri); - socketUrl.searchParams.set("ticket", ticket); - return privateJson({ - url: socketUrl.href, - expiresAt: new Date(expiresAt).toISOString(), - }); + return privateJson(await integrated(env, ctx).subscribeSpace({ + userDid: verified.did!, + space: String(body.space ?? ""), + })); } if (method === "com.atproto.space.notifyWrite" && request.method === "POST") { @@ -1033,95 +1271,51 @@ export function createSpacesWorker = {}; - const ranges: Record = {}; + const rangeFilters: 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 min = url.searchParams.get(`${field}Min`) ?? undefined; + const max = url.searchParams.get(`${field}Max`) ?? undefined; + if (min !== undefined || max !== undefined) rangeFilters[field] = { min, max }; } - 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, - }), + return privateJson(await integrated(env, ctx).listSpaceRecords({ + userDid: verified.did!, + space, + collection: collection.collection!, + limit: Number(url.searchParams.get("limit") ?? 50), + cursor: url.searchParams.get("cursor") ?? undefined, + search: url.searchParams.get("search") ?? undefined, + did: url.searchParams.get("did") ?? 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), - ]), - ), - }); + rangeFilters, + })); } return json({ error: "MethodNotFound" }, 404); }; return { + integrated, fetch(request, env, ctx) { return handle(request, env, ctx).catch(errorResponse); }, diff --git a/packages/contrail-spaces-alpha/tests/consumer.test.ts b/packages/contrail-spaces-alpha/tests/consumer.test.ts index fe44681..c2484de 100644 --- a/packages/contrail-spaces-alpha/tests/consumer.test.ts +++ b/packages/contrail-spaces-alpha/tests/consumer.test.ts @@ -6,6 +6,7 @@ import { listSimpleSpaceMembers, removeSimpleSpaceMember, spacesConsumerOAuthScopes, + spacesIntegratedOAuthScopes, updateSimpleSpacePolicy, type AuthenticatedPdsSession, } from "../src/consumer"; @@ -31,6 +32,14 @@ describe("consumer scopes", () => { expect(scopes[2]).toContain("garden.atmo.circle.listSpaces"); expect(scopes[2]).toContain("garden.atmo.circle.subscribeSpace"); expect(scopes[2]).not.toContain("notifyWrite"); + + const integrated = spacesIntegratedOAuthScopes({ + collections: ["garden.atmo.circle.note"], + spaceType: "garden.atmo.circle", + skey: "self", + }); + expect(integrated).toHaveLength(2); + expect(integrated.some((scope) => scope.startsWith("rpc?"))).toBe(false); }); it("uses the PDS-native member-list procedures", async () => { diff --git a/packages/contrail-spaces-alpha/tests/worker-query.test.ts b/packages/contrail-spaces-alpha/tests/worker-query.test.ts index 9ef92a1..6383936 100644 --- a/packages/contrail-spaces-alpha/tests/worker-query.test.ts +++ b/packages/contrail-spaces-alpha/tests/worker-query.test.ts @@ -120,6 +120,7 @@ describe("Spaces Worker private query boundary", () => { }, }, }, + standaloneUserApi: true, spaceTypes: { "garden.atmo.circle": { collections: [collection], @@ -164,6 +165,13 @@ describe("Spaces Worker private query boundary", () => { truncated: false, }); + const integrated = worker.integrated(env, context()); + expect((await integrated.listSpaceRecords<{ value: { text: string } }>({ + userDid: issuer, + space, + collection, + })).records.map((record) => record.value.text)).toEqual(["private hello"]); + const wrongMethod = new Request(url, { headers: { authorization: `Bearer ${await token("garden.atmo.circle.syncSpace")}`, @@ -188,6 +196,34 @@ describe("Spaces Worker private query boundary", () => { }]); }); + it("defaults to integrated user APIs while retaining authenticated PDS callbacks", async () => { + const worker = createSpacesWorker({ + projection, + lexicons, + service: { + endpoint: "https://spaces.atmo.garden", + audience, + }, + spaceTypes: { + "garden.atmo.circle": { + collections: [collection], + policy: "member-list", + }, + }, + }); + const discovery = await worker.fetch!(new Request( + "https://spaces.atmo.garden/.well-known/contrail-spaces-alpha", + ) as never, {} as never, context()); + expect((await discovery.json() as { methods: string[] }).methods).toEqual([ + "com.atproto.space.notifyWrite", + "com.atproto.space.notifySpaceDeleted", + ]); + const removed = await worker.fetch!(new Request( + "https://spaces.atmo.garden/xrpc/garden.atmo.circle.listSpaces", + ) as never, {} as never, context()); + expect(removed.status).toBe(404); + }); + it("requires an application authorizer only for managing-app policies", () => { const base = { projection, diff --git a/patches/@atcute__multibase@1.2.5.patch b/patches/@atcute__multibase@1.2.5.patch new file mode 100644 index 0000000..8cd96e0 --- /dev/null +++ b/patches/@atcute__multibase@1.2.5.patch @@ -0,0 +1,35 @@ +diff --git a/dist/bases/base64-node.js b/dist/bases/base64-node.js +index 292c3a70f8f97ccfc7f324f8bf77ab329724e1b3..1633d166b3a61903cf9ff437a10c200dc145b5a7 100644 +--- a/dist/bases/base64-node.js ++++ b/dist/bases/base64-node.js +@@ -66,26 +66,26 @@ const rawFromBase64UrlPad = (str) => { + }; + // #region base64 + export const toBase64 = (bytes) => { +- return trimBase64Padding(_base64Slice.call(bytes)); ++ return trimBase64Padding(_base64Slice.call(bytes, 0, bytes.length)); + }; + export const fromBase64 = /*#__PURE__*/ roundtrip(rawFromBase64, toBase64); + // #endregion + // #region base64pad + export const toBase64Pad = (bytes) => { +- return _base64Slice.call(bytes); ++ return _base64Slice.call(bytes, 0, bytes.length); + }; + export const fromBase64Pad = /*#__PURE__*/ roundtrip(rawFromBase64Pad, toBase64Pad); + // #endregion + // #region base64url + export const toBase64Url = (bytes) => { +- return _base64UrlSlice.call(bytes); ++ return _base64UrlSlice.call(bytes, 0, bytes.length); + }; + export const fromBase64Url = /*#__PURE__*/ roundtrip(rawFromBase64Url, toBase64Url); + // #endregion + // #region base64urlpad + const PADDING = ['', '===', '==', '=']; + export const toBase64UrlPad = (bytes) => { +- const str = _base64UrlSlice.call(bytes); ++ const str = _base64UrlSlice.call(bytes, 0, bytes.length); + return str + PADDING[str.length % 4]; + }; + export const fromBase64UrlPad = /*#__PURE__*/ roundtrip(rawFromBase64UrlPad, toBase64UrlPad); diff --git a/patches/@svelte-atproto__oauth@0.3.0.patch b/patches/@svelte-atproto__oauth@0.3.0.patch new file mode 100644 index 0000000..8d6c13b --- /dev/null +++ b/patches/@svelte-atproto__oauth@0.3.0.patch @@ -0,0 +1,38 @@ +diff --git a/dist/server/index.d.mts b/dist/server/index.d.mts +index 2fcf6f6433cbf471fc4c425ee568e3e8c3700eda..425681a93acfedc7703716b3e2942b5c530eee9e 100644 +--- a/dist/server/index.d.mts ++++ b/dist/server/index.d.mts +@@ -21,6 +21,8 @@ interface AtprotoAuthConfig { + * Required outside dev. + */ + origin?: string | undefined; ++ /** Override SvelteKit's build-time dev flag for a built loopback Worker. */ ++ dev?: boolean; + /** + * HMAC secret for signed cookies. Required outside dev. In dev, falls + * back to a fixed dev string with a one-time warning. +diff --git a/dist/server/index.mjs b/dist/server/index.mjs +index 40942bd3edd80c8a3dce60ffdba49384ffc56d0f..3ef252c3f72775f4cc010c9a5fd35fcf628f7b92 100644 +--- a/dist/server/index.mjs ++++ b/dist/server/index.mjs +@@ -30,9 +30,10 @@ function normalizeScope(s) { + } + let warnedDevSecret = false; + function resolveConfig(c) { ++ const resolvedDev = c.dev ?? dev; + let cookieSecret = c.cookieSecret; + if (!cookieSecret) { +- if (!dev) throw new Error("atproto-oauth: cookieSecret is required outside dev"); ++ if (!resolvedDev) throw new Error("atproto-oauth: cookieSecret is required outside dev"); + if (!warnedDevSecret) { + console.warn("[atproto-oauth] cookieSecret not set — using a known dev fallback. Set `cookieSecret` (e.g. via env) before deploying."); + warnedDevSecret = true; +@@ -55,7 +56,7 @@ function resolveConfig(c) { + loginPath: c.loginPath ?? DEFAULT_LOGIN_PATH, + logoutPath: c.logoutPath ?? DEFAULT_LOGOUT_PATH, + doh: c.doh ?? DEFAULT_DOH, +- dev, ++ dev: resolvedDev, + devPort: c.devPort ?? DEFAULT_DEV_PORT, + cookieMaxAge: c.cookieMaxAge ?? DEFAULT_MAX_AGE, + scope, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a6393f..180521a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,14 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + '@atcute/multibase@1.2.5': + hash: a69cbd2cf51eb578b303df98cb16d071b3eb3727445ee58926ce090c0b647cc9 + path: patches/@atcute__multibase@1.2.5.patch + '@svelte-atproto/oauth@0.3.0': + hash: 6e9cef7a07d214f8a2d7e7142343bb25ebfd9146a99f667c7363322cbbfd7036 + path: patches/@svelte-atproto__oauth@0.3.0.patch + importers: .: @@ -102,12 +110,15 @@ importers: apps/spaces-demo: dependencies: + '@atmo-dev/contrail': + specifier: workspace:* + version: link:../../packages/contrail '@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) + version: 0.3.0(patch_hash=6e9cef7a07d214f8a2d7e7142343bb25ebfd9146a99f667c7363322cbbfd7036)(@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 @@ -127,34 +138,15 @@ importers: 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 tsx: specifier: ^4.21.0 version: 4.23.6 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) @@ -4280,12 +4272,12 @@ snapshots: '@atcute/cbor@2.3.6(@atcute/cid@2.4.2)': dependencies: '@atcute/cid': 2.4.2 - '@atcute/multibase': 1.2.5 + '@atcute/multibase': 1.2.5(patch_hash=a69cbd2cf51eb578b303df98cb16d071b3eb3727445ee58926ce090c0b647cc9) '@atcute/uint8array': 1.1.5 '@atcute/cid@2.4.2': dependencies: - '@atcute/multibase': 1.2.5 + '@atcute/multibase': 1.2.5(patch_hash=a69cbd2cf51eb578b303df98cb16d071b3eb3727445ee58926ce090c0b647cc9) '@atcute/uint8array': 1.1.5 '@atcute/client@5.1.1(@atcute/lexicons@2.0.3)(typescript@6.0.3)': @@ -4297,7 +4289,7 @@ snapshots: '@atcute/crypto@2.4.4': dependencies: - '@atcute/multibase': 1.2.5 + '@atcute/multibase': 1.2.5(patch_hash=a69cbd2cf51eb578b303df98cb16d071b3eb3727445ee58926ce090c0b647cc9) '@atcute/uint8array': 1.1.5 '@noble/secp256k1': 3.1.0 @@ -4386,7 +4378,7 @@ snapshots: '@atcute/cid': 2.4.2 '@atcute/uint8array': 1.1.5 - '@atcute/multibase@1.2.5': + '@atcute/multibase@1.2.5(patch_hash=a69cbd2cf51eb578b303df98cb16d071b3eb3727445ee58926ce090c0b647cc9)': dependencies: '@atcute/uint8array': 1.1.5 @@ -4395,7 +4387,7 @@ snapshots: '@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/multibase': 1.2.5(patch_hash=a69cbd2cf51eb578b303df98cb16d071b3eb3727445ee58926ce090c0b647cc9) '@atcute/oauth-crypto': 1.0.1(typescript@6.0.3) '@atcute/oauth-types': 1.0.1(typescript@6.0.3) nanoid: 5.1.16 @@ -4404,7 +4396,7 @@ snapshots: '@atcute/oauth-crypto@1.0.1(typescript@6.0.3)': dependencies: - '@atcute/multibase': 1.2.5 + '@atcute/multibase': 1.2.5(patch_hash=a69cbd2cf51eb578b303df98cb16d071b3eb3727445ee58926ce090c0b647cc9) '@atcute/uint8array': 1.1.5 nanoid: 5.1.16 valibot: 1.4.2(typescript@6.0.3) @@ -4478,7 +4470,7 @@ snapshots: '@atcute/identity': 2.0.2(@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/multibase': 1.2.5(patch_hash=a69cbd2cf51eb578b303df98cb16d071b3eb3727445ee58926ce090c0b647cc9) '@atcute/uint8array': 1.1.5 nanoid: 6.0.1 valibot: 1.4.2(typescript@6.0.3) @@ -5613,7 +5605,7 @@ 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)': + '@svelte-atproto/oauth@0.3.0(patch_hash=6e9cef7a07d214f8a2d7e7142343bb25ebfd9146a99f667c7363322cbbfd7036)(@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) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8d72a79..4e58395 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,5 +9,9 @@ onlyBuiltDependencies: - '@tailwindcss/oxide' - '@sveltejs/kit' +patchedDependencies: + '@atcute/multibase@1.2.5': patches/@atcute__multibase@1.2.5.patch + '@svelte-atproto/oauth@0.3.0': patches/@svelte-atproto__oauth@0.3.0.patch + trustPolicyExclude: - chokidar@4.0.3 -- 2.51.2