diff --git a/.changeset/dry-ideas-divide.md b/.changeset/dry-ideas-divide.md new file mode 100644 index 0000000..7754650 --- /dev/null +++ b/.changeset/dry-ideas-divide.md @@ -0,0 +1,5 @@ +--- +"@atmo-dev/contrail": minor +--- + +add permissioned data stuff, change endpoints, add lexicon publishing diff --git a/docs/spaces-later.md b/docs/spaces-later.md new file mode 100644 index 0000000..58a3945 --- /dev/null +++ b/docs/spaces-later.md @@ -0,0 +1,75 @@ +# Spaces: things to revisit later + +Deferred items from the spaces design review. Not blocking shipping; keep an +eye on these as usage grows or as the permissioned-data spec firms up. See +also [spaces-spec-mapping.md](./spaces-spec-mapping.md). + +## Hydrated members endpoint +`space.listMembers` today returns raw `{did, perms, addedAt, addedBy}` rows. +Every client ends up wanting profile hydration (handle, displayName, avatar). +Add `space.getMembers` (or extend `listMembers`) with: +- Cursor-based pagination (current endpoint is unbounded) +- Optional `hydrate=true` that joins against the configured profile collection +- Sort options (joined-at, alphabetical by handle) + +## More tests +The e2e + invite tests cover the happy paths. Gaps: +- Non-owner calling `createSpace` (should succeed — anyone can create their + own) vs non-member trying to use someone else's space URI +- App policy enforcement in both `allow` and `deny` modes (clientId checks) +- `deleteRecord` by owner on another author's record +- Re-querying a soft-deleted space returns NotFound +- `transferOwnership` with invalid/non-member/read-only-member targets +- `leaveSpace` by owner (should error) +- `whoami` for owner, member, non-member + +## Config-change behavior +What happens today if a deployment: +- Adds a new collection after spaces already contain data? The per-collection + table (`spaces_records_`) won't exist until schema init re-runs. + `listCollections` swallows the missing-table error, but `putRecord` / + `listRecords` will throw. Document and/or auto-create on demand. +- Toggles `allowInSpaces: false` on an existing collection? Table stays but + routes stop dispatching. Orphaned data. +- Renames a collection's `collection` NSID? `shortNameForNsid` may change, + so the derived table name changes — existing records become unreachable. + +Need a config-drift audit (or migration) story. + +## Verify `clientId` actually flows through +`checkAccess` uses `ServiceAuth.clientId` for app policy checks. Confirm: +- JWT verifier actually extracts `client_id` from real atproto service tokens + (not just our test fixture) +- App policy with a populated `apps[]` blocks/allows correctly in practice +- Empty `apps[]` under `mode: "deny"` blocks everyone (is that what we want?) + +If `clientId` is `undefined` in the wild, app policy is decorative. + +## Join requests (spec-adjacent, not in spec) +The rough spec punts invite/onboarding mechanics to apps. A natural fit given +our invite system: a fourth kind `request` where `redeem` creates a pending +row for the owner to approve. Likely wants: +- `space.requestJoin` → creates pending row +- `space.listJoinRequests` (owner) → pending rows +- `space.approveJoinRequest` / `space.denyJoinRequest` + +Should this live under `space.*` or a separate extras namespace? + +## Real-time: SSE / subscriptions +Every collaborative app wants "new records in this space, as they land." +The spec's sync model uses write-notifications through the space owner; we +don't have that yet. Lightweight interim: Server-Sent Events on +`space.subscribeRecords?spaceUri=&collection=`. Works for first-party apps +right away; swap to the real thing later. + +## Namespace split for contrail-specific extras +Right now `space.invite.*`, `space.whoami`, `space.leaveSpace`, +`space.transferOwnership` all live alongside spec-adjacent endpoints. If the +spec lands with different names or semantics for some of these, migration +cost is "rename everywhere." A second namespace +(`.spaceExt.*` or `.contrail.*`) for clearly-off-spec features would +keep the `space.*` surface close to whatever the spec becomes. + +Decision: split them. Pick a namespace name, move at least `invite.*` and +`whoami`; `leaveSpace` / `transferOwnership` are ambiguous (spec implies +ownership transfer is a thing, just doesn't name it). diff --git a/docs/spaces-spec-mapping.md b/docs/spaces-spec-mapping.md new file mode 100644 index 0000000..feb445e --- /dev/null +++ b/docs/spaces-spec-mapping.md @@ -0,0 +1,109 @@ +# Spaces: mapping to the atproto permissioned-data rough spec + +This is the map between contrail's spaces implementation and the rough spec +sketched at (Daniel Holmgren, +March 2026). The spec is explicitly low-confidence and subject to change — so +is this doc. The goal is to make it obvious, when the real spec lands, where +contrail already lines up and where it needs to change. + +Contrail is a backend-in-a-bottle / simple appview, not a PDS. For permissioned +data it currently stores everything in its own database; the plan is to +switch permissioned reads to come from users' PDSes once the protocol-level +flow is shipped (same story we already have for public records via jetstream). + +--- + +## Concept-by-concept alignment + +| Spec concept | Contrail | Alignment | Notes | +| ------------------------------ | ------------------------------------------------------------ | --------- | ------------------------------------------------------------------------------ | +| Space owner (DID) | `spaces.owner_did` | ✅ | 1:1 | +| Space type (NSID) | `spaces.type` | ✅ | 1:1 | +| Space key / skey | `spaces.key` | ✅ | TID-generated when caller omits it | +| Record addressing 6-tuple | `(owner, type, key, author-did, collection, rkey)` | ✅ | Storage is keyed by `(space_uri, did, rkey)`; `space_uri` encodes the first 3 | +| Single ACL = member list | `spaces_members (did, perms)` | ✅ | Only `read`/`write` perms; owner is implicit write | +| Space credential (2–4h token) | _none; service-auth JWTs used directly_ | ❌ | Fine while contrail is a single appview. Add a shim when real PDS sync lands | +| App allow/deny | `appPolicy {mode, apps[]}` | ✅ | Matches spec's default-allow / default-deny model. Visible only to the owner | +| Permissioned repo per user | single DB (`spaces_records_`) | ⚠️ | Structurally compatible — keyed per `(space, author)`. Federation is future | +| ECMH commit / sync log | _none_ | ❌ | Out of scope until federated sync exists | +| Pull-based sync, write notifs | _none_ | ❌ | Same | +| URI scheme | `at:////` for spaces; records not exposed | ⚠️ | Spec floats `ats://`. We centralize construction in `src/core/spaces/uri.ts` | +| Authority model for record URI | sidestepped (records keyed, not URI-addressed) | ✅ | Spec is undecided; we don't commit either way | +| Managing app routing | _none (join-requests etc. not modeled yet)_ | ⚠️ | See [spaces-later.md](./spaces-later.md) | + +--- + +## Endpoints + +All endpoints are emitted under `.space.*` from templates in +`spaces-lexicon-templates/`. + +### Read +- `space.listSpaces` — caller's spaces (scope=member|owner) +- `space.getSpace` — metadata; supports `?inviteToken=` bearer read +- `space.listMembers` — members for a space (member/owner only) +- `space.listRecords` — space-scoped record listing; bearer-read supported +- `space.getRecord` — single record; bearer-read supported +- `space.whoami` — caller's relationship to a space (extra; not in spec) + +### Write +- `space.putRecord` +- `space.deleteRecord` + +### Owner-gated (space management) +- `space.createSpace` +- `space.addMember` +- `space.removeMember` +- `space.leaveSpace` — self-remove; owner must transfer first (extra) +- `space.transferOwnership` — new owner must already be a write member (extra) + +### Invites (extra; not in the spec) +- `space.invite.create` — returns raw token once; hash stored +- `space.invite.redeem` +- `space.invite.list` +- `space.invite.revoke` + +Invites have three kinds: `join`, `read`, `read-join`. `read` tokens grant +bearer-only anonymous read access; `read-join` does both; `join` requires a +signed-in caller and grants a membership row. None of this is in the spec — +it lives here because the spec explicitly defers invite/onboarding mechanics +to apps, and shipping a working invite primitive is useful for every consumer. + +### Collection integration +Per-collection `listRecords` / `getRecord` accept `?spaceUri=` (space-scoped) +and optional `?inviteToken=`. Without `spaceUri`, authenticated callers get +public + own-member-spaces union (see `src/core/router/collection.ts`). + +--- + +## Migration readiness + +Hasn't shipped yet → nothing to migrate, but the shape of what changes when +the real spec lands: + +1. **URI scheme swap (if any).** Centralized in `src/core/spaces/uri.ts` — + flip `at://` to `ats://` (or whatever) in two helpers and every caller + follows. +2. **Space-credential flow.** Needs an endpoint that mints short-lived tokens + from an owner key, and a verifier that accepts them in place of a + service-auth JWT on read paths. The current JWT middleware + (`src/core/spaces/auth.ts`) is the right anchor for this. +3. **Read records from PDSes.** Mirrors the jetstream ingestion we already do + for public data: consume permissioned-repo sync, write into the same + `spaces_records_` tables. The storage schema is already keyed per + `(space, author)` so no migration needed on that side. +4. **ECMH commits & sync log.** Greenfield; unrelated to existing storage. +5. **Endpoint naming.** Spec doesn't pin XRPC names. When it does, rename + lexicon template files + routes. No storage churn. + +### Design decisions worth preserving +- Keep the member list as the single ACL. Don't add roles or per-collection + policies just because it's easy — the spec is emphatic that the member list + is _the_ ACL. +- Keep `space.whoami`, `space.leaveSpace`, `space.transferOwnership`, and the + invite endpoints clearly labeled as contrail extras in docs. If the spec + ends up naming some of them, renaming is cheap; relying on them from the + base spec isn't. +- Don't mint a canonical record URI. The spec is undecided on the authority + (user DID vs space owner DID); storing records by tuple avoids picking. + diff --git a/spaces-lexicon-templates/admin/addMember.json b/spaces-lexicon-templates/addMember.json similarity index 95% rename from spaces-lexicon-templates/admin/addMember.json rename to spaces-lexicon-templates/addMember.json index 5267981..e591863 100644 --- a/spaces-lexicon-templates/admin/addMember.json +++ b/spaces-lexicon-templates/addMember.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "tools.atmo.space.admin.addMember", + "id": "tools.atmo.space.addMember", "defs": { "main": { "type": "procedure", diff --git a/spaces-lexicon-templates/admin/createSpace.json b/spaces-lexicon-templates/createSpace.json similarity index 96% rename from spaces-lexicon-templates/admin/createSpace.json rename to spaces-lexicon-templates/createSpace.json index fc4d4d0..73a131d 100644 --- a/spaces-lexicon-templates/admin/createSpace.json +++ b/spaces-lexicon-templates/createSpace.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "tools.atmo.space.admin.createSpace", + "id": "tools.atmo.space.createSpace", "defs": { "main": { "type": "procedure", diff --git a/spaces-lexicon-templates/leaveSpace.json b/spaces-lexicon-templates/leaveSpace.json new file mode 100644 index 0000000..62706e9 --- /dev/null +++ b/spaces-lexicon-templates/leaveSpace.json @@ -0,0 +1,34 @@ +{ + "lexicon": 1, + "id": "tools.atmo.space.leaveSpace", + "defs": { + "main": { + "type": "procedure", + "description": "Remove the caller from a space's member list. The owner cannot leave — they must transferOwnership first.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["spaceUri"], + "properties": { + "spaceUri": { "type": "string", "format": "at-uri" } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["ok"], + "properties": { + "ok": { "type": "boolean" } + } + } + }, + "errors": [ + { "name": "NotFound" }, + { "name": "InvalidRequest", "description": "Raised if the caller is the space owner." } + ] + } + } +} diff --git a/spaces-lexicon-templates/admin/removeMember.json b/spaces-lexicon-templates/removeMember.json similarity index 94% rename from spaces-lexicon-templates/admin/removeMember.json rename to spaces-lexicon-templates/removeMember.json index 8acdef0..f69ef5b 100644 --- a/spaces-lexicon-templates/admin/removeMember.json +++ b/spaces-lexicon-templates/removeMember.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "tools.atmo.space.admin.removeMember", + "id": "tools.atmo.space.removeMember", "defs": { "main": { "type": "procedure", diff --git a/spaces-lexicon-templates/transferOwnership.json b/spaces-lexicon-templates/transferOwnership.json new file mode 100644 index 0000000..7b45767 --- /dev/null +++ b/spaces-lexicon-templates/transferOwnership.json @@ -0,0 +1,36 @@ +{ + "lexicon": 1, + "id": "tools.atmo.space.transferOwnership", + "defs": { + "main": { + "type": "procedure", + "description": "Transfer space ownership to another DID. Caller must be the current owner. The new owner must already be a write member of the space. The previous owner becomes a regular write member.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["spaceUri", "newOwnerDid"], + "properties": { + "spaceUri": { "type": "string", "format": "at-uri" }, + "newOwnerDid": { "type": "string", "format": "did" } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["space"], + "properties": { + "space": { "type": "ref", "ref": "tools.atmo.space.defs#spaceView" } + } + } + }, + "errors": [ + { "name": "NotFound" }, + { "name": "Forbidden" }, + { "name": "InvalidRequest", "description": "Raised if the new owner is not a write member of the space." } + ] + } + } +} diff --git a/spaces-lexicon-templates/whoami.json b/spaces-lexicon-templates/whoami.json new file mode 100644 index 0000000..e30f2ef --- /dev/null +++ b/spaces-lexicon-templates/whoami.json @@ -0,0 +1,32 @@ +{ + "lexicon": 1, + "id": "tools.atmo.space.whoami", + "defs": { + "main": { + "type": "query", + "description": "Report the caller's relationship to a space: whether they are the owner, a member, and at what permission level. Useful for clients to avoid a listMembers roundtrip.", + "parameters": { + "type": "params", + "required": ["spaceUri"], + "properties": { + "spaceUri": { "type": "string", "format": "at-uri" } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["isOwner", "isMember"], + "properties": { + "isOwner": { "type": "boolean" }, + "isMember": { "type": "boolean" }, + "perms": { "type": "string", "knownValues": ["read", "write"], "description": "Present only when the caller is a member or the owner." } + } + } + }, + "errors": [ + { "name": "NotFound" } + ] + } + } +} diff --git a/src/core/backfill.ts b/src/core/backfill.ts index 82c584b..d938f68 100644 --- a/src/core/backfill.ts +++ b/src/core/backfill.ts @@ -4,7 +4,7 @@ import { isDid, isNsid } from "@atcute/lexicons/syntax"; import type { Client } from "@atcute/client"; import type { ContrailConfig, Database, IngestEvent } from "./types"; import { getDiscoverableNsids, getDependentNsids, DEFAULT_RELAYS } from "./types"; -import { applyEvents } from "./db"; +import { applyEvents, getLastCursor, saveCursor } from "./db"; import { getClient, getPDS } from "./client"; const PAGE_SIZE = 100; @@ -47,9 +47,9 @@ async function markFailed( ): Promise { await db .prepare( - "UPDATE backfills SET retries = retries + 1, last_error = ?, completed = CASE WHEN retries + 1 >= ? THEN 1 ELSE completed END WHERE did = ? AND collection = ?" + "UPDATE backfills SET retries = retries + 1, last_error = ? WHERE did = ? AND collection = ?" ) - .bind(error, MAX_RETRIES, did, collection) + .bind(error, did, collection) .run(); } @@ -227,11 +227,23 @@ export async function backfillAll( const concurrency = options?.concurrency ?? 100; let totalBackfilled = 0; + // Anchor the jetstream cursor to now if it hasn't been set yet, so records + // emitted during backfill are replayed once jetstream starts. + if ((await getLastCursor(db)) === null) { + await saveCursor(db, Date.now() * 1000); + } + + // Reset retries so users that hit the cap in a prior run get another chance. + await db + .prepare("UPDATE backfills SET retries = 0 WHERE completed = 0") + .run(); + while (true) { const pending = await db .prepare( - "SELECT did, collection FROM backfills WHERE completed = 0 ORDER BY did" + "SELECT did, collection FROM backfills WHERE completed = 0 AND retries < ? ORDER BY did" ) + .bind(MAX_RETRIES) .all<{ did: string; collection: string }>(); const rows = pending.results ?? []; diff --git a/src/core/router/collection.ts b/src/core/router/collection.ts index 6365634..9a9a583 100644 --- a/src/core/router/collection.ts +++ b/src/core/router/collection.ts @@ -42,18 +42,18 @@ export async function runPipeline( const cursor = params.get("cursor") || undefined; const actor = params.get("actor") || params.get("did") || undefined; const wantProfiles = params.get("profiles") === "true"; - const wantBackfill = params.get("backfill") === "true"; let did: string | undefined; if (actor) { const resolved = await resolveActor(db, actor); if (!resolved) throw new Error("Could not resolve actor"); did = resolved; - if (wantBackfill) { - // backfillUser expects the record NSID (for PDS calls), not the short name. - const nsid = nsidForShortName(config, collection) ?? collection; - await backfillUser(db, did, nsid, Date.now() + 10_000, config); - } + // backfillUser expects the record NSID (for PDS calls), not the short name. + const nsid = nsidForShortName(config, collection) ?? collection; + await backfillUser(db, did, nsid, Date.now() + 3_000, config, { + maxRetries: 0, + requestTimeout: 3_000, + }); } const filters: Record = {}; diff --git a/src/core/router/feed.ts b/src/core/router/feed.ts index 363d174..0430292 100644 --- a/src/core/router/feed.ts +++ b/src/core/router/feed.ts @@ -21,7 +21,10 @@ async function maybeBackfillFeed( if (status?.completed) return; // Ensure the user's follow records are backfilled first - await backfillUser(db, actor, feedConfig.follow, Date.now() + 15_000, config); + await backfillUser(db, actor, feedConfig.follow, Date.now() + 3_000, config, { + maxRetries: 0, + requestTimeout: 3_000, + }); // Mark as in-progress (idempotent) await db diff --git a/src/core/router/index.ts b/src/core/router/index.ts index 3eba9c9..cf54ad6 100644 --- a/src/core/router/index.ts +++ b/src/core/router/index.ts @@ -53,7 +53,10 @@ export function createApp( // Ensure profile records are backfilled const profileConfigs = (config.profiles ?? []).map(normalizeProfileConfig); for (const pc of profileConfigs) { - await backfillUser(db, did, pc.collection, Date.now() + 10_000, config); + await backfillUser(db, did, pc.collection, Date.now() + 3_000, config, { + maxRetries: 0, + requestTimeout: 3_000, + }); } const profileMap = await resolveProfiles(db, config, [did]); diff --git a/src/core/spaces/adapter.ts b/src/core/spaces/adapter.ts index 57afe8d..1c8e78c 100644 --- a/src/core/spaces/adapter.ts +++ b/src/core/spaces/adapter.ts @@ -23,6 +23,7 @@ import type { StorageAdapter, StoredRecord, } from "./types"; +import { buildRecordUri } from "./uri"; function parseJson(value: unknown): T | null { if (value == null) return null; @@ -195,6 +196,14 @@ export class HostedAdapter implements StorageAdapter { .run(); } + async transferOwnership(spaceUri: string, newOwnerDid: string): Promise { + await this.db + .prepare(`UPDATE spaces SET owner_did = ? WHERE uri = ? AND deleted_at IS NULL`) + .bind(newOwnerDid, spaceUri) + .run(); + return this.getSpace(spaceUri); + } + async updateSpaceAppPolicy(spaceUri: string, appPolicy: AppPolicy): Promise { await this.db .prepare(`UPDATE spaces SET app_policy = ? WHERE uri = ?`) @@ -325,7 +334,7 @@ export class HostedAdapter implements StorageAdapter { async putRecord(record: StoredRecord): Promise { const table = this.tableFor(record.collection); - const uri = `at://${record.authorDid}/${record.collection}/${record.rkey}`; + const uri = buildRecordUri(record.authorDid, record.collection, record.rkey); const childShort = this.config ? shortNameForNsid(this.config, record.collection) : null; const prev = childShort diff --git a/src/core/spaces/router.ts b/src/core/spaces/router.ts index cf34cd8..268295d 100644 --- a/src/core/spaces/router.ts +++ b/src/core/spaces/router.ts @@ -12,6 +12,7 @@ import { } from "./auth"; import { nextTid } from "./tid"; import { generateInviteToken, hashInviteToken } from "./invite-token"; +import { buildSpaceUri } from "./uri"; import type { InviteKind, InviteRow, MemberPerm, SpaceRow, SpacesConfig, StorageAdapter } from "./types"; import type { Did } from "@atcute/lexicons"; @@ -265,8 +266,8 @@ export function registerSpacesRoutes( return c.json({ ok: true }); }); - // Admin endpoints - app.post(`/xrpc/${SPACE}.admin.createSpace`, auth, async (c) => { + // Space management (owner-gated) + app.post(`/xrpc/${SPACE}.createSpace`, auth, async (c) => { const sa = getAuth(c); const body = (await c.req.json().catch(() => ({}))) as { type?: string; @@ -278,7 +279,7 @@ export function registerSpacesRoutes( const type = body.type ?? spacesConfig.type; const key = body.key ?? nextTid(); - const uri = `at://${sa.issuer}/${type}/${key}`; + const uri = buildSpaceUri({ ownerDid: sa.issuer, type, key }); const existing = await adapter.getSpace(uri); if (existing) return c.json({ error: "AlreadyExists", uri }, 409); @@ -386,7 +387,7 @@ export function registerSpacesRoutes( return c.json({ ok }); }); - app.post(`/xrpc/${SPACE}.admin.addMember`, auth, async (c) => { + app.post(`/xrpc/${SPACE}.addMember`, auth, async (c) => { const sa = getAuth(c); const body = (await c.req.json().catch(() => null)) as | { spaceUri?: string; did?: string; perms?: MemberPerm } @@ -403,7 +404,7 @@ export function registerSpacesRoutes( return c.json({ ok: true }); }); - app.post(`/xrpc/${SPACE}.admin.removeMember`, auth, async (c) => { + app.post(`/xrpc/${SPACE}.removeMember`, auth, async (c) => { const sa = getAuth(c); const body = (await c.req.json().catch(() => null)) as | { spaceUri?: string; did?: string } @@ -422,6 +423,71 @@ export function registerSpacesRoutes( await adapter.removeMember(body.spaceUri, body.did); return c.json({ ok: true }); }); + + app.post(`/xrpc/${SPACE}.leaveSpace`, auth, async (c) => { + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as { spaceUri?: string } | null; + if (!body?.spaceUri) { + return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); + } + const space = await adapter.getSpace(body.spaceUri); + if (!space) return c.json({ error: "NotFound" }, 404); + if (space.ownerDid === sa.issuer) { + return c.json( + { error: "InvalidRequest", reason: "owner-cannot-leave", message: "Transfer ownership before leaving" }, + 400 + ); + } + await adapter.removeMember(body.spaceUri, sa.issuer); + return c.json({ ok: true }); + }); + + app.post(`/xrpc/${SPACE}.transferOwnership`, auth, async (c) => { + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as + | { spaceUri?: string; newOwnerDid?: string } + | null; + if (!body?.spaceUri || !body.newOwnerDid) { + return c.json({ error: "InvalidRequest", message: "spaceUri and newOwnerDid required" }, 400); + } + const space = await adapter.getSpace(body.spaceUri); + if (!space) return c.json({ error: "NotFound" }, 404); + if (space.ownerDid !== sa.issuer) { + return c.json({ error: "Forbidden", reason: "not-owner" }, 403); + } + if (body.newOwnerDid === sa.issuer) { + return c.json({ space: publicSpaceView(space, true) }); + } + const target = await adapter.getMember(body.spaceUri, body.newOwnerDid); + if (!target || target.perms !== "write") { + return c.json( + { error: "InvalidRequest", reason: "new-owner-not-write-member" }, + 400 + ); + } + // Ensure the outgoing owner stays a write member (the implicit-owner row + // we insert at createSpace has perms=write already, but bump in case). + await adapter.addMember(body.spaceUri, sa.issuer, "write", sa.issuer); + const updated = await adapter.transferOwnership(body.spaceUri, body.newOwnerDid); + if (!updated) return c.json({ error: "NotFound" }, 404); + return c.json({ space: publicSpaceView(updated, false) }); + }); + + app.get(`/xrpc/${SPACE}.whoami`, auth, async (c) => { + const sa = getAuth(c); + const spaceUri = c.req.query("spaceUri"); + if (!spaceUri) return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); + const space = await adapter.getSpace(spaceUri); + if (!space) return c.json({ error: "NotFound" }, 404); + + const isOwner = space.ownerDid === sa.issuer; + if (isOwner) { + return c.json({ isOwner: true, isMember: true, perms: "write" as const }); + } + const member = await adapter.getMember(spaceUri, sa.issuer); + if (!member) return c.json({ isOwner: false, isMember: false }); + return c.json({ isOwner: false, isMember: true, perms: member.perms }); + }); } function buildAuthMiddleware(spaces: SpacesConfig): MiddlewareHandler { diff --git a/src/core/spaces/types.ts b/src/core/spaces/types.ts index cb166e2..0ea3787 100644 --- a/src/core/spaces/types.ts +++ b/src/core/spaces/types.ts @@ -121,6 +121,9 @@ export interface StorageAdapter { listSpaces(options: ListSpacesOptions): Promise<{ spaces: SpaceRow[]; cursor?: string }>; deleteSpace(spaceUri: string): Promise; updateSpaceAppPolicy(spaceUri: string, appPolicy: AppPolicy): Promise; + /** Update ownerDid of a space. Membership rows are not touched; callers are + * responsible for ensuring the new owner is already a write member. */ + transferOwnership(spaceUri: string, newOwnerDid: string): Promise; // Members addMember(spaceUri: string, did: string, perms: MemberPerm, addedBy: string | null): Promise; diff --git a/src/core/spaces/uri.ts b/src/core/spaces/uri.ts new file mode 100644 index 0000000..e863613 --- /dev/null +++ b/src/core/spaces/uri.ts @@ -0,0 +1,37 @@ +/** Centralized space URI construction / parsing. + * + * Permissioned spaces are addressed by (ownerDid, type, key). The rough spec + * (https://dholms.leaflet.pub/3mhj6bcqats2o) floats `ats://` as a likely + * distinct scheme for permissioned data, but that's unresolved — we keep + * `at://` today and isolate the format here so swapping is a one-liner. + * + * Record URIs inside a space are minted by authorDid for index purposes + * (`at:////`); the spec is explicitly undecided + * about authority (user vs space owner), so we don't expose those as a + * canonical record address — they're storage-internal. */ + +export interface SpaceUriParts { + ownerDid: string; + type: string; + key: string; +} + +/** Build a space URI from its three addressing components. */ +export function buildSpaceUri(parts: SpaceUriParts): string { + return `at://${parts.ownerDid}/${parts.type}/${parts.key}`; +} + +/** Parse a space URI into its components, or null if malformed. */ +export function parseSpaceUri(uri: string): SpaceUriParts | null { + if (!uri.startsWith("at://")) return null; + const rest = uri.slice("at://".length); + const [ownerDid, type, key, ...extra] = rest.split("/"); + if (!ownerDid || !type || !key || extra.length > 0) return null; + return { ownerDid, type, key }; +} + +/** Build a record URI under a given author. Used only as a secondary index key + * inside storage — not a canonical address for permissioned records. */ +export function buildRecordUri(authorDid: string, collection: string, rkey: string): string { + return `at://${authorDid}/${collection}/${rkey}`; +} diff --git a/tests/spaces-e2e.test.ts b/tests/spaces-e2e.test.ts index fbef767..92b1fe5 100644 --- a/tests/spaces-e2e.test.ts +++ b/tests/spaces-e2e.test.ts @@ -90,7 +90,7 @@ describe("spaces e2e", () => { app = await makeApp(); // Alice creates a space - const res = await call(app, "POST", "/xrpc/test.spaces.space.admin.createSpace", ALICE, { + const res = await call(app, "POST", "/xrpc/test.spaces.space.createSpace", ALICE, { key: "birthday-2026", }); expect(res.status).toBe(200); @@ -133,7 +133,7 @@ describe("spaces e2e", () => { }); it("owner adds Bob as member", async () => { - const res = await call(app, "POST", "/xrpc/test.spaces.space.admin.addMember", ALICE, { + const res = await call(app, "POST", "/xrpc/test.spaces.space.addMember", ALICE, { spaceUri, did: BOB, perms: "write", @@ -222,7 +222,7 @@ describe("spaces e2e", () => { // End-to-end works: createSpace, putRecord, listRecords const create = await splitApp.fetch( - new Request("http://localhost/xrpc/test.spaces.space.admin.createSpace", { + new Request("http://localhost/xrpc/test.spaces.space.createSpace", { method: "POST", headers: { "X-Test-Did": ALICE, "Content-Type": "application/json" }, body: JSON.stringify({ key: "split-test" }), diff --git a/tests/spaces-invites.test.ts b/tests/spaces-invites.test.ts index fad11c5..e14f641 100644 --- a/tests/spaces-invites.test.ts +++ b/tests/spaces-invites.test.ts @@ -77,7 +77,7 @@ describe("invite e2e", () => { await initSchema(db, resolved); app = createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); - const res = await call(app, "POST", "/xrpc/test.spaces.space.admin.createSpace", ALICE, { + const res = await call(app, "POST", "/xrpc/test.spaces.space.createSpace", ALICE, { key: "party", }); spaceUri = ((await res.json()) as any).space.uri;