diff --git a/apps/spaces-demo-provider/README.md b/apps/spaces-demo-provider/README.md index 12766e5..93baa9c 100644 --- a/apps/spaces-demo-provider/README.md +++ b/apps/spaces-demo-provider/README.md @@ -2,7 +2,7 @@ Standalone Contrail Spaces-alpha provider at `https://spaces.atmo.garden` (`did:web:spaces.atmo.garden#spaces`). -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. +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. diff --git a/apps/spaces-demo-provider/scripts/publish-lexicons.ts b/apps/spaces-demo-provider/scripts/publish-lexicons.ts index 814613f..dc1a12e 100644 --- a/apps/spaces-demo-provider/scripts/publish-lexicons.ts +++ b/apps/spaces-demo-provider/scripts/publish-lexicons.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { networkLexicons } from "../src/lexicons"; const collection = "com.atproto.lexicon.schema"; +const retiredLexicons = ["garden.atmo.circle.member"] as const; function required(name: string): string { const value = process.env[name]; @@ -75,4 +76,27 @@ for (const doc of networkLexicons) { console.log(`published lexicon: ${doc.id}`); } +for (const nsid of retiredLexicons) { + const existing = new URL(`${pds}/xrpc/com.atproto.repo.getRecord`); + existing.searchParams.set("repo", did); + existing.searchParams.set("collection", collection); + existing.searchParams.set("rkey", nsid); + const current = await fetch(existing, { + headers: { authorization: `Bearer ${accessJwt}` }, + }); + if (current.status === 400 || current.status === 404) continue; + if (!current.ok) { + throw new Error(`${existing} failed (${current.status}): ${await current.text()}`); + } + await jsonRequest(`${pds}/xrpc/com.atproto.repo.deleteRecord`, { + method: "POST", + headers: { + authorization: `Bearer ${accessJwt}`, + "content-type": "application/json", + }, + body: JSON.stringify({ repo: did, collection, rkey: nsid }), + }); + console.log(`retired lexicon: ${nsid}`); +} + 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 26aa1c2..6bbecbd 100644 --- a/apps/spaces-demo-provider/src/constants.ts +++ b/apps/spaces-demo-provider/src/constants.ts @@ -1,7 +1,6 @@ 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 4536b8a..17aad6b 100644 --- a/apps/spaces-demo-provider/src/lexicons.ts +++ b/apps/spaces-demo-provider/src/lexicons.ts @@ -1,5 +1,4 @@ import { - MEMBER_COLLECTION, NOTE_COLLECTION, REACTION_COLLECTION, SPACE_SKEY, @@ -14,8 +13,8 @@ export const circleLexicon = { 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], + description: "A private multi-writer circle using native PDS membership.", + collections: [NOTE_COLLECTION, REACTION_COLLECTION], }, }, } as const; @@ -48,26 +47,6 @@ 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, @@ -95,5 +74,5 @@ export const reactionLexicon = { }, } as const; -export const recordLexicons = [noteLexicon, memberLexicon, reactionLexicon] as const; +export const recordLexicons = [noteLexicon, 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 5267e21..90e6448 100644 --- a/apps/spaces-demo-provider/src/worker.ts +++ b/apps/spaces-demo-provider/src/worker.ts @@ -1,9 +1,7 @@ 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 { authorityRecordMembership } from "@atmo-dev/contrail-spaces-alpha"; import { - MEMBER_COLLECTION, NOTE_COLLECTION, PROVIDER_AUDIENCE, PROVIDER_ENDPOINT, @@ -38,11 +36,6 @@ const projection: ContrailConfig = { reply: { collection: "note", field: "reply.uri" }, }, }, - member: { - collection: MEMBER_COLLECTION, - validate: true, - queryable: { subject: {} }, - }, reaction: { collection: REACTION_COLLECTION, validate: true, @@ -64,15 +57,12 @@ export default createSpacesWorker({ }, spaceTypes: { [SPACE_TYPE]: { - collections: [NOTE_COLLECTION, MEMBER_COLLECTION, REACTION_COLLECTION], - access: authorityRecordMembership({ - collection: MEMBER_COLLECTION, - principalField: "subject", - }), + collections: [NOTE_COLLECTION, REACTION_COLLECTION], + policy: "member-list", skey: SPACE_SKEY, }, }, subscriptions: { binding: "SPACE_SUBSCRIPTIONS" }, - accessLeaseMs: 15 * 60_000, + accessLeaseMs: 2 * 60_000, reconcileIntervalMs: 5 * 60_000, }); diff --git a/apps/spaces-demo/README.md b/apps/spaces-demo/README.md index aed614d..0519599 100644 --- a/apps/spaces-demo/README.md +++ b/apps/spaces-demo/README.md @@ -1,12 +1,13 @@ # Atmo Circle demo -Small SvelteKit Cloudflare Worker demonstrating a separately deployed Contrail Spaces provider with an owner-managed member list. +Small SvelteKit Cloudflare Worker demonstrating a separately deployed Contrail Spaces provider with PDS-native membership. - 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 using authority-signed membership records; -- an authenticated provider query lists the active circles the viewer owns or can access; +- 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. diff --git a/apps/spaces-demo/src/lib/constants.ts b/apps/spaces-demo/src/lib/constants.ts index aa76212..6161951 100644 --- a/apps/spaces-demo/src/lib/constants.ts +++ b/apps/spaces-demo/src/lib/constants.ts @@ -1,8 +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 COLLECTIONS = [NOTE_COLLECTION, MEMBER_COLLECTION, REACTION_COLLECTION] as const; +export const COLLECTIONS = [NOTE_COLLECTION, REACTION_COLLECTION] as const; export const PROVIDER_ENDPOINT = "https://spaces.atmo.garden"; export const PROVIDER_AUDIENCE = "did:web:spaces.atmo.garden#spaces"; diff --git a/apps/spaces-demo/src/routes/+page.server.ts b/apps/spaces-demo/src/routes/+page.server.ts index 0432adf..3c6f89f 100644 --- a/apps/spaces-demo/src/routes/+page.server.ts +++ b/apps/spaces-demo/src/routes/+page.server.ts @@ -1,13 +1,16 @@ import { fail, redirect } from "@sveltejs/kit"; import { SpacesProviderClient, + addSimpleSpaceMember, createSpace, createSpaceRecord, - deleteSpaceRecord, formatSpaceUri, + getSimpleSpace, + listSimpleSpaceMembers, + removeSimpleSpaceMember, + updateSimpleSpacePolicy, } from "@atmo-dev/contrail-spaces-alpha/consumer"; import { - MEMBER_COLLECTION, NOTE_COLLECTION, PROVIDER_AUDIENCE, PROVIDER_ENDPOINT, @@ -29,16 +32,9 @@ interface NoteRecord { }; } -interface MemberRecord { - uri: string; - rkey: string; +interface CircleMember { did: string; - cid: string; - value: { - subject: string; - handle?: string; - createdAt: string; - }; + handle?: string; } function circleUri(ownerDid: string): string { @@ -68,15 +64,6 @@ 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" @@ -99,6 +86,45 @@ async function resolveMember(value: unknown): Promise<{ did: string; handle?: st 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; + } +} + +async function nativeMembership( + session: NonNullable, + space: string, +): Promise<{ enabled: boolean; members: CircleMember[] }> { + const description = await getSimpleSpace(session, space); + const enabled = description.policy?.$type === + "com.atproto.simplespace.defs#memberListPolicy"; + if (!enabled) return { enabled, members: [] }; + const dids: string[] = []; + let cursor: string | undefined; + do { + const page = await listSimpleSpaceMembers(session, { space, cursor, limit: 100 }); + 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 { enabled, members }; +} + export const load: PageServerLoad = async ({ locals, url }) => { if (!locals.session || !locals.did) { return { @@ -107,6 +133,7 @@ export const load: PageServerLoad = async ({ locals, url }) => { space: null, notes: [], members: [], + nativeMemberPolicy: false, circles: [], }; } @@ -118,27 +145,35 @@ export const load: PageServerLoad = async ({ locals, url }) => { spaces: [] as Array<{ uri: string; authorityDid: string; type: string }>, truncated: false, })); + const membership = owner === locals.did + ? await nativeMembership(locals.session, space).catch(() => ({ + enabled: false, + members: [] as CircleMember[], + })) + : { enabled: true, members: [] as CircleMember[] }; + const queryNotes = () => client.listSpaceRecords({ + space, + collection: NOTE_COLLECTION, + limit: 100, + search: url.searchParams.get("search") ?? undefined, + }); try { - 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, - }), - ]); + let notes; + try { + notes = await queryNotes(); + } catch { + // Native PDS policy remains authoritative. Renew the short provider + // lease only when cached access expires; removed members fail here. + await client.authorizeSpace(space); + notes = await queryNotes(); + } return { signedIn: true as const, owner, space, notes: notes.records, - members: members.records, + members: membership.members, + nativeMemberPolicy: membership.enabled, circles: available.spaces, circlesTruncated: available.truncated, viewer: locals.did, @@ -150,7 +185,8 @@ export const load: PageServerLoad = async ({ locals, url }) => { owner, space, notes: [] as NoteRecord[], - members: [] as MemberRecord[], + members: membership.members, + nativeMemberPolicy: membership.enabled, circles: available.spaces, circlesTruncated: available.truncated, viewer: locals.did, @@ -167,7 +203,7 @@ export const actions: Actions = { const created = await createSpace(session, { type: SPACE_TYPE, skey: SPACE_SKEY, - managingApp: PROVIDER_AUDIENCE, + policy: { kind: "member-list" }, }); await provider(session).authorizeSpace(created.uri); } catch (error) { @@ -179,6 +215,21 @@ export const actions: Actions = { throw redirect(303, `/?owner=${encodeURIComponent(did)}`); }, + async enableNativeMembers({ locals }) { + const { session, did } = signedIn(locals); + const space = circleUri(did); + try { + await updateSimpleSpacePolicy(session, space, { kind: "member-list" }); + await provider(session).authorizeSpace(space); + } catch (error) { + return fail(400, { + action: "enableNativeMembers", + message: error instanceof Error ? error.message : "Could not update circle policy", + }); + } + throw redirect(303, `/?owner=${encodeURIComponent(did)}`); + }, + async authorize({ request, locals }) { const { session, did } = signedIn(locals); const form = await request.formData(); @@ -205,28 +256,12 @@ export const actions: Actions = { 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); + const description = await getSimpleSpace(session, space); + if (description.policy?.$type !== + "com.atproto.simplespace.defs#memberListPolicy") { + throw new Error("Enable the native member-list policy first"); } + await addSimpleSpaceMember(session, space, member.did); } catch (error) { return fail(400, { action: "addMember", @@ -240,24 +275,19 @@ export const actions: Actions = { const { session, did } = signedIn(locals); const form = await request.formData(); const owner = ownerFrom(form, did); - const rkey = form.get("rkey"); + const memberDid = form.get("memberDid"); 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" }); + if (!validDid(memberDid)) { + return fail(400, { action: "removeMember", message: "Invalid member DID" }); } try { const space = circleUri(owner); - await deleteSpaceRecord(session, { - space, - collection: MEMBER_COLLECTION, - rkey, - }); - await provider(session).syncSpace(space, did); + await removeSimpleSpaceMember(session, space, memberDid); } catch (error) { return fail(400, { action: "removeMember", diff --git a/apps/spaces-demo/src/routes/+page.svelte b/apps/spaces-demo/src/routes/+page.svelte index 5c9c91a..ad2c293 100644 --- a/apps/spaces-demo/src/routes/+page.svelte +++ b/apps/spaces-demo/src/routes/+page.svelte @@ -177,7 +177,7 @@ {#if data.circles.length}
-

Your circles

+

Connected circles

{#if data.circlesTruncated}first 200{/if}
{#each data.circles as circle (circle.uri)} @@ -200,61 +200,70 @@
-
- - -
+ {#if !data.nativeMemberPolicy} +
+ +
+ {:else} +
+ + +
+ {/if}
{:else if data.needsAuthorization}

Connect this circle

-

Ask the circle owner to add your handle to the member list. After that, the provider uses a one-time delegation to verify the Space.

+

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.

{#if data.error}
{data.error}
{/if}
{:else} -
-
-

Members

- {data.members.length + 1} -
-
- owner - {data.owner} -
- {#each data.members as member (member.uri)} + {#if data.owner === data.viewer} +
- - {member.value.handle ? `@${member.value.handle}` : member.value.subject} - - {#if member.value.handle} - {member.value.subject} - {/if} - {#if data.owner === data.viewer} -
- - - -
+

Native PDS members

+ {#if data.nativeMemberPolicy} + {data.members.length + 1} {/if}
- {/each} - {#if data.owner === data.viewer} -
- - - -
- {/if} -
+ {#if !data.nativeMemberPolicy} +

This existing circle still uses the managing-app policy. Switch it to the protocol's native member-list policy before adding people.

+
+ +
+ {:else} +
+ owner + {data.owner} +
+ {#each data.members as member (member.did)} +
+ {member.handle ? `@${member.handle}` : member.did} + {#if member.handle}{member.did}{/if} +
+ + + +
+
+ {/each} +
+ + + +
+ {/if} +
+ {/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 26e1ef3..6ad8bca 100644 --- a/apps/spaces-demo/src/routes/api/space-subscription/+server.ts +++ b/apps/spaces-demo/src/routes/api/space-subscription/+server.ts @@ -29,12 +29,19 @@ export const POST: RequestHandler = async ({ locals, request }) => { skey: SPACE_SKEY, }); try { - const subscription = await new SpacesProviderClient({ + const client = new SpacesProviderClient({ endpoint: PROVIDER_ENDPOINT, audience: PROVIDER_AUDIENCE, namespace: SPACE_TYPE, session: locals.session, - }).subscribeSpace(space); + }); + let subscription; + try { + subscription = await client.subscribeSpace(space); + } catch { + await client.authorizeSpace(space); + subscription = await client.subscribeSpace(space); + } return json(subscription, { headers: { "cache-control": "private, no-store" }, }); diff --git a/docs/experimental-spaces-alpha.md b/docs/experimental-spaces-alpha.md index 3898c83..27ac4e1 100644 --- a/docs/experimental-spaces-alpha.md +++ b/docs/experimental-spaces-alpha.md @@ -21,14 +21,14 @@ A Space incarnation maps to one opaque projection scope. Each writer repo is sta - authenticated delegation authorization; - exact-Space private collection routes; -- managing-app access checks; +- native public/member-list policy validation and optional managing-app access checks; - signed write/deletion notifications; - Queue consumption; - scheduled bounded reconciliation; - 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. Space types may declare an `authority-record-membership` policy. Its collection is automatically authority-only, and verified membership mutations maintain a generation-scoped entitlement index atomically with projection changes. Access checks and `listSpaces` use this index; full recovery switches records and entitlements through the same visible writer-generation pointer. Custom policy callbacks remain available and may provide an inverse `listSpaces` lookup. Rediscovery is accepted only for a watch already hidden by a verified deletion, so an ordinary read-authorized caller cannot rotate an active generation. +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. 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. diff --git a/packages/contrail-spaces-alpha/README.md b/packages/contrail-spaces-alpha/README.md index 3402592..81aeb1d 100644 --- a/packages/contrail-spaces-alpha/README.md +++ b/packages/contrail-spaces-alpha/README.md @@ -20,8 +20,8 @@ Pinned compatibility tuple: - signed notification routes, Queue jobs, leases, and scheduled reconciliation; - exact method-bound service authentication; - delegation access leases or an authoritative application callback; -- declarative authority-record membership with a materialized, generation-scoped entitlement index; -- paginated discovery of active Spaces authorized for the calling principal; +- 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. @@ -31,7 +31,6 @@ Public Contrail tables and anonymous methods are unchanged. Installing the packa ```ts import { createSpacesWorker } from "@atmo-dev/contrail-spaces-alpha/worker"; -import { authorityRecordMembership } from "@atmo-dev/contrail-spaces-alpha"; import { SpacesProviderClient, createSpace, diff --git a/packages/contrail-spaces-alpha/src/consumer.ts b/packages/contrail-spaces-alpha/src/consumer.ts index c0200d7..640a2d3 100644 --- a/packages/contrail-spaces-alpha/src/consumer.ts +++ b/packages/contrail-spaces-alpha/src/consumer.ts @@ -107,25 +107,94 @@ async function pdsProcedure( return readJson(response); } +export type SimpleSpacePolicyInput = + | { kind: "public" } + | { kind: "member-list" } + | { kind: "managing-app"; managingApp: string }; + +function simpleSpacePolicy(input: SimpleSpacePolicyInput): Record { + if (input.kind === "public") { + return { $type: "com.atproto.simplespace.defs#publicPolicy" }; + } + if (input.kind === "member-list") { + return { $type: "com.atproto.simplespace.defs#memberListPolicy" }; + } + return { + $type: "com.atproto.simplespace.defs#managingAppPolicy", + managingApp: input.managingApp, + }; +} + export function createSpace( session: AuthenticatedPdsSession, input: { type: string; skey?: string; - managingApp: string; + policy: SimpleSpacePolicyInput; }, ): Promise<{ uri: string }> { return pdsProcedure(session, "com.atproto.simplespace.createSpace", { type: input.type, ...(input.skey ? { skey: input.skey } : {}), - policy: { - $type: "com.atproto.simplespace.defs#managingAppPolicy", - managingApp: input.managingApp, - }, + policy: simpleSpacePolicy(input.policy), appAccess: { $type: "com.atproto.simplespace.defs#open" }, }); } +export function getSimpleSpace( + session: AuthenticatedPdsSession, + space: string, +): Promise<{ + uri: string; + policy?: { $type?: string; managingApp?: string }; + appAccess?: { $type?: string }; +}> { + parseSpaceUri(space); + return pdsQuery(session, "com.atproto.simplespace.getSpace", { space }); +} + +export function updateSimpleSpacePolicy( + session: AuthenticatedPdsSession, + space: string, + policy: SimpleSpacePolicyInput, +): Promise> { + parseSpaceUri(space); + return pdsProcedure(session, "com.atproto.simplespace.updateSpace", { + space, + policy: simpleSpacePolicy(policy), + }); +} + +export function addSimpleSpaceMember( + session: AuthenticatedPdsSession, + space: string, + did: string, +): Promise> { + parseSpaceUri(space); + return pdsProcedure(session, "com.atproto.simplespace.addMember", { space, did }); +} + +export function removeSimpleSpaceMember( + session: AuthenticatedPdsSession, + space: string, + did: string, +): Promise> { + parseSpaceUri(space); + return pdsProcedure(session, "com.atproto.simplespace.removeMember", { space, did }); +} + +export function listSimpleSpaceMembers( + session: AuthenticatedPdsSession, + input: { space: string; cursor?: string; limit?: number }, +): Promise<{ members: Array<{ did: string }>; cursor?: string }> { + parseSpaceUri(input.space); + return pdsQuery(session, "com.atproto.simplespace.listMembers", { + space: input.space, + ...(input.cursor ? { cursor: input.cursor } : {}), + ...(input.limit ? { limit: String(input.limit) } : {}), + }); +} + export function createSpaceRecord( session: AuthenticatedPdsSession, input: { diff --git a/packages/contrail-spaces-alpha/src/storage.ts b/packages/contrail-spaces-alpha/src/storage.ts index 3c26778..8c4b723 100644 --- a/packages/contrail-spaces-alpha/src/storage.ts +++ b/packages/contrail-spaces-alpha/src/storage.ts @@ -79,30 +79,6 @@ export async function initSpacesStorage( )`, `CREATE INDEX IF NOT EXISTS idx_spaces_access_expiry ON spaces_access_leases(expires_at)`, - `CREATE TABLE IF NOT EXISTS spaces_access_entries ( - space_uri TEXT NOT NULL, - space_generation INTEGER NOT NULL, - scope_key TEXT NOT NULL, - partition_key TEXT NOT NULL, - partition_generation INTEGER NOT NULL, - principal_did TEXT NOT NULL, - role TEXT NOT NULL, - source_uri TEXT NOT NULL, - source_cid TEXT, - created_at ${bigint} NOT NULL, - PRIMARY KEY (scope_key, partition_key, partition_generation, source_uri) - )`, - `CREATE INDEX IF NOT EXISTS idx_spaces_access_principal - ON spaces_access_entries(principal_did, space_uri, space_generation)`, - `CREATE INDEX IF NOT EXISTS idx_spaces_access_partition - ON spaces_access_entries(scope_key, partition_key, partition_generation)`, - `CREATE TABLE IF NOT EXISTS spaces_access_index_state ( - space_uri TEXT NOT NULL, - space_generation INTEGER NOT NULL, - policy_key TEXT NOT NULL, - indexed_at ${bigint} NOT NULL, - PRIMARY KEY (space_uri, space_generation) - )`, `CREATE TABLE IF NOT EXISTS spaces_credentials ( space_uri TEXT NOT NULL, space_generation INTEGER NOT NULL, @@ -222,165 +198,9 @@ export async function rediscoverSpace( return watch; } -export async function listActiveSpaceWatches( - db: Database, - limit: number, - cursor?: string, -): Promise { - const boundedLimit = Math.max(1, Math.min(1_001, Math.trunc(limit))); - const rows = await db.prepare( - `SELECT space_uri, authority_did, space_type, generation, status, - registration_expires_at, next_reconcile_at, last_reconciled_at, last_error - FROM spaces_watches - WHERE status = 'active' AND space_uri > ? - ORDER BY space_uri LIMIT ?`, - ).bind(cursor ?? "", boundedLimit).all>(); - return (rows.results ?? []).map(rowToWatch); -} - -export interface SpaceAccessEntryInput { - spaceUri: string; - spaceGeneration: number; - scopeKey: string; - partitionKey: string; - partitionGeneration: number; - principalDid: string; - role?: string; - sourceUri: string; - sourceCid?: string | null; -} - -export function upsertSpaceAccessEntryStatement( - db: Database, - input: SpaceAccessEntryInput, -): Statement { - return db.prepare( - `INSERT INTO spaces_access_entries - (space_uri, space_generation, scope_key, partition_key, - partition_generation, principal_did, role, source_uri, source_cid, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(scope_key, partition_key, partition_generation, source_uri) - DO UPDATE SET principal_did = excluded.principal_did, - role = excluded.role, source_cid = excluded.source_cid, - created_at = excluded.created_at`, - ).bind( - input.spaceUri, - input.spaceGeneration, - input.scopeKey, - input.partitionKey, - input.partitionGeneration, - input.principalDid, - input.role ?? "member", - input.sourceUri, - input.sourceCid ?? null, - Date.now(), - ); -} - -export function deleteSpaceAccessEntryStatement( - db: Database, - input: Pick, -): Statement { - return db.prepare( - `DELETE FROM spaces_access_entries - WHERE scope_key = ? AND partition_key = ? - AND partition_generation = ? AND source_uri = ?`, - ).bind( - input.scopeKey, - input.partitionKey, - input.partitionGeneration, - input.sourceUri, - ); -} - -export async function deleteSpaceAccessPartitionGeneration( - db: Database, - input: { scopeKey: string; partitionKey: string; partitionGeneration: number }, -): Promise { - await db.prepare( - `DELETE FROM spaces_access_entries - WHERE scope_key = ? AND partition_key = ? AND partition_generation = ?`, - ).bind(input.scopeKey, input.partitionKey, input.partitionGeneration).run(); -} - -export async function getVisibleSpaceWriterGeneration( - db: Database, - input: { scopeKey: string; partitionKey: string }, -): Promise { - const row = await db.prepare( - `SELECT visible_generation - FROM isolated_projection_partitions - WHERE scope_key = ? AND partition_key = ?`, - ).bind(input.scopeKey, input.partitionKey).first<{ visible_generation: unknown }>(); - return row ? Number(row.visible_generation) : null; -} - -export async function spaceAccessIndexPolicyKey( - db: Database, - input: { spaceUri: string; spaceGeneration: number }, -): Promise { - const row = await db.prepare( - `SELECT policy_key FROM spaces_access_index_state - WHERE space_uri = ? AND space_generation = ?`, - ).bind(input.spaceUri, input.spaceGeneration).first<{ policy_key: unknown }>(); - return row ? String(row.policy_key) : null; -} - -export async function replaceSpaceAccessIndex( - db: Database, - input: { - spaceUri: string; - spaceGeneration: number; - policyKey: string; - entries: readonly SpaceAccessEntryInput[]; - }, -): Promise { - await db.prepare( - `DELETE FROM spaces_access_entries - WHERE space_uri = ? AND space_generation = ?`, - ).bind(input.spaceUri, input.spaceGeneration).run(); - const chunkSize = 90; - for (let offset = 0; offset < input.entries.length; offset += chunkSize) { - await db.batch(input.entries.slice(offset, offset + chunkSize).map((entry) => - upsertSpaceAccessEntryStatement(db, entry) - )); - } - await db.prepare( - `INSERT INTO spaces_access_index_state - (space_uri, space_generation, policy_key, indexed_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(space_uri, space_generation) DO UPDATE SET - policy_key = excluded.policy_key, indexed_at = excluded.indexed_at`, - ).bind( - input.spaceUri, - input.spaceGeneration, - input.policyKey, - Date.now(), - ).run(); -} - -export async function hasProjectedSpaceAccess( - db: Database, - input: { spaceUri: string; spaceGeneration: number; principalDid: string }, -): Promise { - const row = await db.prepare( - `SELECT 1 AS allowed - FROM spaces_access_entries access - JOIN isolated_projection_partitions visible - ON visible.scope_key = access.scope_key - AND visible.partition_key = access.partition_key - AND visible.visible_generation = access.partition_generation - WHERE access.space_uri = ? AND access.space_generation = ? - AND access.principal_did = ? - LIMIT 1`, - ).bind(input.spaceUri, input.spaceGeneration, input.principalDid).first(); - return row !== null; -} - -export async function listProjectedAccessibleSpaceWatches( +export async function listConnectedSpaceWatches( db: Database, - input: { principalDid: string; cursor?: string; limit: number }, + input: { userDid: string; cursor?: string; limit: number; now?: number }, ): Promise { const limit = Math.max(1, Math.min(201, Math.trunc(input.limit))); const rows = await db.prepare( @@ -391,23 +211,19 @@ export async function listProjectedAccessibleSpaceWatches( AND ( watch.authority_did = ? OR EXISTS ( - SELECT 1 - FROM spaces_access_entries access - JOIN isolated_projection_partitions visible - ON visible.scope_key = access.scope_key - AND visible.partition_key = access.partition_key - AND visible.visible_generation = access.partition_generation + SELECT 1 FROM spaces_access_leases access WHERE access.space_uri = watch.space_uri AND access.space_generation = watch.generation - AND access.principal_did = ? + AND access.user_did = ? AND access.expires_at > ? ) ) ORDER BY watch.space_uri LIMIT ?`, ).bind( input.cursor ?? "", - input.principalDid, - input.principalDid, + input.userDid, + input.userDid, + input.now ?? Date.now(), limit, ).all>(); return (rows.results ?? []).map(rowToWatch); @@ -812,18 +628,8 @@ export async function purgeSpaceGeneration( kind: "isolated", key: spaceProjectionKey(watch.spaceUri, watch.generation), }); - await db.batch([ - db.prepare( - `DELETE FROM spaces_repo_state - WHERE space_uri = ? AND space_generation = ?`, - ).bind(watch.spaceUri, watch.generation), - db.prepare( - `DELETE FROM spaces_access_entries - WHERE space_uri = ? AND space_generation = ?`, - ).bind(watch.spaceUri, watch.generation), - db.prepare( - `DELETE FROM spaces_access_index_state - WHERE space_uri = ? AND space_generation = ?`, - ).bind(watch.spaceUri, watch.generation), - ]); + await db.prepare( + `DELETE FROM spaces_repo_state + WHERE space_uri = ? AND space_generation = ?`, + ).bind(watch.spaceUri, watch.generation).run(); } diff --git a/packages/contrail-spaces-alpha/src/sync.ts b/packages/contrail-spaces-alpha/src/sync.ts index 8fec499..4a7ca03 100644 --- a/packages/contrail-spaces-alpha/src/sync.ts +++ b/packages/contrail-spaces-alpha/src/sync.ts @@ -12,13 +12,11 @@ import { deleteIsolatedPartitionGeneration, hideIsolatedPartitionStatement, ingestRecords, - queryIsolatedRecords, rebuildIsolatedCountsStatements, recordTimeUs, type ContrailConfig, type Database, type IngestEvent, - type Statement, } from "@atmo-dev/contrail"; import { getRepoCar, @@ -35,13 +33,9 @@ import { import { acquireSyncLease, deleteRepoState, - deleteSpaceAccessEntryStatement, - deleteSpaceAccessPartitionGeneration, getRepoState, getSpaceWatch, - getVisibleSpaceWriterGeneration, hideDeletedSpace, - listActiveSpaceWatches, listDueWatches, listRepoStates, loadCredential, @@ -49,39 +43,21 @@ import { purgeSpaceGeneration, releaseSyncLease, renewSyncLease, - replaceSpaceAccessIndex, saveRepoStateStatement, - spaceAccessIndexPolicyKey, updateWatch, - upsertSpaceAccessEntryStatement, - type SpaceAccessEntryInput, type SpaceRepoState, type SpaceWatch, } from "./storage"; import { formatSpaceRecordUri, parseSpaceUri, spaceProjectionKey } from "./uri"; -export interface AuthorityRecordMembershipPolicy { - kind: "authority-record-membership"; - collection: string; - principalField: string; -} - -export function authorityRecordMembership( - input: Omit, -): AuthorityRecordMembershipPolicy { - return { kind: "authority-record-membership", ...input }; -} +export type SpaceUserPolicy = "public" | "member-list" | "managing-app"; export interface SpaceTypeConfig { collections: readonly string[]; skey?: string; - /** Built-in, materialized access policy for owner-controlled membership. */ - access?: AuthorityRecordMembershipPolicy; - /** Additional collections projected only from the Space authority's repo. */ - authorityOnlyCollections?: readonly string[]; - /** Require this provider to be the managing app. Defaults to true. Set false - * only for a deliberately supported upstream-owned policy. */ - requireManagingApp?: boolean; + /** User policy asserted against the authority PDS description. Defaults to + * `managing-app` for backwards compatibility. */ + policy?: SpaceUserPolicy; } export interface SpacesSyncConfig { @@ -131,137 +107,9 @@ function asSignedCommit(input: SignedCommitInput): SignedCommit { function collectionAllowed( config: SpacesSyncConfig, watch: SpaceWatch, - repoDid: string, collection: string, ): boolean { - const type = config.spaceTypes[watch.spaceType]; - if (!type?.collections.includes(collection)) return false; - const authorityOnly = type.authorityOnlyCollections?.includes(collection) || - type.access?.collection === collection; - return !authorityOnly || repoDid === watch.authorityDid; -} - -const VALID_DID = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/; - -function recordField(record: string | null, field: string): unknown { - if (record === null) return undefined; - let value: unknown = JSON.parse(record); - for (const segment of field.split(".")) { - if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; - value = (value as Record)[segment]; - } - return value; -} - -function accessProjectionStatements( - db: Database, - config: SpacesSyncConfig, - watch: SpaceWatch, - partition: string, - partitionGeneration: number, - events: readonly IngestEvent[], -): Statement[] { - const policy = config.spaceTypes[watch.spaceType]?.access; - if (!policy || partition !== watch.authorityDid) return []; - const scopeKey = spaceProjectionKey(watch.spaceUri, watch.generation); - return events - .filter((event) => event.collection === policy.collection) - .map((event) => { - const identity = { - scopeKey, - partitionKey: partition, - partitionGeneration, - sourceUri: event.uri, - }; - if (event.operation === "delete") { - return deleteSpaceAccessEntryStatement(db, identity); - } - const principalDid = recordField(event.record, policy.principalField); - if (typeof principalDid !== "string" || !VALID_DID.test(principalDid)) { - throw new Error( - `Membership record ${event.uri} has an invalid ${policy.principalField}`, - ); - } - return upsertSpaceAccessEntryStatement(db, { - ...identity, - spaceUri: watch.spaceUri, - spaceGeneration: watch.generation, - principalDid, - sourceCid: event.cid, - }); - }); -} - -/** Additive migration for policies enabled after records were already - * projected. New incremental/full syncs maintain the index transactionally. */ -export async function initializeSpaceAccessPolicies( - db: Database, - config: Pick, -): Promise { - let watchCursor: string | undefined; - while (true) { - const watches = await listActiveSpaceWatches(db, 200, watchCursor); - for (const watch of watches) { - const policy = config.spaceTypes[watch.spaceType]?.access; - if (!policy) continue; - const policyKey = JSON.stringify([policy.kind, policy.collection, policy.principalField]); - const existingKey = await spaceAccessIndexPolicyKey(db, { - spaceUri: watch.spaceUri, - spaceGeneration: watch.generation, - }); - if (existingKey === policyKey) continue; - const short = Object.entries(config.projection.collections).find( - ([, collection]) => collection.collection === policy.collection, - )?.[0]; - if (!short) throw new Error(`Unknown membership collection ${policy.collection}`); - const scopeKey = spaceProjectionKey(watch.spaceUri, watch.generation); - const partitionGeneration = await getVisibleSpaceWriterGeneration(db, { - scopeKey, - partitionKey: watch.authorityDid, - }); - const entries: SpaceAccessEntryInput[] = []; - if (partitionGeneration !== null) { - let cursor: string | undefined; - do { - const page = await queryIsolatedRecords(db, config.projection, { - scope: { kind: "isolated", key: scopeKey }, - collection: short, - did: watch.authorityDid, - limit: 200, - cursor, - }); - for (const row of page.records) { - const principalDid = recordField( - typeof row.record === "string" ? row.record : JSON.stringify(row.record), - policy.principalField, - ); - if (typeof principalDid !== "string" || !VALID_DID.test(principalDid)) { - throw new Error(`Membership record ${row.uri} has an invalid principal`); - } - entries.push({ - spaceUri: watch.spaceUri, - spaceGeneration: watch.generation, - scopeKey, - partitionKey: watch.authorityDid, - partitionGeneration, - principalDid, - sourceUri: String(row.uri), - sourceCid: row.cid === null ? null : String(row.cid), - }); - } - cursor = page.cursor; - } while (cursor); - } - await replaceSpaceAccessIndex(db, { - spaceUri: watch.spaceUri, - spaceGeneration: watch.generation, - policyKey, - entries, - }); - } - if (watches.length < 200) break; - watchCursor = watches.at(-1)!.spaceUri; - } + return config.spaceTypes[watch.spaceType]?.collections.includes(collection) ?? false; } export class SpacesSyncEngine { @@ -319,12 +167,16 @@ export class SpacesSyncEngine { if (description.uri !== watch.spaceUri) { throw new Error("Space authority returned a different URI"); } - const managingPolicy = description.policy?.$type === - "com.atproto.simplespace.defs#managingAppPolicy"; - if (type.requireManagingApp !== false && !managingPolicy) { - throw new Error("Space does not use the configured managing-app policy"); + const expectedPolicy = type.policy ?? "managing-app"; + const expectedPolicyType = { + public: "com.atproto.simplespace.defs#publicPolicy", + "member-list": "com.atproto.simplespace.defs#memberListPolicy", + "managing-app": "com.atproto.simplespace.defs#managingAppPolicy", + }[expectedPolicy]; + if (description.policy?.$type !== expectedPolicyType) { + throw new Error(`Space does not use the configured ${expectedPolicy} policy`); } - if (managingPolicy && + if (expectedPolicy === "managing-app" && description.policy.managingApp !== this.config.serviceAudience) { throw new Error("Space is managed by a different application"); } @@ -546,12 +398,7 @@ export class SpacesSyncEngine { cid: operation.cid ? parseCid(operation.cid) : null, prev: operation.prev ? parseCid(operation.prev) : null, }); - if (!collectionAllowed( - this.config, - watch, - local.repoDid, - operation.collection, - )) continue; + if (!collectionAllowed(this.config, watch, operation.collection)) continue; if (operation.cid && operation.value === undefined) { throw new Error("Incremental operation omitted its record value"); } @@ -631,17 +478,7 @@ export class SpacesSyncEngine { partition: local.repoDid, generation: local.visibleWriterGeneration, }), - trailingStatements: [ - ...accessProjectionStatements( - this.db, - this.config, - watch, - local.repoDid, - local.visibleWriterGeneration, - events, - ), - checkpoint, - ], + trailingStatements: [checkpoint], skipDiagnostics: true, }); } else { @@ -680,7 +517,7 @@ export class SpacesSyncEngine { const events: IngestEvent[] = []; const observedAt = Date.now() * 1000; for (const record of recovered.records) { - if (!collectionAllowed(this.config, watch, repoDid, record.collection)) continue; + if (!collectionAllowed(this.config, watch, record.collection)) continue; events.push(createIngestEvent({ uri: formatSpaceRecordUri({ spaceUri: watch.spaceUri, @@ -716,11 +553,6 @@ export class SpacesSyncEngine { }; await assertLease(); await deleteIsolatedPartitionGeneration(this.db, this.config.projection, target); - await deleteSpaceAccessPartitionGeneration(this.db, { - scopeKey: target.scope.key, - partitionKey: target.partition, - partitionGeneration: target.generation, - }); const chunkSize = this.config.maxAtomicMutations ?? 10; for (let offset = 0; offset < events.length; offset += chunkSize) { await assertLease(); @@ -732,14 +564,6 @@ export class SpacesSyncEngine { { projection: createIsolatedProjection(target), authoritativeSourceObservation: true, - trailingStatements: accessProjectionStatements( - this.db, - this.config, - watch, - repoDid, - writerGeneration, - chunk, - ), skipDiagnostics: true, }, ); @@ -773,11 +597,6 @@ export class SpacesSyncEngine { partition: repoDid, generation: prior.visibleWriterGeneration, }); - await deleteSpaceAccessPartitionGeneration(this.db, { - scopeKey: this.scope(watch).key, - partitionKey: repoDid, - partitionGeneration: prior.visibleWriterGeneration, - }); } } @@ -805,11 +624,6 @@ export class SpacesSyncEngine { partition: state.repoDid, generation: state.visibleWriterGeneration, }); - await deleteSpaceAccessPartitionGeneration(this.db, { - scopeKey: this.scope(watch).key, - partitionKey: state.repoDid, - partitionGeneration: state.visibleWriterGeneration, - }); await this.config.onInvalidate?.(watch.spaceUri); } } diff --git a/packages/contrail-spaces-alpha/src/worker.ts b/packages/contrail-spaces-alpha/src/worker.ts index e0ded61..a76e033 100644 --- a/packages/contrail-spaces-alpha/src/worker.ts +++ b/packages/contrail-spaces-alpha/src/worker.ts @@ -17,10 +17,9 @@ import { ensureSpaceWatch, getSpaceWatch, hasAccessLease, - hasProjectedSpaceAccess, hideDeletedSpace, initSpacesStorage, - listProjectedAccessibleSpaceWatches, + listConnectedSpaceWatches, purgeSpaceGeneration, rediscoverSpace, saveAccessLease, @@ -29,7 +28,6 @@ import { type SpaceWatch, } from "./storage"; import { - initializeSpaceAccessPolicies, SpacesSyncEngine, type SpaceTypeConfig, } from "./sync"; @@ -73,8 +71,8 @@ export interface SpacesWorkerOptions; - /** Optional inverse lookup for custom policies. Built-in membership - * policies are enumerated from the materialized access index. */ + /** Optional inverse lookup for custom managing-app policies. Native PDS + * policies are listed from successful provider authorization leases. */ listSpaces?( userDid: string, context: { env: Env; db: Database }, @@ -416,6 +414,7 @@ function buildProviderLexicons(config: ContrailConfig, subscriptions: boolean): id: `${namespace}.${LIST_SPACES_METHOD}`, defs: { main: { type: "query", + description: "Lists active Spaces this provider has connected for the caller; this is not global protocol membership discovery.", parameters: { type: "params", properties: { cursor: { type: "string" }, limit: { type: "integer", minimum: 1, maximum: 200 }, @@ -529,29 +528,13 @@ export function createSpacesWorker (type.policy ?? "managing-app") === "managing-app", + ); + if (managingAppEnabled && !options.authorization) { + throw new TypeError("Managing-app Space types require an authoritative authorizer"); + } for (const [spaceType, type] of Object.entries(options.spaceTypes)) { - if (type.requireManagingApp !== false && !type.access && !options.authorization) { - throw new TypeError( - `Space type ${spaceType} requires an authoritative managing-app access policy`, - ); - } - if (type.access) { - if (!type.collections.includes(type.access.collection)) { - throw new TypeError( - `Membership collection ${type.access.collection} is not allowed by Space type ${spaceType}`, - ); - } - if (!/^[a-zA-Z0-9_.]+$/.test(type.access.principalField)) { - throw new TypeError(`Invalid membership principal field ${type.access.principalField}`); - } - } - for (const nsid of type.authorityOnlyCollections ?? []) { - if (!type.collections.includes(nsid)) { - throw new TypeError( - `Authority-only collection ${nsid} is not allowed by Space type ${spaceType}`, - ); - } - } for (const nsid of type.collections) { const collection = Object.values(projection.collections).find( (candidate) => candidate.collection === nsid, @@ -582,7 +565,7 @@ export function createSpacesWorker [ collectionMethod(namespace, short, LIST_SPACE_RECORDS_METHOD), collectionMethod(namespace, short, GET_SPACE_RECORD_METHOD), @@ -634,13 +617,7 @@ export function createSpacesWorker { - await initSpacesStorage(db, projection); - await initializeSpaceAccessPolicies(db, { - projection, - spaceTypes: options.spaceTypes, - }); - })(); + init = initSpacesStorage(db, projection); initialized.set(db as object, init); } const engine = new SpacesSyncEngine(db, { @@ -683,16 +660,9 @@ export function createSpacesWorker => { if (input.userDid === watch.authorityDid) return true; - const policy = options.spaceTypes[watch.spaceType]?.access; - if (policy?.kind === "authority-record-membership") { - return hasProjectedSpaceAccess(db, { - principalDid: input.userDid, - spaceUri: input.spaceUri, - spaceGeneration: watch.generation, - }); - } - if (options.authorization) { - return options.authorization.authorize(input, { env, db }); + const policy = options.spaceTypes[watch.spaceType]?.policy ?? "managing-app"; + if (policy === "managing-app") { + return options.authorization!.authorize(input, { env, db }); } return hasAccessLease(db, { userDid: input.userDid, @@ -889,8 +859,8 @@ export function createSpacesWorker 200 || (cursor?.length ?? 0) > 2_048) { return privateJson({ error: "InvalidRequest" }, 400); } - const indexed = await listProjectedAccessibleSpaceWatches(db, { - principalDid: verified.did!, + const indexed = await listConnectedSpaceWatches(db, { + userDid: verified.did!, cursor, limit: requestedLimit + 1, }); @@ -1024,6 +994,7 @@ export function createSpacesWorker { - it("backfills only authority-authored membership from the visible projection", async () => { - const db = createSqliteDatabase(":memory:"); - await initSpacesStorage(db, projection); - const watch = await ensureSpaceWatch(db, { spaceUri: space }); - const scope = { - kind: "isolated" as const, - key: spaceProjectionKey(space, watch.generation), - }; - await ingestRecords(db, [ - memberEvent("did:plc:alice", "owner-member", "did:plc:carol"), - ], projection, { - projection: createIsolatedProjection({ - scope, - partition: "did:plc:alice", - generation: 1, - activate: true, - }), - skipDiagnostics: true, - }); - await ingestRecords(db, [ - memberEvent("did:plc:bob", "forged-member", "did:plc:dave"), - ], projection, { - projection: createIsolatedProjection({ - scope, - partition: "did:plc:bob", - generation: 1, - activate: true, - }), - skipDiagnostics: true, - }); - - await initializeSpaceAccessPolicies(db, { - projection, - spaceTypes: { - "garden.atmo.circle": { - collections: [memberCollection], - access: authorityRecordMembership({ - collection: memberCollection, - principalField: "subject", - }), - }, - }, - }); - - expect(await hasProjectedSpaceAccess(db, { - spaceUri: space, - spaceGeneration: 1, - principalDid: "did:plc:carol", - })).toBe(true); - expect(await hasProjectedSpaceAccess(db, { - spaceUri: space, - spaceGeneration: 1, - principalDid: "did:plc:dave", - })).toBe(false); - }); -}); diff --git a/packages/contrail-spaces-alpha/tests/consumer.test.ts b/packages/contrail-spaces-alpha/tests/consumer.test.ts index 364c64c..fe44681 100644 --- a/packages/contrail-spaces-alpha/tests/consumer.test.ts +++ b/packages/contrail-spaces-alpha/tests/consumer.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "vitest"; import { + addSimpleSpaceMember, + createSpace, formatSpacePermissionScope, + listSimpleSpaceMembers, + removeSimpleSpaceMember, spacesConsumerOAuthScopes, + updateSimpleSpacePolicy, + type AuthenticatedPdsSession, } from "../src/consumer"; describe("consumer scopes", () => { @@ -26,4 +32,46 @@ describe("consumer scopes", () => { expect(scopes[2]).toContain("garden.atmo.circle.subscribeSpace"); expect(scopes[2]).not.toContain("notifyWrite"); }); -}); + + it("uses the PDS-native member-list procedures", async () => { + const calls: Array<{ path: string; body?: Record }> = []; + const session: AuthenticatedPdsSession = { + did: "did:plc:alice", + async handle(path, init) { + calls.push({ + path, + body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined, + }); + if (path.includes("listMembers")) { + return Response.json({ members: [{ did: "did:plc:bob" }] }); + } + return Response.json(path.includes("createSpace") + ? { uri: "at://did:plc:alice/space/garden.atmo.circle/self" } + : {}); + }, + }; + const space = "at://did:plc:alice/space/garden.atmo.circle/self"; + await createSpace(session, { + type: "garden.atmo.circle", + skey: "self", + policy: { kind: "member-list" }, + }); + await addSimpleSpaceMember(session, space, "did:plc:bob"); + expect((await listSimpleSpaceMembers(session, { space })).members).toEqual([ + { did: "did:plc:bob" }, + ]); + await removeSimpleSpaceMember(session, space, "did:plc:bob"); + await updateSimpleSpacePolicy(session, space, { kind: "member-list" }); + + expect(calls[0].body?.policy).toEqual({ + $type: "com.atproto.simplespace.defs#memberListPolicy", + }); + expect(calls.map((call) => call.path)).toEqual([ + "/xrpc/com.atproto.simplespace.createSpace", + "/xrpc/com.atproto.simplespace.addMember", + `/xrpc/com.atproto.simplespace.listMembers?space=${encodeURIComponent(space)}`, + "/xrpc/com.atproto.simplespace.removeMember", + "/xrpc/com.atproto.simplespace.updateSpace", + ]); + }); +}); \ No newline at end of file diff --git a/packages/contrail-spaces-alpha/tests/storage.test.ts b/packages/contrail-spaces-alpha/tests/storage.test.ts index 3168902..37b3c10 100644 --- a/packages/contrail-spaces-alpha/tests/storage.test.ts +++ b/packages/contrail-spaces-alpha/tests/storage.test.ts @@ -7,16 +7,14 @@ import { ensureSpaceWatch, getSpaceWatch, hasAccessLease, - hasProjectedSpaceAccess, hideDeletedSpace, initSpacesStorage, - listProjectedAccessibleSpaceWatches, + listConnectedSpaceWatches, loadCredential, rediscoverSpace, renewSyncLease, saveAccessLease, saveCredential, - upsertSpaceAccessEntryStatement, } from "../src/storage"; const projection = resolveConfig({ @@ -82,64 +80,28 @@ describe("Spaces storage boundaries", () => { })).toBe(false); }); - it("cuts membership discovery over with the visible writer generation", async () => { + it("lists only owned or currently authorized provider connections", async () => { const db = createSqliteDatabase(":memory:"); await initSpacesStorage(db, projection); - await ensureSpaceWatch(db, { spaceUri: space }); - const scopeKey = `${space}\u00001`; - const entry = (principalDid: string, generation: number, sourceUri: string) => - upsertSpaceAccessEntryStatement(db, { - spaceUri: space, - spaceGeneration: 1, - scopeKey, - partitionKey: "did:plc:alice", - partitionGeneration: generation, - principalDid, - sourceUri, - }); - - await entry("did:plc:bob", 2, `${space}/did:plc:alice/member/bob`).run(); - expect(await hasProjectedSpaceAccess(db, { - spaceUri: space, - spaceGeneration: 1, - principalDid: "did:plc:bob", - })).toBe(false); - - await db.prepare( - `INSERT INTO isolated_projection_partitions - (scope_key, partition_key, visible_generation, updated_at) - VALUES (?, ?, 2, 1)`, - ).bind(scopeKey, "did:plc:alice").run(); - expect(await hasProjectedSpaceAccess(db, { - spaceUri: space, - spaceGeneration: 1, - principalDid: "did:plc:bob", - })).toBe(true); - expect((await listProjectedAccessibleSpaceWatches(db, { - principalDid: "did:plc:bob", + const watch = await ensureSpaceWatch(db, { spaceUri: space }); + expect(await listConnectedSpaceWatches(db, { + userDid: "did:plc:bob", limit: 10, - })).map((watch) => watch.spaceUri)).toEqual([space]); - - await entry("did:plc:carol", 3, `${space}/did:plc:alice/member/carol`).run(); - expect(await hasProjectedSpaceAccess(db, { - spaceUri: space, - spaceGeneration: 1, - principalDid: "did:plc:carol", - })).toBe(false); - await db.prepare( - `UPDATE isolated_projection_partitions SET visible_generation = 3 - WHERE scope_key = ? AND partition_key = ?`, - ).bind(scopeKey, "did:plc:alice").run(); - expect(await hasProjectedSpaceAccess(db, { - spaceUri: space, - spaceGeneration: 1, - principalDid: "did:plc:bob", - })).toBe(false); - expect(await hasProjectedSpaceAccess(db, { + })).toEqual([]); + await saveAccessLease(db, { + userDid: "did:plc:bob", spaceUri: space, - spaceGeneration: 1, - principalDid: "did:plc:carol", - })).toBe(true); + generation: watch.generation, + expiresAt: Date.now() + 60_000, + }); + expect((await listConnectedSpaceWatches(db, { + userDid: "did:plc:bob", + limit: 10, + })).map((item) => item.spaceUri)).toEqual([space]); + expect((await listConnectedSpaceWatches(db, { + userDid: "did:plc:alice", + limit: 10, + })).map((item) => item.spaceUri)).toEqual([space]); }); it("allows only one unexpired sync-lease owner and fences stale owners", async () => { diff --git a/packages/contrail-spaces-alpha/tests/worker-query.test.ts b/packages/contrail-spaces-alpha/tests/worker-query.test.ts index e14c7ad..aca12db 100644 --- a/packages/contrail-spaces-alpha/tests/worker-query.test.ts +++ b/packages/contrail-spaces-alpha/tests/worker-query.test.ts @@ -184,21 +184,29 @@ describe("Spaces Worker private query boundary", () => { }]); }); - it("rejects authority-only collections outside the Space allow-list", () => { - expect(() => createSpacesWorker({ + it("requires an application authorizer only for managing-app policies", () => { + const base = { projection, lexicons, service: { endpoint: "https://spaces.atmo.garden", audience, }, + }; + expect(() => createSpacesWorker({ + ...base, + spaceTypes: { + "garden.atmo.circle": { collections: [collection] }, + }, + })).toThrow(/Managing-app/); + expect(() => createSpacesWorker({ + ...base, spaceTypes: { "garden.atmo.circle": { collections: [collection], - authorityOnlyCollections: ["garden.atmo.circle.member"], + policy: "member-list", }, }, - authorization: { authorize: () => true }, - })).toThrow(/Authority-only collection/); + })).not.toThrow(); }); });