diff --git a/apps/spaces-demo-provider/README.md b/apps/spaces-demo-provider/README.md index 5f121f0..12766e5 100644 --- a/apps/spaces-demo-provider/README.md +++ b/apps/spaces-demo-provider/README.md @@ -2,7 +2,9 @@ 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. +It indexes `garden.atmo.circle.note`, `garden.atmo.circle.member`, and `garden.atmo.circle.reaction` from one deterministic `garden.atmo.circle/self` Space per owner. The managing-app policy allows the owner and DIDs in the Space's member collection. Only membership records signed in the authority's repo are projected, so members cannot grant access to others. Policy failures deny access. + +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 @@ -33,4 +35,4 @@ 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. +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 index a5e5f4b..c4402f1 100644 --- a/apps/spaces-demo-provider/package.json +++ b/apps/spaces-demo-provider/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", + "publish-lexicons": "tsx scripts/publish-lexicons.ts", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -14,6 +15,7 @@ }, "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/scripts/publish-lexicons.ts b/apps/spaces-demo-provider/scripts/publish-lexicons.ts new file mode 100644 index 0000000..814613f --- /dev/null +++ b/apps/spaces-demo-provider/scripts/publish-lexicons.ts @@ -0,0 +1,78 @@ +import { createHash } from "node:crypto"; +import { networkLexicons } from "../src/lexicons"; + +const collection = "com.atproto.lexicon.schema"; + +function required(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing ${name}`); + return value; +} + +async function jsonRequest( + url: string | URL, + init?: RequestInit, +): Promise> { + const response = await fetch(url, init); + if (!response.ok) { + throw new Error(`${url} failed (${response.status}): ${await response.text()}`); + } + return response.json() as Promise>; +} + +function digest(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +const pds = required("LEXICON_AUTHORITY_PDS").replace(/\/$/, ""); +const identifier = required("LEXICON_AUTHORITY_IDENTIFIER"); +const password = required("LEXICON_AUTHORITY_PASSWORD"); +const expectedDid = process.env.LEXICON_AUTHORITY_DID; + +const session = await jsonRequest(`${pds}/xrpc/com.atproto.server.createSession`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ identifier, password }), +}); +const accessJwt = String(session.accessJwt ?? ""); +const did = String(session.did ?? ""); +if (!accessJwt || !did.startsWith("did:")) throw new Error("Authority login returned no session"); +if (expectedDid && did !== expectedDid) { + throw new Error(`Authority DID mismatch: expected ${expectedDid}, received ${did}`); +} + +for (const doc of networkLexicons) { + const existing = new URL(`${pds}/xrpc/com.atproto.repo.getRecord`); + existing.searchParams.set("repo", did); + existing.searchParams.set("collection", collection); + existing.searchParams.set("rkey", doc.id); + const current = await fetch(existing, { + headers: { authorization: `Bearer ${accessJwt}` }, + }); + if (current.ok) { + const body = await current.json() as { value?: unknown }; + if (digest(body.value) === digest(doc)) { + console.log(`lexicon unchanged: ${doc.id}`); + continue; + } + } else if (current.status !== 400 && current.status !== 404) { + throw new Error(`${existing} failed (${current.status}): ${await current.text()}`); + } + + await jsonRequest(`${pds}/xrpc/com.atproto.repo.putRecord`, { + method: "POST", + headers: { + authorization: `Bearer ${accessJwt}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + repo: did, + collection, + rkey: doc.id, + record: doc, + }), + }); + console.log(`published lexicon: ${doc.id}`); +} + +console.log(`published ${networkLexicons.length} network Lexicons from ${did}`); diff --git a/apps/spaces-demo-provider/src/constants.ts b/apps/spaces-demo-provider/src/constants.ts index 6bbecbd..26aa1c2 100644 --- a/apps/spaces-demo-provider/src/constants.ts +++ b/apps/spaces-demo-provider/src/constants.ts @@ -1,6 +1,7 @@ export const SPACE_TYPE = "garden.atmo.circle"; export const SPACE_SKEY = "self"; export const NOTE_COLLECTION = "garden.atmo.circle.note"; +export const MEMBER_COLLECTION = "garden.atmo.circle.member"; 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 index 2f649f0..4536b8a 100644 --- a/apps/spaces-demo-provider/src/lexicons.ts +++ b/apps/spaces-demo-provider/src/lexicons.ts @@ -1,4 +1,24 @@ -import { NOTE_COLLECTION, REACTION_COLLECTION } from "./constants"; +import { + MEMBER_COLLECTION, + NOTE_COLLECTION, + REACTION_COLLECTION, + SPACE_SKEY, + SPACE_TYPE, +} from "./constants"; + +export const circleLexicon = { + lexicon: 1, + id: SPACE_TYPE, + defs: { + main: { + type: "space", + key: `literal:${SPACE_SKEY}`, + name: "Atmo Circle", + description: "A private multi-writer circle with an owner-managed member list.", + collections: [NOTE_COLLECTION, MEMBER_COLLECTION, REACTION_COLLECTION], + }, + }, +} as const; export const noteLexicon = { lexicon: 1, @@ -28,6 +48,26 @@ export const noteLexicon = { }, } as const; +export const memberLexicon = { + lexicon: 1, + id: MEMBER_COLLECTION, + defs: { + main: { + type: "record", + key: "any", + record: { + type: "object", + required: ["subject", "createdAt"], + properties: { + subject: { type: "string", format: "did" }, + handle: { type: "string", format: "handle" }, + createdAt: { type: "string", format: "datetime" }, + }, + }, + }, + }, +} as const; + export const reactionLexicon = { lexicon: 1, id: REACTION_COLLECTION, @@ -55,4 +95,5 @@ export const reactionLexicon = { }, } as const; -export const lexicons = [noteLexicon, reactionLexicon] as const; +export const recordLexicons = [noteLexicon, memberLexicon, reactionLexicon] as const; +export const networkLexicons = [circleLexicon, ...recordLexicons] as const; diff --git a/apps/spaces-demo-provider/src/worker.ts b/apps/spaces-demo-provider/src/worker.ts index da7b9a6..5267e21 100644 --- a/apps/spaces-demo-provider/src/worker.ts +++ b/apps/spaces-demo-provider/src/worker.ts @@ -1,7 +1,9 @@ import type { ContrailConfig } from "@atmo-dev/contrail"; import { createSpacesWorker } from "@atmo-dev/contrail-spaces-alpha/worker"; -import { parseSpaceUri } from "@atmo-dev/contrail-spaces-alpha"; +export { SpaceSubscriptionHub } from "@atmo-dev/contrail-spaces-alpha/worker"; +import { authorityRecordMembership } from "@atmo-dev/contrail-spaces-alpha"; import { + MEMBER_COLLECTION, NOTE_COLLECTION, PROVIDER_AUDIENCE, PROVIDER_ENDPOINT, @@ -9,14 +11,14 @@ import { SPACE_SKEY, SPACE_TYPE, } from "./constants"; -import { lexicons } from "./lexicons"; +import { recordLexicons } from "./lexicons"; interface Env { [key: string]: unknown; DB: D1Database; SPACES_QUEUE: Queue; SPACES_CREDENTIAL_ENCRYPTION_KEY: string; - BSKY_APPVIEW_URL: string; + SPACE_SUBSCRIPTIONS: DurableObjectNamespace; } const projection: ContrailConfig = { @@ -36,6 +38,11 @@ const projection: ContrailConfig = { reply: { collection: "note", field: "reply.uri" }, }, }, + member: { + collection: MEMBER_COLLECTION, + validate: true, + queryable: { subject: {} }, + }, reaction: { collection: REACTION_COLLECTION, validate: true, @@ -48,61 +55,24 @@ const projection: ContrailConfig = { 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, + lexicons: recordLexicons, service: { endpoint: PROVIDER_ENDPOINT, audience: PROVIDER_AUDIENCE, }, spaceTypes: { [SPACE_TYPE]: { - collections: [NOTE_COLLECTION, REACTION_COLLECTION], + collections: [NOTE_COLLECTION, MEMBER_COLLECTION, REACTION_COLLECTION], + access: authorityRecordMembership({ + collection: MEMBER_COLLECTION, + principalField: "subject", + }), 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; - } - }, - }, + subscriptions: { binding: "SPACE_SUBSCRIPTIONS" }, accessLeaseMs: 15 * 60_000, reconcileIntervalMs: 5 * 60_000, }); diff --git a/apps/spaces-demo-provider/wrangler.jsonc b/apps/spaces-demo-provider/wrangler.jsonc index 9c37540..94221f9 100644 --- a/apps/spaces-demo-provider/wrangler.jsonc +++ b/apps/spaces-demo-provider/wrangler.jsonc @@ -8,16 +8,21 @@ "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" + "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" } diff --git a/apps/spaces-demo/README.md b/apps/spaces-demo/README.md index d9894f2..aed614d 100644 --- a/apps/spaces-demo/README.md +++ b/apps/spaces-demo/README.md @@ -1,14 +1,17 @@ # Atmo Circle demo -Small SvelteKit Cloudflare Worker demonstrating a separately deployed Contrail Spaces provider. +Small SvelteKit Cloudflare Worker demonstrating a separately deployed Contrail Spaces provider with an owner-managed member list. - 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`. +- owners add or remove members by handle using authority-signed membership records; +- an authenticated provider query lists the active circles the viewer owns or can access; +- 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. -Only Spaces-compatible PDS accounts can use the alpha. +Only Spaces-compatible PDS accounts can use the alpha. Clients refetch authorized query results after an invalidation; private records never travel in WebSocket events. ## Provision and deploy diff --git a/apps/spaces-demo/src/lib/constants.ts b/apps/spaces-demo/src/lib/constants.ts index 6161951..aa76212 100644 --- a/apps/spaces-demo/src/lib/constants.ts +++ b/apps/spaces-demo/src/lib/constants.ts @@ -1,7 +1,8 @@ export const SPACE_TYPE = "garden.atmo.circle"; export const SPACE_SKEY = "self"; export const NOTE_COLLECTION = "garden.atmo.circle.note"; +export const MEMBER_COLLECTION = "garden.atmo.circle.member"; export const REACTION_COLLECTION = "garden.atmo.circle.reaction"; -export const COLLECTIONS = [NOTE_COLLECTION, REACTION_COLLECTION] as const; +export const COLLECTIONS = [NOTE_COLLECTION, MEMBER_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/routes/+page.server.ts b/apps/spaces-demo/src/routes/+page.server.ts index 8df2111..0432adf 100644 --- a/apps/spaces-demo/src/routes/+page.server.ts +++ b/apps/spaces-demo/src/routes/+page.server.ts @@ -3,9 +3,11 @@ import { SpacesProviderClient, createSpace, createSpaceRecord, + deleteSpaceRecord, formatSpaceUri, } from "@atmo-dev/contrail-spaces-alpha/consumer"; import { + MEMBER_COLLECTION, NOTE_COLLECTION, PROVIDER_AUDIENCE, PROVIDER_ENDPOINT, @@ -27,6 +29,18 @@ interface NoteRecord { }; } +interface MemberRecord { + uri: string; + rkey: string; + did: string; + cid: string; + value: { + subject: string; + handle?: string; + createdAt: string; + }; +} + function circleUri(ownerDid: string): string { return formatSpaceUri({ authorityDid: ownerDid, type: SPACE_TYPE, skey: SPACE_SKEY }); } @@ -54,25 +68,79 @@ function ownerFrom(form: FormData, fallback: string): string { return validDid(owner) ? owner : fallback; } +async function memberRkey(did: string): Promise { + const digest = new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(did)), + ); + let binary = ""; + for (const byte of digest) binary += String.fromCharCode(byte); + return `m-${btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "")}`; +} + +async function resolveMember(value: unknown): Promise<{ did: string; handle?: string }> { + if (validDid(value)) return { did: value }; + const handle = typeof value === "string" + ? value.trim().replace(/^@/, "").toLowerCase() + : ""; + if (!handle || handle.length > 253) throw new Error("Enter a valid handle or DID"); + const url = new URL( + "/xrpc/com.atproto.identity.resolveHandle", + "https://public.api.bsky.app", + ); + url.searchParams.set("handle", handle); + const response = await fetch(url, { + headers: { accept: "application/json" }, + redirect: "manual", + signal: AbortSignal.timeout(5_000), + }); + 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 }; +} + export const load: PageServerLoad = async ({ locals, url }) => { if (!locals.session || !locals.did) { - return { signedIn: false as const, owner: null, space: null, notes: [] }; + return { + signedIn: false as const, + owner: null, + space: null, + notes: [], + members: [], + circles: [], + }; } 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(() => ({ + spaces: [] as Array<{ uri: string; authorityDid: string; type: string }>, + truncated: false, + })); try { - const result = await provider(locals.session).listSpaceRecords({ - space, - collection: NOTE_COLLECTION, - limit: 100, - search: url.searchParams.get("search") ?? undefined, - }); + const [notes, members] = await Promise.all([ + client.listSpaceRecords({ + space, + collection: NOTE_COLLECTION, + limit: 100, + search: url.searchParams.get("search") ?? undefined, + }), + client.listSpaceRecords({ + space, + collection: MEMBER_COLLECTION, + did: owner, + limit: 200, + }), + ]); return { signedIn: true as const, owner, space, - notes: result.records, + notes: notes.records, + members: members.records, + circles: available.spaces, + circlesTruncated: available.truncated, viewer: locals.did, needsAuthorization: false, }; @@ -82,6 +150,9 @@ export const load: PageServerLoad = async ({ locals, url }) => { owner, space, notes: [] as NoteRecord[], + members: [] as MemberRecord[], + circles: available.spaces, + circlesTruncated: available.truncated, viewer: locals.did, needsAuthorization: true, error: error instanceof Error ? error.message : "Circle unavailable", @@ -123,6 +194,79 @@ export const actions: Actions = { throw redirect(303, `/?owner=${encodeURIComponent(owner)}`); }, + async addMember({ request, locals }) { + const { session, did } = signedIn(locals); + const form = await request.formData(); + const owner = ownerFrom(form, did); + if (owner !== did) { + return fail(403, { action: "addMember", message: "Only the circle owner can add members" }); + } + try { + const member = await resolveMember(form.get("member")); + if (member.did === owner) throw new Error("The owner already has access"); + const space = circleUri(owner); + const client = provider(session); + const existing = await client.listSpaceRecords({ + space, + collection: MEMBER_COLLECTION, + did: owner, + filters: { subject: member.did }, + limit: 1, + }); + if (existing.records.length === 0) { + await createSpaceRecord(session, { + space, + collection: MEMBER_COLLECTION, + rkey: await memberRkey(member.did), + record: { + $type: MEMBER_COLLECTION, + subject: member.did, + ...(member.handle ? { handle: member.handle } : {}), + createdAt: new Date().toISOString(), + }, + }); + await client.syncSpace(space, did); + } + } catch (error) { + return fail(400, { + action: "addMember", + message: error instanceof Error ? error.message : "Could not add member", + }); + } + throw redirect(303, `/?owner=${encodeURIComponent(owner)}`); + }, + + async removeMember({ request, locals }) { + const { session, did } = signedIn(locals); + const form = await request.formData(); + const owner = ownerFrom(form, did); + const rkey = form.get("rkey"); + if (owner !== did) { + return fail(403, { + action: "removeMember", + message: "Only the circle owner can remove members", + }); + } + if (typeof rkey !== "string" || !/^m-[A-Za-z0-9_-]{43}$/.test(rkey)) { + return fail(400, { action: "removeMember", message: "Invalid member record" }); + } + try { + const space = circleUri(owner); + await deleteSpaceRecord(session, { + space, + collection: MEMBER_COLLECTION, + rkey, + }); + await provider(session).syncSpace(space, did); + } catch (error) { + return fail(400, { + action: "removeMember", + message: error instanceof Error ? error.message : "Could not remove member", + }); + } + throw redirect(303, `/?owner=${encodeURIComponent(owner)}`); + }, + async post({ request, locals }) { const { session, did } = signedIn(locals); const form = await request.formData(); diff --git a/apps/spaces-demo/src/routes/+page.svelte b/apps/spaces-demo/src/routes/+page.svelte index df55f48..5c9c91a 100644 --- a/apps/spaces-demo/src/routes/+page.svelte +++ b/apps/spaces-demo/src/routes/+page.svelte @@ -1,10 +1,107 @@