From ef42ef2547688b0a1d7b878a51628b8c1a5fed2d Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:35:26 +0200 Subject: [PATCH] remove transfer ownership --- .changeset/fluffy-eagles-exist.md | 5 ++ docs/spaces-later.md | 24 +++++--- docs/spaces-spec-mapping.md | 10 ++-- .../rsvp/atmo/permissionSet.json | 1 - .../rsvp/atmo/space/leaveSpace.json | 2 +- .../rsvp/atmo/space/transferOwnership.json | 57 ------------------- spaces-lexicon-templates/leaveSpace.json | 2 +- .../transferOwnership.json | 36 ------------ src/core/spaces/adapter.ts | 8 --- src/core/spaces/router.ts | 33 +---------- src/core/spaces/types.ts | 3 - 11 files changed, 27 insertions(+), 154 deletions(-) create mode 100644 .changeset/fluffy-eagles-exist.md delete mode 100644 lexicons-generated/rsvp/atmo/space/transferOwnership.json delete mode 100644 spaces-lexicon-templates/transferOwnership.json diff --git a/.changeset/fluffy-eagles-exist.md b/.changeset/fluffy-eagles-exist.md new file mode 100644 index 0000000..a700042 --- /dev/null +++ b/.changeset/fluffy-eagles-exist.md @@ -0,0 +1,5 @@ +--- +"@atmo-dev/contrail": patch +--- + +remove transfer ownership diff --git a/docs/spaces-later.md b/docs/spaces-later.md index 58a3945..972a4ce 100644 --- a/docs/spaces-later.md +++ b/docs/spaces-later.md @@ -19,7 +19,6 @@ The e2e + invite tests cover the happy paths. Gaps: - 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 @@ -63,13 +62,20 @@ don't have that yet. Lightweight interim: Server-Sent Events on 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. +Right now `space.invite.*`, `space.whoami`, and `space.leaveSpace` 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). +`whoami`; `leaveSpace` is ambiguous. + +## Ownership transfer +Dropped for now. The space URI is `at:////` — owner DID +is baked into the URI, and every record/member/invite row keys off that URI +string. Transferring would mean either rewriting every referencing row in a +transaction (and breaking external refs to the old URI) or decoupling storage +from URI with an internal stable space id (bigger refactor). Revisit once the +spec pins down whether ownership transfer exists and what the URI authority +is supposed to be post-transfer. diff --git a/docs/spaces-spec-mapping.md b/docs/spaces-spec-mapping.md index feb445e..890f193 100644 --- a/docs/spaces-spec-mapping.md +++ b/docs/spaces-spec-mapping.md @@ -54,8 +54,7 @@ All endpoints are emitted under `.space.*` from templates in - `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) +- `space.leaveSpace` — self-remove; owner cannot leave (extra) ### Invites (extra; not in the spec) - `space.invite.create` — returns raw token once; hash stored @@ -100,10 +99,9 @@ the real spec lands: - 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. +- Keep `space.whoami`, `space.leaveSpace`, 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/lexicons-generated/rsvp/atmo/permissionSet.json b/lexicons-generated/rsvp/atmo/permissionSet.json index 5e9aa5c..c1ab89e 100644 --- a/lexicons-generated/rsvp/atmo/permissionSet.json +++ b/lexicons-generated/rsvp/atmo/permissionSet.json @@ -35,7 +35,6 @@ "rsvp.atmo.space.listSpaces", "rsvp.atmo.space.putRecord", "rsvp.atmo.space.removeMember", - "rsvp.atmo.space.transferOwnership", "rsvp.atmo.space.whoami" ] } diff --git a/lexicons-generated/rsvp/atmo/space/leaveSpace.json b/lexicons-generated/rsvp/atmo/space/leaveSpace.json index f68fd30..3ea3458 100644 --- a/lexicons-generated/rsvp/atmo/space/leaveSpace.json +++ b/lexicons-generated/rsvp/atmo/space/leaveSpace.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Remove the caller from a space's member list. The owner cannot leave — they must transferOwnership first.", + "description": "Remove the caller from a space's member list. The owner cannot leave — they must delete the space instead.", "input": { "encoding": "application/json", "schema": { diff --git a/lexicons-generated/rsvp/atmo/space/transferOwnership.json b/lexicons-generated/rsvp/atmo/space/transferOwnership.json deleted file mode 100644 index 6778445..0000000 --- a/lexicons-generated/rsvp/atmo/space/transferOwnership.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.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": "rsvp.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/leaveSpace.json b/spaces-lexicon-templates/leaveSpace.json index 62706e9..fac5b0a 100644 --- a/spaces-lexicon-templates/leaveSpace.json +++ b/spaces-lexicon-templates/leaveSpace.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Remove the caller from a space's member list. The owner cannot leave — they must transferOwnership first.", + "description": "Remove the caller from a space's member list. The owner cannot leave — they must delete the space instead.", "input": { "encoding": "application/json", "schema": { diff --git a/spaces-lexicon-templates/transferOwnership.json b/spaces-lexicon-templates/transferOwnership.json deleted file mode 100644 index 7b45767..0000000 --- a/spaces-lexicon-templates/transferOwnership.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "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/src/core/spaces/adapter.ts b/src/core/spaces/adapter.ts index 1c8e78c..67a0b8c 100644 --- a/src/core/spaces/adapter.ts +++ b/src/core/spaces/adapter.ts @@ -196,14 +196,6 @@ 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 = ?`) diff --git a/src/core/spaces/router.ts b/src/core/spaces/router.ts index 268295d..ef2c93f 100644 --- a/src/core/spaces/router.ts +++ b/src/core/spaces/router.ts @@ -434,7 +434,7 @@ export function registerSpacesRoutes( 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" }, + { error: "InvalidRequest", reason: "owner-cannot-leave", message: "Owner cannot leave; delete the space instead" }, 400 ); } @@ -442,37 +442,6 @@ export function registerSpacesRoutes( 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"); diff --git a/src/core/spaces/types.ts b/src/core/spaces/types.ts index 0ea3787..cb166e2 100644 --- a/src/core/spaces/types.ts +++ b/src/core/spaces/types.ts @@ -121,9 +121,6 @@ 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; -- 2.51.2