diff --git a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/community/invite/create.json b/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/community/invite/create.json deleted file mode 100644 index 9babcc7..0000000 --- a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/community/invite/create.json +++ /dev/null @@ -1,93 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.invite.create", - "defs": { - "main": { - "type": "procedure", - "description": "Create an invite for a community-owned space. Caller must have manager+ on the target space and cannot grant a higher level than their own. Returns the raw token once; only its SHA-256 is stored.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri", - "accessLevel" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "accessLevel": { - "type": "ref", - "ref": "tools.atmo.chat.community.defs#accessLevel" - }, - "expiresAt": { - "type": "integer", - "description": "Unix ms. Omit for no expiry." - }, - "maxUses": { - "type": "integer", - "minimum": 1, - "description": "Omit for unlimited redemptions." - }, - "note": { - "type": "string", - "maxLength": 500 - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "token", - "tokenHash", - "spaceUri", - "accessLevel", - "createdAt" - ], - "properties": { - "token": { - "type": "string", - "description": "Raw token. Shown once — cannot be retrieved later." - }, - "tokenHash": { - "type": "string" - }, - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "accessLevel": { - "type": "ref", - "ref": "tools.atmo.chat.community.defs#accessLevel" - }, - "expiresAt": { - "type": "integer" - }, - "maxUses": { - "type": "integer" - }, - "createdAt": { - "type": "integer" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "Forbidden" - }, - { - "name": "NotFound" - } - ] - } - } -} diff --git a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/community/invite/list.json b/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/community/invite/list.json deleted file mode 100644 index 55ef50a..0000000 --- a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/community/invite/list.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.invite.list", - "defs": { - "main": { - "type": "query", - "description": "List invites for a community-owned space. Caller must have manager+ on the space. Raw tokens are never returned — tokenHash is the stable id.", - "parameters": { - "type": "params", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "includeRevoked": { - "type": "boolean", - "default": false - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "invites" - ], - "properties": { - "invites": { - "type": "array", - "items": { - "type": "ref", - "ref": "tools.atmo.chat.community.defs#inviteView" - } - } - } - } - }, - "errors": [ - { - "name": "Forbidden" - }, - { - "name": "NotFound" - } - ] - } - } -} diff --git a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/community/invite/revoke.json b/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/community/invite/revoke.json deleted file mode 100644 index 30f2cf0..0000000 --- a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/community/invite/revoke.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.invite.revoke", - "defs": { - "main": { - "type": "procedure", - "description": "Revoke an invite. Caller must be the invite's creator or have manager+ on the target space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "tokenHash" - ], - "properties": { - "tokenHash": { - "type": "string" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/community/space/whoami.json b/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/community/space/whoami.json deleted file mode 100644 index 457244d..0000000 --- a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/community/space/whoami.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.community.space.whoami", - "defs": { - "main": { - "type": "query", - "description": "Report the JWT issuer's effective access level in a space (resolving delegated memberships).", - "parameters": { - "type": "params", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "accessLevel": { - "type": "ref", - "ref": "tools.atmo.chat.community.defs#accessLevel", - "description": "Null when caller has no resolvable access." - } - } - } - } - } - } -} diff --git a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/spaceExt/invite/create.json b/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/invite/create.json similarity index 59% rename from examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/spaceExt/invite/create.json rename to examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/invite/create.json index f6ed9fe..2d9b235 100644 --- a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/spaceExt/invite/create.json +++ b/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/invite/create.json @@ -1,10 +1,10 @@ { "lexicon": 1, - "id": "tools.atmo.chat.spaceExt.invite.create", + "id": "tools.atmo.chat.invite.create", "defs": { "main": { "type": "procedure", - "description": "Create an invite for a space. Caller must be the space owner. Returns the raw token once; only the hash is stored.", + "description": "Create an invite for a space. The service dispatches on space ownership: user-owned spaces take `kind` (default `join`); community-owned spaces take `accessLevel`. Exactly one of `kind` / `accessLevel` must be set. Returns the raw token once; only the hash is stored.", "input": { "encoding": "application/json", "schema": { @@ -24,8 +24,17 @@ "read", "read-join" ], - "default": "join", - "description": "join: redeem to become a member. read: bearer-only read access, no membership. read-join: anonymous read + signed-in redeem to join." + "description": "For user-owned spaces. join: redeem to become a member. read: bearer-only read access, no membership. read-join: anonymous read + signed-in redeem to join." + }, + "accessLevel": { + "type": "string", + "knownValues": [ + "member", + "manager", + "admin", + "owner" + ], + "description": "For community-owned spaces. The access level granted on redemption — the creator's own level caps what they can grant." }, "expiresAt": { "type": "integer", @@ -33,8 +42,7 @@ }, "maxUses": { "type": "integer", - "minimum": 1, - "description": "Caps join redemptions only — read-token reads are unlimited. Omit for unlimited joins." + "minimum": 1 }, "note": { "type": "string", @@ -58,7 +66,7 @@ }, "invite": { "type": "ref", - "ref": "tools.atmo.chat.space.defs#inviteView" + "ref": "tools.atmo.chat.invite.defs#inviteView" } } } @@ -69,6 +77,9 @@ }, { "name": "Forbidden" + }, + { + "name": "InvalidRequest" } ] } diff --git a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/invite/defs.json b/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/invite/defs.json new file mode 100644 index 0000000..b631730 --- /dev/null +++ b/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/invite/defs.json @@ -0,0 +1,73 @@ +{ + "lexicon": 1, + "id": "tools.atmo.chat.invite.defs", + "defs": { + "inviteView": { + "type": "object", + "description": "An invite row as exposed to clients. Either `kind` (user-owned space) or `accessLevel` (community-owned space) is set, never both.", + "required": [ + "tokenHash", + "spaceUri", + "createdBy", + "createdAt", + "usedCount" + ], + "properties": { + "tokenHash": { + "type": "string", + "description": "Stable identifier for list/revoke operations." + }, + "spaceUri": { + "type": "string", + "format": "at-uri" + }, + "kind": { + "type": "string", + "knownValues": [ + "join", + "read", + "read-join" + ], + "description": "Set for user-owned spaces. Absent for community-owned." + }, + "accessLevel": { + "type": "string", + "knownValues": [ + "member", + "manager", + "admin", + "owner" + ], + "description": "Set for community-owned spaces. Absent for user-owned." + }, + "createdBy": { + "type": "string", + "format": "did" + }, + "createdAt": { + "type": "integer", + "description": "Unix ms." + }, + "expiresAt": { + "type": "integer", + "description": "Unix ms. Omitted for no expiry." + }, + "maxUses": { + "type": "integer", + "minimum": 1 + }, + "usedCount": { + "type": "integer" + }, + "revokedAt": { + "type": "integer", + "description": "Unix ms. Omitted if not revoked." + }, + "note": { + "type": "string", + "maxLength": 500 + } + } + } + } +} diff --git a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/spaceExt/invite/list.json b/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/invite/list.json similarity index 80% rename from examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/spaceExt/invite/list.json rename to examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/invite/list.json index d5191da..21a702e 100644 --- a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/spaceExt/invite/list.json +++ b/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/invite/list.json @@ -1,10 +1,10 @@ { "lexicon": 1, - "id": "tools.atmo.chat.spaceExt.invite.list", + "id": "tools.atmo.chat.invite.list", "defs": { "main": { "type": "query", - "description": "List invites for a space. Owner only.", + "description": "List invites for a space. User-owned spaces: owner-only. Community-owned spaces: manager+.", "parameters": { "type": "params", "required": [ @@ -33,7 +33,7 @@ "type": "array", "items": { "type": "ref", - "ref": "tools.atmo.chat.space.defs#inviteView" + "ref": "tools.atmo.chat.invite.defs#inviteView" } } } diff --git a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/community/invite/redeem.json b/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/invite/redeem.json similarity index 58% rename from examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/community/invite/redeem.json rename to examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/invite/redeem.json index bc83ece..86c2148 100644 --- a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/community/invite/redeem.json +++ b/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/invite/redeem.json @@ -1,10 +1,10 @@ { "lexicon": 1, - "id": "tools.atmo.chat.community.invite.redeem", + "id": "tools.atmo.chat.invite.redeem", "defs": { "main": { "type": "procedure", - "description": "Redeem a community invite. The caller's DID (from service-auth) is granted the access level encoded in the invite. Atomic — a token is consumed only if the invite is currently usable.", + "description": "Redeem an invite token. User-owned spaces: caller becomes a member. Community-owned spaces: caller is granted the invite's access level.", "input": { "encoding": "application/json", "schema": { @@ -24,32 +24,32 @@ "schema": { "type": "object", "required": [ - "spaceUri", - "accessLevel", - "communityDid" + "spaceUri" ], "properties": { "spaceUri": { "type": "string", "format": "at-uri" }, + "kind": { + "type": "string", + "description": "Set for user-owned spaces — echoes the invite kind consumed." + }, "accessLevel": { - "type": "ref", - "ref": "tools.atmo.chat.community.defs#accessLevel" + "type": "string", + "description": "Set for community-owned spaces — the level granted." }, "communityDid": { "type": "string", - "format": "did" + "format": "did", + "description": "Set for community-owned spaces." } } } }, "errors": [ { - "name": "InvalidRequest" - }, - { - "name": "InvalidToken" + "name": "InvalidInvite" }, { "name": "NotFound" diff --git a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/spaceExt/invite/revoke.json b/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/invite/revoke.json similarity index 69% rename from examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/spaceExt/invite/revoke.json rename to examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/invite/revoke.json index 49d5d2e..4686cb9 100644 --- a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/spaceExt/invite/revoke.json +++ b/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/invite/revoke.json @@ -1,22 +1,22 @@ { "lexicon": 1, - "id": "tools.atmo.chat.spaceExt.invite.revoke", + "id": "tools.atmo.chat.invite.revoke", "defs": { "main": { "type": "procedure", - "description": "Revoke an invite. Owner only. Invites are identified by their tokenHash (visible in list output).", + "description": "Revoke an invite by tokenHash. User-owned spaces: owner-only. Community-owned spaces: invite creator OR manager+ on the target space.", "input": { "encoding": "application/json", "schema": { "type": "object", "required": [ - "spaceUri", "tokenHash" ], "properties": { "spaceUri": { "type": "string", - "format": "at-uri" + "format": "at-uri", + "description": "Optional — ownership is inferred from the invite row; required for user-owned spaces for a sanity check." }, "tokenHash": { "type": "string" diff --git a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/permissionSet.json b/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/permissionSet.json index 99c71cf..89a6a5a 100644 --- a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/permissionSet.json +++ b/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/permissionSet.json @@ -19,10 +19,6 @@ "tools.atmo.chat.community.delete", "tools.atmo.chat.community.deleteRecord", "tools.atmo.chat.community.getHealth", - "tools.atmo.chat.community.invite.create", - "tools.atmo.chat.community.invite.list", - "tools.atmo.chat.community.invite.redeem", - "tools.atmo.chat.community.invite.revoke", "tools.atmo.chat.community.list", "tools.atmo.chat.community.mint", "tools.atmo.chat.community.putRecord", @@ -36,10 +32,13 @@ "tools.atmo.chat.community.space.resync", "tools.atmo.chat.community.space.revoke", "tools.atmo.chat.community.space.setAccessLevel", - "tools.atmo.chat.community.space.whoami", "tools.atmo.chat.getCursor", "tools.atmo.chat.getOverview", "tools.atmo.chat.getProfile", + "tools.atmo.chat.invite.create", + "tools.atmo.chat.invite.list", + "tools.atmo.chat.invite.redeem", + "tools.atmo.chat.invite.revoke", "tools.atmo.chat.message.getRecord", "tools.atmo.chat.message.listRecords", "tools.atmo.chat.message.watchRecords", @@ -63,10 +62,6 @@ "tools.atmo.chat.space.putRecord", "tools.atmo.chat.space.removeMember", "tools.atmo.chat.space.uploadBlob", - "tools.atmo.chat.spaceExt.invite.create", - "tools.atmo.chat.spaceExt.invite.list", - "tools.atmo.chat.spaceExt.invite.redeem", - "tools.atmo.chat.spaceExt.invite.revoke", "tools.atmo.chat.spaceExt.whoami" ] } diff --git a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/spaceExt/invite/redeem.json b/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/spaceExt/invite/redeem.json deleted file mode 100644 index 9d9e552..0000000 --- a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/spaceExt/invite/redeem.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.chat.spaceExt.invite.redeem", - "defs": { - "main": { - "type": "procedure", - "description": "Redeem an invite token. The JWT issuer becomes a member of the space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "token" - ], - "properties": { - "token": { - "type": "string" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - } - } - } - }, - "errors": [ - { - "name": "InvalidInvite" - } - ] - } - } -} diff --git a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/spaceExt/whoami.json b/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/spaceExt/whoami.json index 83d5849..75b5e2e 100644 --- a/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/spaceExt/whoami.json +++ b/examples/sveltekit-group-chat/lexicons-generated/tools/atmo/chat/spaceExt/whoami.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Report the caller's relationship to a space: whether they are the owner and/or a member. Useful for clients to avoid a listMembers roundtrip.", + "description": "Caller's relationship to a space. For user-owned spaces membership is binary (`isMember`). For community-owned spaces `accessLevel` is also returned, resolved through the community's access-level ladder (including delegated grants).", "parameters": { "type": "params", "required": [ @@ -31,6 +31,16 @@ }, "isMember": { "type": "boolean" + }, + "accessLevel": { + "type": "string", + "knownValues": [ + "member", + "manager", + "admin", + "owner" + ], + "description": "Only set for community-owned spaces. Null when the caller has no resolvable access." } } } diff --git a/examples/sveltekit-group-chat/src/lib/atproto/generated-methods.ts b/examples/sveltekit-group-chat/src/lib/atproto/generated-methods.ts index 0cfd48f..0458f77 100644 --- a/examples/sveltekit-group-chat/src/lib/atproto/generated-methods.ts +++ b/examples/sveltekit-group-chat/src/lib/atproto/generated-methods.ts @@ -9,10 +9,6 @@ export const xrpcMethods = [ "tools.atmo.chat.community.delete", "tools.atmo.chat.community.deleteRecord", "tools.atmo.chat.community.getHealth", - "tools.atmo.chat.community.invite.create", - "tools.atmo.chat.community.invite.list", - "tools.atmo.chat.community.invite.redeem", - "tools.atmo.chat.community.invite.revoke", "tools.atmo.chat.community.list", "tools.atmo.chat.community.mint", "tools.atmo.chat.community.putRecord", @@ -26,10 +22,13 @@ export const xrpcMethods = [ "tools.atmo.chat.community.space.resync", "tools.atmo.chat.community.space.revoke", "tools.atmo.chat.community.space.setAccessLevel", - "tools.atmo.chat.community.space.whoami", "tools.atmo.chat.getCursor", "tools.atmo.chat.getOverview", "tools.atmo.chat.getProfile", + "tools.atmo.chat.invite.create", + "tools.atmo.chat.invite.list", + "tools.atmo.chat.invite.redeem", + "tools.atmo.chat.invite.revoke", "tools.atmo.chat.message.getRecord", "tools.atmo.chat.message.listRecords", "tools.atmo.chat.message.watchRecords", @@ -53,9 +52,5 @@ export const xrpcMethods = [ "tools.atmo.chat.space.putRecord", "tools.atmo.chat.space.removeMember", "tools.atmo.chat.space.uploadBlob", - "tools.atmo.chat.spaceExt.invite.create", - "tools.atmo.chat.spaceExt.invite.list", - "tools.atmo.chat.spaceExt.invite.redeem", - "tools.atmo.chat.spaceExt.invite.revoke", "tools.atmo.chat.spaceExt.whoami" ] as const; diff --git a/examples/sveltekit-group-chat/src/lib/rooms/server.ts b/examples/sveltekit-group-chat/src/lib/rooms/server.ts index c8e0e99..0aa444a 100644 --- a/examples/sveltekit-group-chat/src/lib/rooms/server.ts +++ b/examples/sveltekit-group-chat/src/lib/rooms/server.ts @@ -213,7 +213,7 @@ export async function whoami( ctx: AuthedCallContext, query: { spaceUri: string } ): Promise<{ spaceUri: string; accessLevel: string | null }> { - return callContrail(ctx, 'tools.atmo.chat.community.space.whoami', { query }); + return callContrail(ctx, 'tools.atmo.chat.spaceExt.whoami', { query }); } // --- realtime -------------------------------------------------------------- @@ -258,7 +258,7 @@ export async function createCommunityInvite( maxUses: number | null; createdAt: number; }> { - return callContrail(ctx, 'tools.atmo.chat.community.invite.create', { body: input }); + return callContrail(ctx, 'tools.atmo.chat.invite.create', { body: input }); } export async function listCommunityInvites( @@ -267,21 +267,21 @@ export async function listCommunityInvites( ): Promise<{ invites: InviteView[] }> { const q: Record = { spaceUri: query.spaceUri }; if (query.includeRevoked) q.includeRevoked = 'true'; - return callContrail(ctx, 'tools.atmo.chat.community.invite.list', { query: q }); + return callContrail(ctx, 'tools.atmo.chat.invite.list', { query: q }); } export async function revokeCommunityInvite( ctx: AuthedCallContext, input: { tokenHash: string } ): Promise<{ ok: true }> { - return callContrail(ctx, 'tools.atmo.chat.community.invite.revoke', { body: input }); + return callContrail(ctx, 'tools.atmo.chat.invite.revoke', { body: input }); } export async function redeemCommunityInvite( ctx: AuthedCallContext, input: { token: string } ): Promise<{ spaceUri: string; accessLevel: string; communityDid: string }> { - return callContrail(ctx, 'tools.atmo.chat.community.invite.redeem', { body: input }); + return callContrail(ctx, 'tools.atmo.chat.invite.redeem', { body: input }); } // --- blobs ----------------------------------------------------------------- diff --git a/examples/sveltekit-group-chat/src/routes/c/[communityDid]/+layout.server.ts b/examples/sveltekit-group-chat/src/routes/c/[communityDid]/+layout.server.ts index d3f554a..ff8f27b 100644 --- a/examples/sveltekit-group-chat/src/routes/c/[communityDid]/+layout.server.ts +++ b/examples/sveltekit-group-chat/src/routes/c/[communityDid]/+layout.server.ts @@ -118,7 +118,7 @@ export const load: LayoutServerLoad = async ({ locals, params, platform }) => { const adminUri = buildAdminUri(communityDid); const d = await authedFetch<{ accessLevel: string | null }>( ctx, - 'tools.atmo.chat.community.space.whoami', + 'tools.atmo.chat.spaceExt.whoami', { query: { spaceUri: adminUri } } ); isAdmin = d.accessLevel === 'admin' || d.accessLevel === 'owner'; diff --git a/lexicon-templates/community/invite/create.json b/lexicon-templates/community/invite/create.json deleted file mode 100644 index de9a4d4..0000000 --- a/lexicon-templates/community/invite/create.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.invite.create", - "defs": { - "main": { - "type": "procedure", - "description": "Create an invite for a community-owned space. Caller must have manager+ on the target space and cannot grant a higher level than their own. Returns the raw token once; only its SHA-256 is stored.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaceUri", "accessLevel"], - "properties": { - "spaceUri": { "type": "string", "format": "at-uri" }, - "accessLevel": { "type": "ref", "ref": "tools.atmo.community.defs#accessLevel" }, - "expiresAt": { "type": "integer", "description": "Unix ms. Omit for no expiry." }, - "maxUses": { "type": "integer", "minimum": 1, "description": "Omit for unlimited redemptions." }, - "note": { "type": "string", "maxLength": 500 } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["token", "tokenHash", "spaceUri", "accessLevel", "createdAt"], - "properties": { - "token": { "type": "string", "description": "Raw token. Shown once — cannot be retrieved later." }, - "tokenHash": { "type": "string" }, - "spaceUri": { "type": "string", "format": "at-uri" }, - "accessLevel": { "type": "ref", "ref": "tools.atmo.community.defs#accessLevel" }, - "expiresAt": { "type": "integer" }, - "maxUses": { "type": "integer" }, - "createdAt": { "type": "integer" } - } - } - }, - "errors": [ - { "name": "InvalidRequest" }, - { "name": "Forbidden" }, - { "name": "NotFound" } - ] - } - } -} diff --git a/lexicon-templates/community/invite/list.json b/lexicon-templates/community/invite/list.json deleted file mode 100644 index 4ee794f..0000000 --- a/lexicon-templates/community/invite/list.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.invite.list", - "defs": { - "main": { - "type": "query", - "description": "List invites for a community-owned space. Caller must have manager+ on the space. Raw tokens are never returned — tokenHash is the stable id.", - "parameters": { - "type": "params", - "required": ["spaceUri"], - "properties": { - "spaceUri": { "type": "string", "format": "at-uri" }, - "includeRevoked": { "type": "boolean", "default": false } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["invites"], - "properties": { - "invites": { - "type": "array", - "items": { "type": "ref", "ref": "tools.atmo.community.defs#inviteView" } - } - } - } - }, - "errors": [ - { "name": "Forbidden" }, - { "name": "NotFound" } - ] - } - } -} diff --git a/lexicon-templates/community/invite/redeem.json b/lexicon-templates/community/invite/redeem.json deleted file mode 100644 index 5935aa0..0000000 --- a/lexicon-templates/community/invite/redeem.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.invite.redeem", - "defs": { - "main": { - "type": "procedure", - "description": "Redeem a community invite. The caller's DID (from service-auth) is granted the access level encoded in the invite. Atomic — a token is consumed only if the invite is currently usable.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["token"], - "properties": { - "token": { "type": "string" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaceUri", "accessLevel", "communityDid"], - "properties": { - "spaceUri": { "type": "string", "format": "at-uri" }, - "accessLevel": { "type": "ref", "ref": "tools.atmo.community.defs#accessLevel" }, - "communityDid": { "type": "string", "format": "did" } - } - } - }, - "errors": [ - { "name": "InvalidRequest" }, - { "name": "InvalidToken" }, - { "name": "NotFound" } - ] - } - } -} diff --git a/lexicon-templates/community/invite/revoke.json b/lexicon-templates/community/invite/revoke.json deleted file mode 100644 index 05b3054..0000000 --- a/lexicon-templates/community/invite/revoke.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.invite.revoke", - "defs": { - "main": { - "type": "procedure", - "description": "Revoke an invite. Caller must be the invite's creator or have manager+ on the target space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["tokenHash"], - "properties": { - "tokenHash": { "type": "string" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["ok"], - "properties": { - "ok": { "type": "boolean" } - } - } - }, - "errors": [ - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/lexicon-templates/community/space/whoami.json b/lexicon-templates/community/space/whoami.json deleted file mode 100644 index fd0a6fa..0000000 --- a/lexicon-templates/community/space/whoami.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.community.space.whoami", - "defs": { - "main": { - "type": "query", - "description": "Report the JWT issuer's effective access level in a space (resolving delegated memberships).", - "parameters": { - "type": "params", - "required": ["spaceUri"], - "properties": { - "spaceUri": { "type": "string", "format": "at-uri" } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaceUri"], - "properties": { - "spaceUri": { "type": "string", "format": "at-uri" }, - "accessLevel": { "type": "ref", "ref": "tools.atmo.community.defs#accessLevel", "description": "Null when caller has no resolvable access." } - } - } - } - } - } -} diff --git a/lexicon-templates/invite/create.json b/lexicon-templates/invite/create.json new file mode 100644 index 0000000..81910c0 --- /dev/null +++ b/lexicon-templates/invite/create.json @@ -0,0 +1,41 @@ +{ + "lexicon": 1, + "id": "tools.atmo.invite.create", + "defs": { + "main": { + "type": "procedure", + "description": "Create an invite for a space. The service dispatches on space ownership: user-owned spaces take `kind` (default `join`); community-owned spaces take `accessLevel`. Exactly one of `kind` / `accessLevel` must be set. Returns the raw token once; only the hash is stored.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["spaceUri"], + "properties": { + "spaceUri": { "type": "string", "format": "at-uri" }, + "kind": { "type": "string", "knownValues": ["join", "read", "read-join"], "description": "For user-owned spaces. join: redeem to become a member. read: bearer-only read access, no membership. read-join: anonymous read + signed-in redeem to join." }, + "accessLevel": { "type": "string", "knownValues": ["member", "manager", "admin", "owner"], "description": "For community-owned spaces. The access level granted on redemption — the creator's own level caps what they can grant." }, + "expiresAt": { "type": "integer", "description": "Unix ms timestamp. Omit for no expiry." }, + "maxUses": { "type": "integer", "minimum": 1 }, + "note": { "type": "string", "maxLength": 500 } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["token", "invite"], + "properties": { + "token": { "type": "string", "description": "Raw token. Shown once — cannot be retrieved later." }, + "invite": { "type": "ref", "ref": "tools.atmo.invite.defs#inviteView" } + } + } + }, + "errors": [ + { "name": "NotFound" }, + { "name": "Forbidden" }, + { "name": "InvalidRequest" } + ] + } + } +} diff --git a/lexicon-templates/invite/defs.json b/lexicon-templates/invite/defs.json new file mode 100644 index 0000000..909e5cd --- /dev/null +++ b/lexicon-templates/invite/defs.json @@ -0,0 +1,24 @@ +{ + "lexicon": 1, + "id": "tools.atmo.invite.defs", + "defs": { + "inviteView": { + "type": "object", + "description": "An invite row as exposed to clients. Either `kind` (user-owned space) or `accessLevel` (community-owned space) is set, never both.", + "required": ["tokenHash", "spaceUri", "createdBy", "createdAt", "usedCount"], + "properties": { + "tokenHash": { "type": "string", "description": "Stable identifier for list/revoke operations." }, + "spaceUri": { "type": "string", "format": "at-uri" }, + "kind": { "type": "string", "knownValues": ["join", "read", "read-join"], "description": "Set for user-owned spaces. Absent for community-owned." }, + "accessLevel": { "type": "string", "knownValues": ["member", "manager", "admin", "owner"], "description": "Set for community-owned spaces. Absent for user-owned." }, + "createdBy": { "type": "string", "format": "did" }, + "createdAt": { "type": "integer", "description": "Unix ms." }, + "expiresAt": { "type": "integer", "description": "Unix ms. Omitted for no expiry." }, + "maxUses": { "type": "integer", "minimum": 1 }, + "usedCount": { "type": "integer" }, + "revokedAt": { "type": "integer", "description": "Unix ms. Omitted if not revoked." }, + "note": { "type": "string", "maxLength": 500 } + } + } + } +} diff --git a/lexicon-templates/spaces/ext/invite/list.json b/lexicon-templates/invite/list.json similarity index 74% rename from lexicon-templates/spaces/ext/invite/list.json rename to lexicon-templates/invite/list.json index ed316f9..a75afd7 100644 --- a/lexicon-templates/spaces/ext/invite/list.json +++ b/lexicon-templates/invite/list.json @@ -1,10 +1,10 @@ { "lexicon": 1, - "id": "tools.atmo.spaceExt.invite.list", + "id": "tools.atmo.invite.list", "defs": { "main": { "type": "query", - "description": "List invites for a space. Owner only.", + "description": "List invites for a space. User-owned spaces: owner-only. Community-owned spaces: manager+.", "parameters": { "type": "params", "required": ["spaceUri"], @@ -21,7 +21,7 @@ "properties": { "invites": { "type": "array", - "items": { "type": "ref", "ref": "tools.atmo.space.defs#inviteView" } + "items": { "type": "ref", "ref": "tools.atmo.invite.defs#inviteView" } } } } diff --git a/lexicon-templates/invite/redeem.json b/lexicon-templates/invite/redeem.json new file mode 100644 index 0000000..f013492 --- /dev/null +++ b/lexicon-templates/invite/redeem.json @@ -0,0 +1,37 @@ +{ + "lexicon": 1, + "id": "tools.atmo.invite.redeem", + "defs": { + "main": { + "type": "procedure", + "description": "Redeem an invite token. User-owned spaces: caller becomes a member. Community-owned spaces: caller is granted the invite's access level.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["token"], + "properties": { + "token": { "type": "string" } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["spaceUri"], + "properties": { + "spaceUri": { "type": "string", "format": "at-uri" }, + "kind": { "type": "string", "description": "Set for user-owned spaces — echoes the invite kind consumed." }, + "accessLevel": { "type": "string", "description": "Set for community-owned spaces — the level granted." }, + "communityDid": { "type": "string", "format": "did", "description": "Set for community-owned spaces." } + } + } + }, + "errors": [ + { "name": "InvalidInvite" }, + { "name": "NotFound" } + ] + } + } +} diff --git a/lexicon-templates/spaces/ext/invite/revoke.json b/lexicon-templates/invite/revoke.json similarity index 59% rename from lexicon-templates/spaces/ext/invite/revoke.json rename to lexicon-templates/invite/revoke.json index 13ecf62..aa534c1 100644 --- a/lexicon-templates/spaces/ext/invite/revoke.json +++ b/lexicon-templates/invite/revoke.json @@ -1,17 +1,17 @@ { "lexicon": 1, - "id": "tools.atmo.spaceExt.invite.revoke", + "id": "tools.atmo.invite.revoke", "defs": { "main": { "type": "procedure", - "description": "Revoke an invite. Owner only. Invites are identified by their tokenHash (visible in list output).", + "description": "Revoke an invite by tokenHash. User-owned spaces: owner-only. Community-owned spaces: invite creator OR manager+ on the target space.", "input": { "encoding": "application/json", "schema": { "type": "object", - "required": ["spaceUri", "tokenHash"], + "required": ["tokenHash"], "properties": { - "spaceUri": { "type": "string", "format": "at-uri" }, + "spaceUri": { "type": "string", "format": "at-uri", "description": "Optional — ownership is inferred from the invite row; required for user-owned spaces for a sanity check." }, "tokenHash": { "type": "string" } } } diff --git a/lexicon-templates/spaces/ext/invite/create.json b/lexicon-templates/spaces/ext/invite/create.json deleted file mode 100644 index 4450aed..0000000 --- a/lexicon-templates/spaces/ext/invite/create.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.spaceExt.invite.create", - "defs": { - "main": { - "type": "procedure", - "description": "Create an invite for a space. Caller must be the space owner. Returns the raw token once; only the hash is stored.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaceUri"], - "properties": { - "spaceUri": { "type": "string", "format": "at-uri" }, - "kind": { "type": "string", "knownValues": ["join", "read", "read-join"], "default": "join", "description": "join: redeem to become a member. read: bearer-only read access, no membership. read-join: anonymous read + signed-in redeem to join." }, - "expiresAt": { "type": "integer", "description": "Unix ms timestamp. Omit for no expiry." }, - "maxUses": { "type": "integer", "minimum": 1, "description": "Caps join redemptions only — read-token reads are unlimited. Omit for unlimited joins." }, - "note": { "type": "string", "maxLength": 500 } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["token", "invite"], - "properties": { - "token": { "type": "string", "description": "Raw token. Shown once — cannot be retrieved later." }, - "invite": { "type": "ref", "ref": "tools.atmo.space.defs#inviteView" } - } - } - }, - "errors": [ - { "name": "NotFound" }, - { "name": "Forbidden" } - ] - } - } -} diff --git a/lexicon-templates/spaces/ext/invite/redeem.json b/lexicon-templates/spaces/ext/invite/redeem.json deleted file mode 100644 index d0372c2..0000000 --- a/lexicon-templates/spaces/ext/invite/redeem.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.spaceExt.invite.redeem", - "defs": { - "main": { - "type": "procedure", - "description": "Redeem an invite token. The JWT issuer becomes a member of the space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["token"], - "properties": { - "token": { "type": "string" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["spaceUri"], - "properties": { - "spaceUri": { "type": "string", "format": "at-uri" } - } - } - }, - "errors": [ - { "name": "InvalidInvite" } - ] - } - } -} diff --git a/lexicon-templates/spaces/ext/whoami.json b/lexicon-templates/spaces/ext/whoami.json index 5736c86..0a61f21 100644 --- a/lexicon-templates/spaces/ext/whoami.json +++ b/lexicon-templates/spaces/ext/whoami.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Report the caller's relationship to a space: whether they are the owner and/or a member. Useful for clients to avoid a listMembers roundtrip.", + "description": "Caller's relationship to a space. For user-owned spaces membership is binary (`isMember`). For community-owned spaces `accessLevel` is also returned, resolved through the community's access-level ladder (including delegated grants).", "parameters": { "type": "params", "required": ["spaceUri"], @@ -18,8 +18,9 @@ "type": "object", "required": ["isOwner", "isMember"], "properties": { - "isOwner": { "type": "boolean" }, - "isMember": { "type": "boolean" } + "isOwner": { "type": "boolean" }, + "isMember": { "type": "boolean" }, + "accessLevel": { "type": "string", "knownValues": ["member", "manager", "admin", "owner"], "description": "Only set for community-owned spaces. Null when the caller has no resolvable access." } } } }, diff --git a/src/core/community/router.ts b/src/core/community/router.ts index b3dfcd2..99b1d76 100644 --- a/src/core/community/router.ts +++ b/src/core/community/router.ts @@ -25,8 +25,6 @@ import { RESERVED_KEYS, } from "./types"; import type { ServiceJwtVerifier } from "@atcute/xrpc-server/auth"; -import { hashInviteToken, mintInviteToken } from "../invite/token"; - export interface CommunityRoutesOptions { /** Override auth middleware for tests. */ authMiddleware?: MiddlewareHandler; @@ -254,15 +252,8 @@ export function registerCommunityRoutes( }); }); - app.get(`/xrpc/${NS}.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 level = await resolveEffectiveLevel(community, spaceUri, sa.issuer); - return c.json({ spaceUri, accessLevel: level }); - }); + // `whoami` lives under `.spaceExt.whoami` — unified across user-owned + // and community-owned spaces, returns `{ isOwner, isMember, accessLevel? }`. // ========================================================================== // Space lifecycle @@ -973,164 +964,9 @@ export function registerCommunityRoutes( return c.json({ ok: true }); }); - // ========================================================================== - // Invites — pre-signed grants for community-owned spaces. Admin/manager - // creates; any authenticated user with the raw token redeems once. - // ========================================================================== - - app.post(`/xrpc/${NS}.invite.create`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { - spaceUri?: string; - accessLevel?: string; - expiresAt?: number; - maxUses?: number; - note?: string; - } - | null; - if (!body?.spaceUri || !body.accessLevel) { - return c.json( - { error: "InvalidRequest", message: "spaceUri and accessLevel required" }, - 400 - ); - } - if (!isAccessLevel(body.accessLevel)) { - return c.json({ error: "InvalidRequest", message: "invalid accessLevel" }, 400); - } - const space = await spaces.getSpace(body.spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - const communityRow = await community.getCommunity(space.ownerDid); - if (!communityRow) { - return c.json({ error: "InvalidRequest", reason: "not-community-owned" }, 400); - } - - // Caller must have at least manager on the target space, and cannot create - // an invite that confers a higher level than their own. - const callerLevel = await resolveEffectiveLevel(community, body.spaceUri, sa.issuer); - if (!callerLevel || rankOf(callerLevel) < rankOf("manager")) { - return c.json({ error: "Forbidden", reason: "manager-required" }, 403); - } - if (rankOf(body.accessLevel) > rankOf(callerLevel)) { - return c.json({ error: "Forbidden", reason: "cannot-grant-higher-than-self" }, 403); - } - - const { token, tokenHash } = await mintInviteToken(); - - const row = await community.createInvite({ - spaceUri: body.spaceUri, - tokenHash, - accessLevel: body.accessLevel, - createdBy: sa.issuer, - expiresAt: body.expiresAt ?? null, - maxUses: body.maxUses ?? null, - note: body.note ?? null, - }); - - // Raw token only returned once. tokenHash is the stable ID for list/revoke. - return c.json({ - token, - tokenHash, - spaceUri: row.spaceUri, - accessLevel: row.accessLevel, - expiresAt: row.expiresAt, - maxUses: row.maxUses, - createdAt: row.createdAt, - }); - }); - - app.get(`/xrpc/${NS}.invite.list`, auth, async (c) => { - const sa = getAuth(c); - const spaceUri = c.req.query("spaceUri"); - const includeRevoked = c.req.query("includeRevoked") === "true"; - if (!spaceUri) { - return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); - } - const space = await spaces.getSpace(spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); - - const callerLevel = await resolveEffectiveLevel(community, spaceUri, sa.issuer); - if (!callerLevel || rankOf(callerLevel) < rankOf("manager")) { - return c.json({ error: "Forbidden", reason: "manager-required" }, 403); - } - - const rows = await community.listInvites(spaceUri, { includeRevoked }); - return c.json({ - invites: rows.map((r) => ({ - tokenHash: r.tokenHash, - spaceUri: r.spaceUri, - accessLevel: r.accessLevel, - createdBy: r.createdBy, - createdAt: r.createdAt, - expiresAt: r.expiresAt, - maxUses: r.maxUses, - usedCount: r.usedCount, - revokedAt: r.revokedAt, - note: r.note, - })), - }); - }); - - app.post(`/xrpc/${NS}.invite.revoke`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { tokenHash?: string } - | null; - if (!body?.tokenHash) { - return c.json({ error: "InvalidRequest", message: "tokenHash required" }, 400); - } - const invite = await community.getInvite(body.tokenHash); - if (!invite) return c.json({ error: "NotFound" }, 404); - - // Revoker: either the invite's creator, or manager+ on the target space. - let allowed = invite.createdBy === sa.issuer; - if (!allowed) { - const level = await resolveEffectiveLevel(community, invite.spaceUri, sa.issuer); - allowed = !!level && rankOf(level) >= rankOf("manager"); - } - if (!allowed) { - return c.json({ error: "Forbidden", reason: "creator-or-manager-required" }, 403); - } - await community.revokeInvite(body.tokenHash); - return c.json({ ok: true }); - }); - - app.post(`/xrpc/${NS}.invite.redeem`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as { token?: string } | null; - if (!body?.token) { - return c.json({ error: "InvalidRequest", message: "token required" }, 400); - } - const tokenHash = await hashInviteToken(body.token); - - // Atomic consume — returns null if the token is expired/revoked/exhausted. - const invite = await community.redeemInvite(tokenHash, Date.now()); - if (!invite) { - return c.json({ error: "InvalidToken", reason: "token-invalid-or-exhausted" }, 410); - } - - const space = await spaces.getSpace(invite.spaceUri); - if (!space) { - return c.json({ error: "NotFound", reason: "space-not-found" }, 404); - } - - // The token itself is the authorization: the creator (who had manager+) - // pre-signed "anyone with this token gets level X on this space". Grant - // directly, attributing to the creator so audit trails make sense. - await community.grant({ - spaceUri: invite.spaceUri, - subjectDid: sa.issuer, - accessLevel: invite.accessLevel, - grantedBy: invite.createdBy, - }); - await reconcile(community, spaces, invite.spaceUri, invite.createdBy); - - return c.json({ - spaceUri: invite.spaceUri, - accessLevel: invite.accessLevel, - communityDid: space.ownerDid, - }); - }); + // Invites live under `.invite.*` — see src/core/invite/router.ts. The + // unified surface dispatches on space ownership to choose between the + // ladder-granting path here and the binary-membership path in spaces. } // ============================================================================ diff --git a/src/core/invite/index.ts b/src/core/invite/index.ts index d79fc3b..8dae0df 100644 --- a/src/core/invite/index.ts +++ b/src/core/invite/index.ts @@ -1 +1,3 @@ export { generateInviteToken, hashInviteToken, mintInviteToken } from "./token"; +export { registerInviteRoutes } from "./router"; +export type { InviteRoutesOptions } from "./router"; diff --git a/src/core/invite/router.ts b/src/core/invite/router.ts new file mode 100644 index 0000000..0b9a4fc --- /dev/null +++ b/src/core/invite/router.ts @@ -0,0 +1,317 @@ +/** Unified invite surface: a single `.invite.*` family serving both + * user-owned and community-owned spaces. Dispatches on space ownership. + * + * - User-owned space → `kind` in create, `addMember` on redeem, owner-only. + * - Community-owned → `accessLevel` in create, `grant` on redeem, + * manager+ with "cannot grant higher than self". + * + * Storage stays separate (`spaces_invites` vs `community_invites` tables) — + * schemas differ enough that unifying them would be net-negative. The token + * primitive and HTTP dance are shared. */ + +import type { Context, Hono, MiddlewareHandler } from "hono"; +import type { ContrailConfig } from "../types"; +import type { ServiceAuth } from "../spaces/auth"; +import type { StorageAdapter } from "../spaces/types"; +import type { InviteKind, InviteRow } from "../spaces/types"; +import type { CommunityAdapter } from "../community/adapter"; +import type { CommunityInviteRow, AccessLevel } from "../community/types"; +import { isAccessLevel, rankOf } from "../community/types"; +import { hashInviteToken, mintInviteToken } from "./token"; +import { resolveEffectiveLevel } from "../community/acl"; +import { reconcile } from "../community/reconcile"; + +export interface InviteRoutesOptions { + authMiddleware: MiddlewareHandler; +} + +/** Shape returned to clients — `kind` (user-owned space) or `accessLevel` + * (community-owned space) is set, never both. */ +interface PublicInviteView { + tokenHash: string; + spaceUri: string; + kind?: InviteKind; + accessLevel?: AccessLevel; + createdBy: string; + createdAt: number; + expiresAt: number | null; + maxUses: number | null; + usedCount: number; + revokedAt: number | null; + note: string | null; +} + +function toSpacesView(row: InviteRow): PublicInviteView { + return { + tokenHash: row.tokenHash, + spaceUri: row.spaceUri, + kind: row.kind, + createdBy: row.createdBy, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + maxUses: row.maxUses, + usedCount: row.usedCount, + revokedAt: row.revokedAt, + note: row.note, + }; +} + +function toCommunityView(row: CommunityInviteRow): PublicInviteView { + return { + tokenHash: row.tokenHash, + spaceUri: row.spaceUri, + accessLevel: row.accessLevel, + createdBy: row.createdBy, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + maxUses: row.maxUses, + usedCount: row.usedCount, + revokedAt: row.revokedAt, + note: row.note, + }; +} + +export function registerInviteRoutes( + app: Hono, + config: ContrailConfig, + spaces: StorageAdapter, + community: CommunityAdapter | null, + options: InviteRoutesOptions +): void { + if (!config.spaces) return; + + const NS = `${config.namespace}.invite`; + const auth = options.authMiddleware; + + /** Resolve whether a space is community-owned. Returns null if the space + * doesn't exist. */ + const classifySpace = async (spaceUri: string) => { + const space = await spaces.getSpace(spaceUri); + if (!space) return null; + const isCommunity = community ? !!(await community.getCommunity(space.ownerDid)) : false; + return { space, isCommunity }; + }; + + app.post(`/xrpc/${NS}.create`, auth, async (c) => { + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as + | { + spaceUri?: string; + kind?: string; + accessLevel?: string; + expiresAt?: number; + maxUses?: number; + note?: string; + } + | null; + if (!body?.spaceUri) { + return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); + } + if (body.kind && body.accessLevel) { + return c.json( + { error: "InvalidRequest", reason: "kind-or-accessLevel", message: "pass kind OR accessLevel, not both" }, + 400 + ); + } + + const classified = await classifySpace(body.spaceUri); + if (!classified) return c.json({ error: "NotFound" }, 404); + const { space, isCommunity } = classified; + + if (isCommunity) { + if (!community) return c.json({ error: "InvalidState" }, 500); + if (body.kind) { + return c.json( + { error: "InvalidRequest", reason: "kind-on-community-space", message: "community spaces take accessLevel, not kind" }, + 400 + ); + } + if (!body.accessLevel || !isAccessLevel(body.accessLevel)) { + return c.json({ error: "InvalidRequest", reason: "accessLevel-required" }, 400); + } + // Caller must have manager+ on the target space and cannot create an + // invite that confers a higher level than their own. + const callerLevel = await resolveEffectiveLevel(community, body.spaceUri, sa.issuer); + if (!callerLevel || rankOf(callerLevel) < rankOf("manager")) { + return c.json({ error: "Forbidden", reason: "manager-required" }, 403); + } + if (rankOf(body.accessLevel) > rankOf(callerLevel)) { + return c.json({ error: "Forbidden", reason: "cannot-grant-higher-than-self" }, 403); + } + const { token, tokenHash } = await mintInviteToken(); + const row = await community.createInvite({ + spaceUri: body.spaceUri, + tokenHash, + accessLevel: body.accessLevel, + createdBy: sa.issuer, + expiresAt: body.expiresAt ?? null, + maxUses: body.maxUses ?? null, + note: body.note ?? null, + }); + return c.json({ token, invite: toCommunityView(row) }); + } + + // User-owned space. + if (body.accessLevel) { + return c.json( + { error: "InvalidRequest", reason: "accessLevel-on-user-space", message: "user-owned spaces take kind, not accessLevel" }, + 400 + ); + } + if (space.ownerDid !== sa.issuer) { + return c.json({ error: "Forbidden", reason: "not-owner" }, 403); + } + const kind = (body.kind ?? "join") as InviteKind; + if (kind !== "join" && kind !== "read" && kind !== "read-join") { + return c.json({ error: "InvalidRequest", message: "kind must be 'join', 'read', or 'read-join'" }, 400); + } + const { token, tokenHash } = await mintInviteToken(); + const invite = await spaces.createInvite({ + spaceUri: body.spaceUri, + tokenHash, + kind, + expiresAt: body.expiresAt ?? null, + maxUses: body.maxUses ?? null, + createdBy: sa.issuer, + note: body.note ?? null, + }); + return c.json({ token, invite: toSpacesView(invite) }); + }); + + app.get(`/xrpc/${NS}.list`, 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 includeRevoked = c.req.query("includeRevoked") === "true"; + + const classified = await classifySpace(spaceUri); + if (!classified) return c.json({ error: "NotFound" }, 404); + const { space, isCommunity } = classified; + + if (isCommunity) { + const callerLevel = await resolveEffectiveLevel(community!, spaceUri, sa.issuer); + if (!callerLevel || rankOf(callerLevel) < rankOf("manager")) { + return c.json({ error: "Forbidden", reason: "manager-required" }, 403); + } + const rows = await community!.listInvites(spaceUri, { includeRevoked }); + return c.json({ invites: rows.map(toCommunityView) }); + } + + if (space.ownerDid !== sa.issuer) { + return c.json({ error: "Forbidden", reason: "not-owner" }, 403); + } + const rows = await spaces.listInvites(spaceUri, { includeRevoked }); + return c.json({ invites: rows.map(toSpacesView) }); + }); + + app.post(`/xrpc/${NS}.revoke`, auth, async (c) => { + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as + | { spaceUri?: string; tokenHash?: string } + | null; + if (!body?.tokenHash) { + return c.json({ error: "InvalidRequest", message: "tokenHash required" }, 400); + } + + // When the caller passes spaceUri we do an auth check up front so the + // response doesn't leak token existence. Community revokers may also be + // the invite creator (even without manager+), which is resolved after. + if (body.spaceUri) { + const classified = await classifySpace(body.spaceUri); + if (!classified) return c.json({ error: "NotFound" }, 404); + if (classified.isCommunity) { + const level = await resolveEffectiveLevel(community!, body.spaceUri, sa.issuer); + const managerOrHigher = !!level && rankOf(level) >= rankOf("manager"); + if (!managerOrHigher) { + const crow = await community!.getInvite(body.tokenHash); + if (!crow || crow.createdBy !== sa.issuer) { + return c.json({ error: "Forbidden", reason: "creator-or-manager-required" }, 403); + } + } + const ok = await community!.revokeInvite(body.tokenHash); + return c.json({ ok }); + } + if (classified.space.ownerDid !== sa.issuer) { + return c.json({ error: "Forbidden", reason: "not-owner" }, 403); + } + const ok = await spaces.revokeInvite(body.tokenHash); + return c.json({ ok }); + } + + // No spaceUri provided — infer from the invite row. + if (community) { + const crow = await community.getInvite(body.tokenHash); + if (crow) { + let allowed = crow.createdBy === sa.issuer; + if (!allowed) { + const level = await resolveEffectiveLevel(community, crow.spaceUri, sa.issuer); + allowed = !!level && rankOf(level) >= rankOf("manager"); + } + if (!allowed) { + return c.json({ error: "Forbidden", reason: "creator-or-manager-required" }, 403); + } + const ok = await community.revokeInvite(body.tokenHash); + return c.json({ ok }); + } + } + const srow = await spaces.getInvite(body.tokenHash); + if (!srow) return c.json({ error: "NotFound" }, 404); + const space = await spaces.getSpace(srow.spaceUri); + if (space && space.ownerDid !== sa.issuer) { + return c.json({ error: "Forbidden", reason: "not-owner" }, 403); + } + const ok = await spaces.revokeInvite(body.tokenHash); + return c.json({ ok }); + }); + + app.post(`/xrpc/${NS}.redeem`, auth, async (c) => { + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as { token?: string } | null; + if (!body?.token) { + return c.json({ error: "InvalidRequest", message: "token required" }, 400); + } + const tokenHash = await hashInviteToken(body.token); + const now = Date.now(); + + // Try community first (it's atomic — returns null if not consumable). + if (community) { + const cinvite = await community.redeemInvite(tokenHash, now); + if (cinvite) { + const space = await spaces.getSpace(cinvite.spaceUri); + if (!space) { + return c.json({ error: "NotFound", reason: "space-not-found" }, 404); + } + // The token itself is the authorization: creator (manager+) pre-signed + // "anyone with this token gets level X". Grant directly, attributing + // to the creator so audit trails make sense. + await community.grant({ + spaceUri: cinvite.spaceUri, + subjectDid: sa.issuer, + accessLevel: cinvite.accessLevel, + grantedBy: cinvite.createdBy, + }); + await reconcile(community, spaces, cinvite.spaceUri, cinvite.createdBy); + return c.json({ + spaceUri: cinvite.spaceUri, + accessLevel: cinvite.accessLevel, + communityDid: space.ownerDid, + }); + } + } + + // Fall back to the spaces (user-owned) path. The spaces redeem filter + // already restricts to `kind IN ('join','read-join')` at the SQL level. + const sinvite = await spaces.redeemInvite(tokenHash, now); + if (!sinvite) { + return c.json({ error: "InvalidInvite", reason: "expired-revoked-or-exhausted" }, 400); + } + await spaces.addMember(sinvite.spaceUri, sa.issuer, sinvite.createdBy); + return c.json({ spaceUri: sinvite.spaceUri, kind: sinvite.kind }); + }); +} + +function getAuth(c: Context): ServiceAuth { + const a = c.get("serviceAuth") as ServiceAuth | undefined; + if (!a) throw new Error("service auth not set"); + return a; +} diff --git a/src/core/router/index.ts b/src/core/router/index.ts index 74f0499..5636762 100644 --- a/src/core/router/index.ts +++ b/src/core/router/index.ts @@ -17,6 +17,7 @@ import type { CommunityRoutesOptions } from "../community/router"; import { CommunityAdapter } from "../community/adapter"; import { registerRealtimeRoutes } from "../realtime/router"; import type { RealtimeRoutesOptions } from "../realtime/router"; +import { registerInviteRoutes } from "../invite/router"; import { InMemoryPubSub } from "../realtime/in-memory"; import { wrapWithPublishing } from "../realtime/publishing-adapter"; import type { PubSub } from "../realtime/types"; @@ -116,7 +117,10 @@ export function createApp( registerCollectionRoutes(app, db, config, spacesCtx, { pubsub: realtimePubsub }); registerFeedRoutes(app, db, config); registerNotifyRoute(app, db, config); - registerSpacesRoutes(app, spacesDb, config, options.spaces, spacesCtx); + const communityAdapterForSpaces = config.community && spacesCtx + ? new CommunityAdapter(spacesDb) + : null; + registerSpacesRoutes(app, spacesDb, config, options.spaces, spacesCtx, communityAdapterForSpaces); const authOverride = config.spaces?.authOverride; @@ -135,6 +139,16 @@ export function createApp( ); } + if (config.spaces && spacesCtx) { + // Unified invite surface: one `.invite.*` family that dispatches on + // space ownership (user-owned → addMember; community-owned → grant). + const authMiddleware = + options.spaces?.authMiddleware ?? + createServiceAuthMiddleware(spacesCtx.verifier, { authOverride }); + const communityAdapter = config.community ? new CommunityAdapter(spacesDb) : null; + registerInviteRoutes(app, config, spacesCtx.adapter, communityAdapter, { authMiddleware }); + } + if (config.realtime && realtimePubsub) { // The ticket endpoint still needs a JWT verifier — but that verifier only // exists when spaces is configured. Without spaces, private-topic ticket diff --git a/src/core/spaces/router.ts b/src/core/spaces/router.ts index a14c880..b955150 100644 --- a/src/core/spaces/router.ts +++ b/src/core/spaces/router.ts @@ -11,12 +11,11 @@ import { verifyServiceAuthRequest, } from "./auth"; import { nextTid } from "./tid"; -import { hashInviteToken, mintInviteToken } from "../invite/token"; +import { hashInviteToken } from "../invite/token"; +import { resolveEffectiveLevel } from "../community/acl"; import { buildSpaceUri } from "./uri"; import { DEFAULT_BLOB_MAX_SIZE, - type InviteKind, - type InviteRow, type SpaceRow, type SpacesConfig, type StorageAdapter, @@ -38,7 +37,8 @@ export function registerSpacesRoutes( db: Database, config: ContrailConfig, options: SpacesRoutesOptions = {}, - ctx?: { adapter: StorageAdapter; verifier: import("@atcute/xrpc-server/auth").ServiceJwtVerifier } | null + ctx?: { adapter: StorageAdapter; verifier: import("@atcute/xrpc-server/auth").ServiceJwtVerifier } | null, + community?: import("../community/adapter").CommunityAdapter | null ): void { const spacesConfig = config.spaces; if (!spacesConfig) return; @@ -486,89 +486,8 @@ export function registerSpacesRoutes( return c.json({ space: publicSpaceView(space, true) }); }); - // Invites (contrail extras — emitted under .spaceExt.invite.*) - app.post(`/xrpc/${SPACE_EXT}.invite.create`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { - spaceUri?: string; - kind?: InviteKind; - expiresAt?: number; - maxUses?: number; - note?: string; - } - | null; - if (!body?.spaceUri) { - return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); - } - const kind: InviteKind = body.kind ?? "join"; - if (kind !== "join" && kind !== "read" && kind !== "read-join") { - return c.json({ error: "InvalidRequest", message: "kind must be 'join', 'read', or 'read-join'" }, 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); - } - - const { token, tokenHash } = await mintInviteToken(); - const invite = await adapter.createInvite({ - spaceUri: body.spaceUri, - tokenHash, - kind, - expiresAt: body.expiresAt ?? null, - maxUses: body.maxUses ?? null, - createdBy: sa.issuer, - note: body.note ?? null, - }); - return c.json({ token, invite: publicInviteView(invite) }); - }); - - app.post(`/xrpc/${SPACE_EXT}.invite.redeem`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as { token?: string } | null; - if (!body?.token) { - return c.json({ error: "InvalidRequest", message: "token required" }, 400); - } - const tokenHash = await hashInviteToken(body.token); - const invite = await adapter.redeemInvite(tokenHash, Date.now()); - if (!invite) { - return c.json({ error: "InvalidInvite", reason: "expired-revoked-or-exhausted" }, 400); - } - await adapter.addMember(invite.spaceUri, sa.issuer, invite.createdBy); - return c.json({ spaceUri: invite.spaceUri }); - }); - - app.get(`/xrpc/${SPACE_EXT}.invite.list`, 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); - if (space.ownerDid !== sa.issuer) { - return c.json({ error: "Forbidden", reason: "not-owner" }, 403); - } - const includeRevoked = c.req.query("includeRevoked") === "true"; - const invites = await adapter.listInvites(spaceUri, { includeRevoked }); - return c.json({ invites: invites.map(publicInviteView) }); - }); - - app.post(`/xrpc/${SPACE_EXT}.invite.revoke`, auth, async (c) => { - const sa = getAuth(c); - const body = (await c.req.json().catch(() => null)) as - | { spaceUri?: string; tokenHash?: string } - | null; - if (!body?.spaceUri || !body.tokenHash) { - return c.json({ error: "InvalidRequest", message: "spaceUri and tokenHash 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); - } - const ok = await adapter.revokeInvite(body.tokenHash); - return c.json({ ok }); - }); + // Invites live under `.invite.*` — see src/core/invite/router.ts. The + // unified surface dispatches on space ownership. app.post(`/xrpc/${SPACE}.addMember`, auth, async (c) => { const sa = getAuth(c); @@ -625,6 +544,9 @@ export function registerSpacesRoutes( return c.json({ ok: true }); }); + // Unified whoami — `.spaceExt.whoami?spaceUri=X` → { isOwner, isMember, + // accessLevel? }. `accessLevel` is present only when the target space is + // community-owned; for user-owned spaces membership is binary. app.get(`/xrpc/${SPACE_EXT}.whoami`, auth, async (c) => { const sa = getAuth(c); const spaceUri = c.req.query("spaceUri"); @@ -633,12 +555,24 @@ export function registerSpacesRoutes( if (!space) return c.json({ error: "NotFound" }, 404); const isOwner = space.ownerDid === sa.issuer; - if (isOwner) { - return c.json({ isOwner: true, isMember: true }); + + // Community-owned space: resolve through the access-level ladder. The + // reconciler keeps spaces_members in sync, so isMember derives from the + // effective level directly. + const isCommunity = community ? !!(await community.getCommunity(space.ownerDid)) : false; + if (isCommunity) { + const level = await resolveEffectiveLevel(community!, spaceUri, sa.issuer); + return c.json({ + isOwner, + isMember: isOwner || !!level, + accessLevel: level, + }); } + + // User-owned space: binary membership. + if (isOwner) return c.json({ isOwner: true, isMember: true }); const member = await adapter.getMember(spaceUri, sa.issuer); - if (!member) return c.json({ isOwner: false, isMember: false }); - return c.json({ isOwner: false, isMember: true }); + return c.json({ isOwner: false, isMember: !!member }); }); } @@ -653,20 +587,6 @@ function getAuth(c: Parameters[0]): ServiceAuth { return auth; } -function publicInviteView(invite: InviteRow) { - return { - tokenHash: invite.tokenHash, - spaceUri: invite.spaceUri, - kind: invite.kind, - expiresAt: invite.expiresAt, - maxUses: invite.maxUses, - usedCount: invite.usedCount, - createdBy: invite.createdBy, - createdAt: invite.createdAt, - revokedAt: invite.revokedAt, - note: invite.note, - }; -} function publicSpaceView(space: SpaceRow, forOwner: boolean) { return { diff --git a/src/generate.ts b/src/generate.ts index 63e55d1..bf9ed21 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -64,7 +64,10 @@ function fieldToParam(field: string): string { * @atmo-dev/contrail. All three module templates live under one * `lexicon-templates/` parent; each subdir is instantiated by the generator * into its own `.*` namespace. */ -function findTemplatesDir(rootDir: string, module: "spaces" | "community" | "realtime"): string | null { +function findTemplatesDir( + rootDir: string, + module: "spaces" | "community" | "realtime" | "invite" +): string | null { const candidates = [ join(rootDir, "lexicon-templates", module), join(rootDir, "node_modules/@atmo-dev/contrail/lexicon-templates", module), @@ -78,6 +81,7 @@ function findTemplatesDir(rootDir: string, module: "spaces" | "community" | "rea const findSpaceTemplatesDir = (rootDir: string) => findTemplatesDir(rootDir, "spaces"); const findCommunityTemplatesDir = (rootDir: string) => findTemplatesDir(rootDir, "community"); const findRealtimeTemplatesDir = (rootDir: string) => findTemplatesDir(rootDir, "realtime"); +const findInviteTemplatesDir = (rootDir: string) => findTemplatesDir(rootDir, "invite"); /** Yield all JSON files under a directory (recursive). */ function* walkJson(dir: string): Generator { @@ -893,6 +897,51 @@ export function generateLexicons(options: GenerateOptions): Record.invite.* family that dispatches on space + // ownership. Emitted whenever spaces is configured (user-owned spaces + // can always issue invites); community-owned spaces add the + // `accessLevel` branch inside the same handlers. --- + + if (config.spaces) { + log("Generating invite endpoints..."); + const templatesDir = findInviteTemplatesDir(rootDir); + if (!templatesDir) { + log(" (invite templates not found — skipping)"); + } else { + const templateIdRe = /^tools\.atmo\.invite(\.[A-Za-z0-9.]+)?$/; + const idReplace = (id: string) => + id.startsWith("tools.atmo.invite") + ? id.replace(/^tools\.atmo\.invite/, `${ns}.invite`) + : id; + + const rewriteRefs = (obj: any): any => { + if (Array.isArray(obj)) return obj.map(rewriteRefs); + if (obj && typeof obj === "object") { + const out: any = {}; + for (const [k, v] of Object.entries(obj)) { + if (k === "ref" && typeof v === "string" && v.startsWith("tools.atmo.invite")) { + out[k] = v.replace(/^tools\.atmo\.invite/, `${ns}.invite`); + } else if (k === "id" && typeof v === "string" && templateIdRe.test(v)) { + out[k] = idReplace(v); + } else { + out[k] = rewriteRefs(v); + } + } + return out; + } + return obj; + }; + + for (const file of walkJson(templatesDir)) { + const doc = JSON.parse(readFileSync(file, "utf-8")); + if (typeof doc.id !== "string" || !templateIdRe.test(doc.id)) continue; + const newId = idReplace(doc.id); + const rewritten = rewriteRefs({ ...doc, id: newId }); + writeLexicon(newId, rewritten); + } + } + } + // --- Permission set --- // Permission-set lexicons (https://atproto.com/guides/permission-sets) can // only reference NSIDs under the same namespace as the set itself, which diff --git a/tests/community-delegation.test.ts b/tests/community-delegation.test.ts index bee3af4..7f9e58e 100644 --- a/tests/community-delegation.test.ts +++ b/tests/community-delegation.test.ts @@ -125,7 +125,7 @@ async function grant(app: Hono, caller: string, spaceUri: string, subject: any, } async function whoamiLevel(app: Hono, caller: string, spaceUri: string): Promise { - const res = await call(app, "GET", `/xrpc/test.comm.community.space.whoami?spaceUri=${encodeURIComponent(spaceUri)}`, caller); + const res = await call(app, "GET", `/xrpc/test.comm.spaceExt.whoami?spaceUri=${encodeURIComponent(spaceUri)}`, caller); expect(res.status).toBe(200); return ((await res.json()) as any).accessLevel; } diff --git a/tests/community-e2e.test.ts b/tests/community-e2e.test.ts index f9d5338..84feb25 100644 --- a/tests/community-e2e.test.ts +++ b/tests/community-e2e.test.ts @@ -136,13 +136,13 @@ describe("community e2e — stage 1", () => { // whoami in both reserved spaces → owner for (const uri of [adminUri, publishersUri]) { - const r = await call(app, "GET", `/xrpc/test.comm.community.space.whoami?spaceUri=${encodeURIComponent(uri)}`, ALICE); + const r = await call(app, "GET", `/xrpc/test.comm.spaceExt.whoami?spaceUri=${encodeURIComponent(uri)}`, ALICE); expect(r.status).toBe(200); expect(((await r.json()) as any).accessLevel).toBe("owner"); } // Bob isn't in either - const r = await call(app, "GET", `/xrpc/test.comm.community.space.whoami?spaceUri=${encodeURIComponent(adminUri)}`, BOB); + const r = await call(app, "GET", `/xrpc/test.comm.spaceExt.whoami?spaceUri=${encodeURIComponent(adminUri)}`, BOB); expect(r.status).toBe(200); expect(((await r.json()) as any).accessLevel).toBe(null); }); @@ -255,7 +255,7 @@ describe("community e2e — stage 1", () => { const level = await call( app, "GET", - `/xrpc/test.comm.community.space.whoami?spaceUri=${encodeURIComponent(spaceUri)}`, + `/xrpc/test.comm.spaceExt.whoami?spaceUri=${encodeURIComponent(spaceUri)}`, BOB ); expect(((await level.json()) as any).accessLevel).toBe(null); diff --git a/tests/community-mint.test.ts b/tests/community-mint.test.ts index b573dfc..4e5e03d 100644 --- a/tests/community-mint.test.ts +++ b/tests/community-mint.test.ts @@ -150,7 +150,7 @@ describe("community.mint — stage 4", () => { // Reserved spaces exist with the caller as owner. const adminUri = `at://${body.communityDid}/tools.atmo.event.space/$admin`; - const whoami = await call(app, "GET", `/xrpc/test.comm.community.space.whoami?spaceUri=${encodeURIComponent(adminUri)}`, ALICE); + const whoami = await call(app, "GET", `/xrpc/test.comm.spaceExt.whoami?spaceUri=${encodeURIComponent(adminUri)}`, ALICE); expect(((await whoami.json()) as any).accessLevel).toBe("owner"); }); diff --git a/tests/invite-unified.test.ts b/tests/invite-unified.test.ts new file mode 100644 index 0000000..cafc755 --- /dev/null +++ b/tests/invite-unified.test.ts @@ -0,0 +1,213 @@ +/** Unified invite surface: `.invite.*` dispatches on space ownership. + * - user-owned space → `kind` in create, `addMember` on redeem. + * - community-owned → `accessLevel` in create, `grant`+`reconcile` on redeem. + * + * Both paths through a single endpoint family. */ + +import { describe, it, expect, beforeAll } from "vitest"; +import { Hono } from "hono"; +import type { MiddlewareHandler } from "hono"; +import { createSqliteDatabase } from "../src/adapters/sqlite"; +import { initSchema } from "../src/core/db/schema"; +import { createApp } from "../src/core/router"; +import { resolveConfig } from "../src/core/types"; +import type { ContrailConfig } from "../src/core/types"; + +const ALICE = "did:plc:alice"; +const BOB = "did:plc:bob"; +const CHARLIE = "did:plc:charlie"; +const COMMUNITY_DID = "did:plc:acmecommunity"; +const PDS = "https://pds.example"; +const MASTER_KEY = new Uint8Array(32).fill(11); + +const CONFIG: ContrailConfig = { + namespace: "test.inv", + collections: { message: { collection: "app.event.message" } }, + spaces: { type: "tools.atmo.event.space", serviceDid: "did:web:test.example#svc" }, + community: { + masterKey: MASTER_KEY, + fetch: mockFetch, + resolver: mockResolver(), + }, +}; + +function mockResolver(): any { + return { + resolve: async (_did: string) => ({ + id: _did, + service: [{ id: "#atproto_pds", type: "AtprotoPersonalDataServer", serviceEndpoint: PDS }], + }), + }; +} + +async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/xrpc/com.atproto.server.createSession") && init?.method === "POST") { + return new Response( + JSON.stringify({ accessJwt: "a.b.c", refreshJwt: "r.r.r", did: COMMUNITY_DID }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + return new Response("not found", { status: 404 }); +} + +function fakeAuth(): MiddlewareHandler { + return async (c, next) => { + const did = c.req.header("X-Test-Did"); + if (!did) return c.json({ error: "AuthRequired" }, 401); + c.set("serviceAuth", { issuer: did, audience: CONFIG.spaces!.serviceDid, lxm: undefined }); + await next(); + }; +} + +async function makeApp(): Promise { + const db = createSqliteDatabase(":memory:"); + const resolved = resolveConfig(CONFIG); + await initSchema(db, resolved); + return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); +} + +function call(app: Hono, method: string, path: string, did: string, body?: any): Promise { + const headers: Record = { "X-Test-Did": did }; + if (body !== undefined) headers["Content-Type"] = "application/json"; + return app.fetch( + new Request(`http://localhost${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + ); +} + +describe(".invite.* — unified surface", () => { + describe("user-owned space (kind)", () => { + let app: Hono; + let spaceUri: string; + + beforeAll(async () => { + app = await makeApp(); + const res = await call(app, "POST", "/xrpc/test.inv.space.createSpace", ALICE, { key: "lounge" }); + expect(res.status).toBe(200); + spaceUri = ((await res.json()) as any).space.uri; + }); + + it("rejects accessLevel on a user-owned space", async () => { + const res = await call(app, "POST", "/xrpc/test.inv.invite.create", ALICE, { + spaceUri, + accessLevel: "member", + }); + expect(res.status).toBe(400); + expect(((await res.json()) as any).reason).toBe("accessLevel-on-user-space"); + }); + + it("owner creates kind=join; redeemer becomes a member", async () => { + const create = await call(app, "POST", "/xrpc/test.inv.invite.create", ALICE, { spaceUri }); + expect(create.status).toBe(200); + const { token, invite } = (await create.json()) as any; + expect(invite.kind).toBe("join"); + expect(invite.accessLevel).toBeUndefined(); + + const redeem = await call(app, "POST", "/xrpc/test.inv.invite.redeem", BOB, { token }); + expect(redeem.status).toBe(200); + const body = (await redeem.json()) as any; + expect(body.spaceUri).toBe(spaceUri); + expect(body.kind).toBe("join"); + expect(body.accessLevel).toBeUndefined(); + }); + }); + + describe("community-owned space (accessLevel)", () => { + let app: Hono; + let adminUri: string; + let channelUri: string; + + beforeAll(async () => { + app = await makeApp(); + // Adopt a community owned by Alice. + const adopt = await call(app, "POST", "/xrpc/test.inv.community.adopt", ALICE, { + identifier: COMMUNITY_DID, + appPassword: "anything", + }); + expect(adopt.status).toBe(200); + adminUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/$admin`; + + // Alice (owner in $admin) creates a child space. + const create = await call(app, "POST", "/xrpc/test.inv.community.space.create", ALICE, { + communityDid: COMMUNITY_DID, + key: "general", + }); + expect(create.status).toBe(200); + channelUri = ((await create.json()) as any).space.uri; + }); + + it("rejects kind on a community-owned space", async () => { + const res = await call(app, "POST", "/xrpc/test.inv.invite.create", ALICE, { + spaceUri: channelUri, + kind: "join", + }); + expect(res.status).toBe(400); + expect(((await res.json()) as any).reason).toBe("kind-on-community-space"); + }); + + it("requires accessLevel", async () => { + const res = await call(app, "POST", "/xrpc/test.inv.invite.create", ALICE, { + spaceUri: channelUri, + }); + expect(res.status).toBe(400); + expect(((await res.json()) as any).reason).toBe("accessLevel-required"); + }); + + it("owner creates with accessLevel=member; redeemer is granted that level", async () => { + const create = await call(app, "POST", "/xrpc/test.inv.invite.create", ALICE, { + spaceUri: channelUri, + accessLevel: "member", + }); + expect(create.status).toBe(200); + const { token, invite } = (await create.json()) as any; + expect(invite.accessLevel).toBe("member"); + expect(invite.kind).toBeUndefined(); + + const redeem = await call(app, "POST", "/xrpc/test.inv.invite.redeem", BOB, { token }); + expect(redeem.status).toBe(200); + const body = (await redeem.json()) as any; + expect(body.spaceUri).toBe(channelUri); + expect(body.accessLevel).toBe("member"); + expect(body.communityDid).toBe(COMMUNITY_DID); + + // Bob now shows up on whoami via the ladder. + const w = await call(app, "GET", `/xrpc/test.inv.spaceExt.whoami?spaceUri=${encodeURIComponent(channelUri)}`, BOB); + expect(((await w.json()) as any).accessLevel).toBe("member"); + }); + + it("non-manager cannot create an invite on a community space", async () => { + const res = await call(app, "POST", "/xrpc/test.inv.invite.create", CHARLIE, { + spaceUri: channelUri, + accessLevel: "member", + }); + expect(res.status).toBe(403); + expect(((await res.json()) as any).reason).toBe("manager-required"); + }); + + it("cannot grant a higher access level than caller's own", async () => { + // Create a second space. Grant Charlie as "manager" there, then have him + // try to mint an "admin" invite — should fail. + const create = await call(app, "POST", "/xrpc/test.inv.community.space.create", ALICE, { + communityDid: COMMUNITY_DID, + key: "side", + }); + const sideUri = ((await create.json()) as any).space.uri; + await call(app, "POST", "/xrpc/test.inv.community.space.grant", ALICE, { + spaceUri: sideUri, + subject: { did: CHARLIE }, + accessLevel: "manager", + }); + + const res = await call(app, "POST", "/xrpc/test.inv.invite.create", CHARLIE, { + spaceUri: sideUri, + accessLevel: "admin", + }); + expect(res.status).toBe(403); + expect(((await res.json()) as any).reason).toBe("cannot-grant-higher-than-self"); + }); + }); +}); diff --git a/tests/spaces-invites.test.ts b/tests/spaces-invites.test.ts index 82f58f7..eb3fc1c 100644 --- a/tests/spaces-invites.test.ts +++ b/tests/spaces-invites.test.ts @@ -84,14 +84,14 @@ describe("invite e2e", () => { }); it("non-owner cannot create an invite", async () => { - const res = await call(app, "POST", "/xrpc/test.spaces.spaceExt.invite.create", BOB, { + const res = await call(app, "POST", "/xrpc/test.spaces.invite.create", BOB, { spaceUri, }); expect(res.status).toBe(403); }); it("owner creates an invite and Bob redeems it to become a member", async () => { - const create = await call(app, "POST", "/xrpc/test.spaces.spaceExt.invite.create", ALICE, { + const create = await call(app, "POST", "/xrpc/test.spaces.invite.create", ALICE, { spaceUri, }); expect(create.status).toBe(200); @@ -101,7 +101,7 @@ describe("invite e2e", () => { expect(invite.spaceUri).toBe(spaceUri); expect(invite.usedCount).toBe(0); - const redeem = await call(app, "POST", "/xrpc/test.spaces.spaceExt.invite.redeem", BOB, { token }); + const redeem = await call(app, "POST", "/xrpc/test.spaces.invite.redeem", BOB, { token }); expect(redeem.status).toBe(200); const body = (await redeem.json()) as any; expect(body.spaceUri).toBe(spaceUri); @@ -116,58 +116,58 @@ describe("invite e2e", () => { }); it("single-use invite rejects second redemption", async () => { - const create = await call(app, "POST", "/xrpc/test.spaces.spaceExt.invite.create", ALICE, { + const create = await call(app, "POST", "/xrpc/test.spaces.invite.create", ALICE, { spaceUri, maxUses: 1, }); const { token } = (await create.json()) as any; - const first = await call(app, "POST", "/xrpc/test.spaces.spaceExt.invite.redeem", BOB, { token }); + const first = await call(app, "POST", "/xrpc/test.spaces.invite.redeem", BOB, { token }); expect(first.status).toBe(200); - const second = await call(app, "POST", "/xrpc/test.spaces.spaceExt.invite.redeem", CHARLIE, { token }); + const second = await call(app, "POST", "/xrpc/test.spaces.invite.redeem", CHARLIE, { token }); expect(second.status).toBe(400); const body = (await second.json()) as any; expect(body.reason).toBe("expired-revoked-or-exhausted"); }); it("expired invite rejects redemption", async () => { - const create = await call(app, "POST", "/xrpc/test.spaces.spaceExt.invite.create", ALICE, { + const create = await call(app, "POST", "/xrpc/test.spaces.invite.create", ALICE, { spaceUri, expiresAt: Date.now() - 1000, }); const { token } = (await create.json()) as any; - const res = await call(app, "POST", "/xrpc/test.spaces.spaceExt.invite.redeem", CHARLIE, { token }); + const res = await call(app, "POST", "/xrpc/test.spaces.invite.redeem", CHARLIE, { token }); expect(res.status).toBe(400); }); it("revoked invite rejects redemption and list filters it by default", async () => { - const create = await call(app, "POST", "/xrpc/test.spaces.spaceExt.invite.create", ALICE, { + const create = await call(app, "POST", "/xrpc/test.spaces.invite.create", ALICE, { spaceUri, }); const { token, invite } = (await create.json()) as any; - const revoke = await call(app, "POST", "/xrpc/test.spaces.spaceExt.invite.revoke", ALICE, { + const revoke = await call(app, "POST", "/xrpc/test.spaces.invite.revoke", ALICE, { spaceUri, tokenHash: invite.tokenHash, }); expect(revoke.status).toBe(200); expect(((await revoke.json()) as any).ok).toBe(true); - const tryRedeem = await call(app, "POST", "/xrpc/test.spaces.spaceExt.invite.redeem", CHARLIE, { token }); + const tryRedeem = await call(app, "POST", "/xrpc/test.spaces.invite.redeem", CHARLIE, { token }); expect(tryRedeem.status).toBe(400); - const listActive = await call(app, "GET", `/xrpc/test.spaces.spaceExt.invite.list?spaceUri=${encodeURIComponent(spaceUri)}`, ALICE); + const listActive = await call(app, "GET", `/xrpc/test.spaces.invite.list?spaceUri=${encodeURIComponent(spaceUri)}`, ALICE); const activeHashes = ((await listActive.json()) as any).invites.map((i: any) => i.tokenHash); expect(activeHashes).not.toContain(invite.tokenHash); - const listAll = await call(app, "GET", `/xrpc/test.spaces.spaceExt.invite.list?spaceUri=${encodeURIComponent(spaceUri)}&includeRevoked=true`, ALICE); + const listAll = await call(app, "GET", `/xrpc/test.spaces.invite.list?spaceUri=${encodeURIComponent(spaceUri)}&includeRevoked=true`, ALICE); const allHashes = ((await listAll.json()) as any).invites.map((i: any) => i.tokenHash); expect(allHashes).toContain(invite.tokenHash); }); it("non-owner cannot list or revoke invites", async () => { - const listRes = await call(app, "GET", `/xrpc/test.spaces.spaceExt.invite.list?spaceUri=${encodeURIComponent(spaceUri)}`, BOB); + const listRes = await call(app, "GET", `/xrpc/test.spaces.invite.list?spaceUri=${encodeURIComponent(spaceUri)}`, BOB); expect(listRes.status).toBe(403); - const revokeRes = await call(app, "POST", "/xrpc/test.spaces.spaceExt.invite.revoke", BOB, { + const revokeRes = await call(app, "POST", "/xrpc/test.spaces.invite.revoke", BOB, { spaceUri, tokenHash: "nonexistent", }); expect(revokeRes.status).toBe(403); @@ -179,7 +179,7 @@ describe("invite e2e", () => { spaceUri, collection: "app.event.message", record: { text: "hello world" }, }); - const create = await call(app, "POST", "/xrpc/test.spaces.spaceExt.invite.create", ALICE, { + const create = await call(app, "POST", "/xrpc/test.spaces.invite.create", ALICE, { spaceUri, kind: "read", }); const { token, invite } = (await create.json()) as any; @@ -193,12 +193,12 @@ describe("invite e2e", () => { expect(body.records.length).toBeGreaterThan(0); // Read token cannot be redeemed for membership. - const redeem = await call(app, "POST", "/xrpc/test.spaces.spaceExt.invite.redeem", CHARLIE, { token }); + const redeem = await call(app, "POST", "/xrpc/test.spaces.invite.redeem", CHARLIE, { token }); expect(redeem.status).toBe(400); }); it("read-join token grants anonymous read AND can be redeemed for membership", async () => { - const create = await call(app, "POST", "/xrpc/test.spaces.spaceExt.invite.create", ALICE, { + const create = await call(app, "POST", "/xrpc/test.spaces.invite.create", ALICE, { spaceUri, kind: "read-join", }); const { token, invite } = (await create.json()) as any; @@ -210,16 +210,16 @@ describe("invite e2e", () => { expect(anon.status).toBe(200); // Same token can also be redeemed by a signed-in user. - const redeem = await call(app, "POST", "/xrpc/test.spaces.spaceExt.invite.redeem", CHARLIE, { token }); + const redeem = await call(app, "POST", "/xrpc/test.spaces.invite.redeem", CHARLIE, { token }); expect(redeem.status).toBe(200); }); it("revoked read-token rejects anonymous read", async () => { - const create = await call(app, "POST", "/xrpc/test.spaces.spaceExt.invite.create", ALICE, { + const create = await call(app, "POST", "/xrpc/test.spaces.invite.create", ALICE, { spaceUri, kind: "read", }); const { token, invite } = (await create.json()) as any; - await call(app, "POST", "/xrpc/test.spaces.spaceExt.invite.revoke", ALICE, { + await call(app, "POST", "/xrpc/test.spaces.invite.revoke", ALICE, { spaceUri, tokenHash: invite.tokenHash, }); const url = `/xrpc/test.spaces.space.listRecords?spaceUri=${encodeURIComponent(spaceUri)}&collection=app.event.message&inviteToken=${token}`; @@ -228,7 +228,7 @@ describe("invite e2e", () => { }); it("read-token does not grant write", async () => { - const create = await call(app, "POST", "/xrpc/test.spaces.spaceExt.invite.create", ALICE, { + const create = await call(app, "POST", "/xrpc/test.spaces.invite.create", ALICE, { spaceUri, kind: "read", }); const { token } = (await create.json()) as any;