diff --git a/lexicons/tools/atmo/space/defs.json b/lexicons/tools/atmo/space/defs.json index b0270fa..1a5aa2b 100644 --- a/lexicons/tools/atmo/space/defs.json +++ b/lexicons/tools/atmo/space/defs.json @@ -57,6 +57,22 @@ "mode": { "type": "string", "knownValues": ["allow", "deny"], "description": "'allow' = default-allow with apps[] as denylist; 'deny' = default-deny with apps[] as allowlist." }, "apps": { "type": "array", "items": { "type": "string" } } } + }, + "inviteView": { + "type": "object", + "required": ["tokenHash", "spaceUri", "perms", "usedCount", "createdBy", "createdAt"], + "properties": { + "tokenHash": { "type": "string" }, + "spaceUri": { "type": "string", "format": "at-uri" }, + "perms": { "type": "string" }, + "expiresAt": { "type": "integer" }, + "maxUses": { "type": "integer" }, + "usedCount": { "type": "integer" }, + "createdBy": { "type": "string", "format": "did" }, + "createdAt": { "type": "integer" }, + "revokedAt": { "type": "integer" }, + "note": { "type": "string" } + } } } } diff --git a/lexicons/tools/atmo/space/invite/create.json b/lexicons/tools/atmo/space/invite/create.json new file mode 100644 index 0000000..7fb8aee --- /dev/null +++ b/lexicons/tools/atmo/space/invite/create.json @@ -0,0 +1,39 @@ +{ + "lexicon": 1, + "id": "tools.atmo.space.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" }, + "perms": { "type": "string", "default": "member" }, + "expiresAt": { "type": "integer", "description": "Unix ms timestamp. Omit for no expiry." }, + "maxUses": { "type": "integer", "minimum": 1, "description": "Omit for unlimited uses." }, + "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/lexicons/tools/atmo/space/invite/list.json b/lexicons/tools/atmo/space/invite/list.json new file mode 100644 index 0000000..217e822 --- /dev/null +++ b/lexicons/tools/atmo/space/invite/list.json @@ -0,0 +1,35 @@ +{ + "lexicon": 1, + "id": "tools.atmo.space.invite.list", + "defs": { + "main": { + "type": "query", + "description": "List invites for a space. Owner only.", + "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.space.defs#inviteView" } + } + } + } + }, + "errors": [ + { "name": "NotFound" }, + { "name": "Forbidden" } + ] + } + } +} diff --git a/lexicons/tools/atmo/space/invite/redeem.json b/lexicons/tools/atmo/space/invite/redeem.json new file mode 100644 index 0000000..7994f68 --- /dev/null +++ b/lexicons/tools/atmo/space/invite/redeem.json @@ -0,0 +1,34 @@ +{ + "lexicon": 1, + "id": "tools.atmo.space.invite.redeem", + "defs": { + "main": { + "type": "procedure", + "description": "Redeem an invite token. The JWT issuer becomes a member of the space with the invite's perms.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["token"], + "properties": { + "token": { "type": "string" } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["spaceUri", "perms"], + "properties": { + "spaceUri": { "type": "string", "format": "at-uri" }, + "perms": { "type": "string" } + } + } + }, + "errors": [ + { "name": "InvalidInvite" } + ] + } + } +} diff --git a/lexicons/tools/atmo/space/invite/revoke.json b/lexicons/tools/atmo/space/invite/revoke.json new file mode 100644 index 0000000..e9d068b --- /dev/null +++ b/lexicons/tools/atmo/space/invite/revoke.json @@ -0,0 +1,35 @@ +{ + "lexicon": 1, + "id": "tools.atmo.space.invite.revoke", + "defs": { + "main": { + "type": "procedure", + "description": "Revoke an invite. Owner only. Invites are identified by their tokenHash (visible in list output).", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["spaceUri", "tokenHash"], + "properties": { + "spaceUri": { "type": "string", "format": "at-uri" }, + "tokenHash": { "type": "string" } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["ok"], + "properties": { + "ok": { "type": "boolean" } + } + } + }, + "errors": [ + { "name": "NotFound" }, + { "name": "Forbidden" } + ] + } + } +} diff --git a/src/core/spaces/adapter.ts b/src/core/spaces/adapter.ts index e3f22b7..3d5f3ee 100644 --- a/src/core/spaces/adapter.ts +++ b/src/core/spaces/adapter.ts @@ -3,6 +3,8 @@ import type { AppPolicy, CollectionCount, CollectionPolicy, + CreateInviteInput, + InviteRow, ListOptions, ListResult, ListSpacesOptions, @@ -54,6 +56,21 @@ function mapMemberRow(row: any): SpaceMemberRow { }; } +function mapInviteRow(row: any): InviteRow { + return { + tokenHash: row.token_hash, + spaceUri: row.space_uri, + perms: row.perms, + expiresAt: row.expires_at == null ? null : toNum(row.expires_at), + maxUses: row.max_uses == null ? null : Number(row.max_uses), + usedCount: Number(row.used_count), + createdBy: row.created_by, + createdAt: toNum(row.created_at), + revokedAt: row.revoked_at == null ? null : toNum(row.revoked_at), + note: row.note ?? null, + }; +} + function mapRecordRow(row: any): StoredRecord { return { spaceUri: row.space_uri, @@ -195,6 +212,81 @@ export class HostedAdapter implements StorageAdapter { return results.map(mapMemberRow); } + async createInvite(input: CreateInviteInput): Promise { + const now = Date.now(); + await this.db + .prepare( + `INSERT INTO spaces_invites (token_hash, space_uri, perms, expires_at, max_uses, used_count, created_by, created_at, note) + VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)` + ) + .bind( + input.tokenHash, + input.spaceUri, + input.perms, + input.expiresAt, + input.maxUses, + input.createdBy, + now, + input.note + ) + .run(); + return { + tokenHash: input.tokenHash, + spaceUri: input.spaceUri, + perms: input.perms, + expiresAt: input.expiresAt, + maxUses: input.maxUses, + usedCount: 0, + createdBy: input.createdBy, + createdAt: now, + revokedAt: null, + note: input.note, + }; + } + + async listInvites( + spaceUri: string, + options: { includeRevoked?: boolean } = {} + ): Promise { + const sql = options.includeRevoked + ? `SELECT * FROM spaces_invites WHERE space_uri = ? ORDER BY created_at DESC` + : `SELECT * FROM spaces_invites WHERE space_uri = ? AND revoked_at IS NULL ORDER BY created_at DESC`; + const { results } = await this.db.prepare(sql).bind(spaceUri).all(); + return results.map(mapInviteRow); + } + + async revokeInvite(tokenHash: string): Promise { + const res = await this.db + .prepare(`UPDATE spaces_invites SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL`) + .bind(Date.now(), tokenHash) + .run(); + const changes = (res as any)?.changes ?? (res as any)?.meta?.changes ?? 0; + return Number(changes) > 0; + } + + async redeemInvite(tokenHash: string, now: number): Promise { + // Atomic: increment used_count only if the invite is usable right now. + const res = await this.db + .prepare( + `UPDATE spaces_invites + SET used_count = used_count + 1 + WHERE token_hash = ? + AND revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > ?) + AND (max_uses IS NULL OR used_count < max_uses)` + ) + .bind(tokenHash, now) + .run(); + const changes = (res as any)?.changes ?? (res as any)?.meta?.changes ?? 0; + if (Number(changes) === 0) return null; + + const row = await this.db + .prepare(`SELECT * FROM spaces_invites WHERE token_hash = ?`) + .bind(tokenHash) + .first(); + return row ? mapInviteRow(row) : null; + } + async putRecord(record: StoredRecord): Promise { await this.db .prepare( diff --git a/src/core/spaces/invite-token.ts b/src/core/spaces/invite-token.ts new file mode 100644 index 0000000..1fc1779 --- /dev/null +++ b/src/core/spaces/invite-token.ts @@ -0,0 +1,35 @@ +const B64U_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +function bytesToB64Url(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i += 3) { + const b0 = bytes[i]; + const b1 = bytes[i + 1] ?? 0; + const b2 = bytes[i + 2] ?? 0; + out += B64U_ALPHABET[b0 >> 2]; + out += B64U_ALPHABET[((b0 & 3) << 4) | (b1 >> 4)]; + if (i + 1 < bytes.length) out += B64U_ALPHABET[((b1 & 15) << 2) | (b2 >> 6)]; + if (i + 2 < bytes.length) out += B64U_ALPHABET[b2 & 63]; + } + return out; +} + +function bytesToHex(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, "0"); + return out; +} + +/** Generate a fresh invite token (cryptographically random, 32 bytes base64url-encoded). */ +export function generateInviteToken(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return bytesToB64Url(bytes); +} + +/** SHA-256 hash of a token, hex-encoded. Used as the PK in storage so raw tokens are never persisted. */ +export async function hashInviteToken(token: string): Promise { + const encoded = new TextEncoder().encode(token); + const digest = await crypto.subtle.digest("SHA-256", encoded); + return bytesToHex(new Uint8Array(digest)); +} diff --git a/src/core/spaces/router.ts b/src/core/spaces/router.ts index 746d833..fd50249 100644 --- a/src/core/spaces/router.ts +++ b/src/core/spaces/router.ts @@ -5,7 +5,8 @@ import { checkAccess, resolveCollectionPolicy } from "./acl"; import type { ServiceAuth } from "./auth"; import { createServiceAuthMiddleware } from "./auth"; import { nextTid } from "./tid"; -import type { CollectionPolicy, SpaceRow, SpacesConfig, StorageAdapter } from "./types"; +import { generateInviteToken, hashInviteToken } from "./invite-token"; +import type { CollectionPolicy, InviteRow, SpaceRow, SpacesConfig, StorageAdapter } from "./types"; import type { Did } from "@atcute/lexicons"; const SPACE = "tools.atmo.space"; @@ -186,6 +187,81 @@ export function registerSpacesRoutes( return c.json({ space: publicSpaceView(space, true) }); }); + // Invites + app.post(`/xrpc/${SPACE}.invite.create`, auth, async (c) => { + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as + | { spaceUri?: string; perms?: string; expiresAt?: number; maxUses?: number; note?: string } + | null; + if (!body?.spaceUri) { + return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); + } + const space = await adapter.getSpace(body.spaceUri); + if (!space) return c.json({ error: "NotFound" }, 404); + if (space.ownerDid !== sa.issuer) { + return c.json({ error: "Forbidden", reason: "not-owner" }, 403); + } + + const token = generateInviteToken(); + const tokenHash = await hashInviteToken(token); + const invite = await adapter.createInvite({ + spaceUri: body.spaceUri, + tokenHash, + perms: body.perms ?? "member", + 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}.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.perms, invite.createdBy); + return c.json({ spaceUri: invite.spaceUri, perms: invite.perms }); + }); + + app.get(`/xrpc/${SPACE}.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}.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 }); + }); + app.post(`/xrpc/${SPACE}.admin.addMember`, auth, async (c) => { const sa = getAuth(c); const body = (await c.req.json().catch(() => null)) as @@ -218,6 +294,21 @@ function getAuth(c: Parameters[0]): ServiceAuth { return auth; } +function publicInviteView(invite: InviteRow) { + return { + tokenHash: invite.tokenHash, + spaceUri: invite.spaceUri, + perms: invite.perms, + 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 { uri: space.uri, diff --git a/src/core/spaces/schema.ts b/src/core/spaces/schema.ts index 7bbbb92..6899623 100644 --- a/src/core/spaces/schema.ts +++ b/src/core/spaces/schema.ts @@ -42,6 +42,20 @@ export function buildSpacesSchema(db: Database): string[] { PRIMARY KEY (space_uri, did) )`, `CREATE INDEX IF NOT EXISTS idx_spaces_members_did ON spaces_members(did)`, + + `CREATE TABLE IF NOT EXISTS spaces_invites ( + token_hash TEXT PRIMARY KEY, + space_uri TEXT NOT NULL, + perms TEXT NOT NULL, + expires_at ${dialect.bigintType}, + max_uses INTEGER, + used_count INTEGER NOT NULL DEFAULT 0, + created_by TEXT NOT NULL, + created_at ${dialect.bigintType} NOT NULL, + revoked_at ${dialect.bigintType}, + note TEXT + )`, + `CREATE INDEX IF NOT EXISTS idx_spaces_invites_space ON spaces_invites(space_uri, created_at DESC)`, ]; } diff --git a/src/core/spaces/types.ts b/src/core/spaces/types.ts index da371af..d98bc34 100644 --- a/src/core/spaces/types.ts +++ b/src/core/spaces/types.ts @@ -87,6 +87,34 @@ export interface CollectionCount { count: number; } +export interface InviteRow { + tokenHash: string; + spaceUri: string; + perms: string; + expiresAt: number | null; + maxUses: number | null; + usedCount: number; + createdBy: string; + createdAt: number; + revokedAt: number | null; + note: string | null; +} + +export interface CreateInviteInput { + spaceUri: string; + tokenHash: string; + perms: string; + expiresAt: number | null; + maxUses: number | null; + createdBy: string; + note: string | null; +} + +export interface RedeemInviteResult { + spaceUri: string; + perms: string; +} + export interface StorageAdapter { // Space lifecycle createSpace(space: Omit): Promise; @@ -102,6 +130,13 @@ export interface StorageAdapter { getMember(spaceUri: string, did: string): Promise; listMembers(spaceUri: string): Promise; + // Invites + createInvite(input: CreateInviteInput): Promise; + listInvites(spaceUri: string, options?: { includeRevoked?: boolean }): Promise; + revokeInvite(tokenHash: string): Promise; + /** Atomically mark an invite as used (one atomic UPDATE). Returns the row if usable, null otherwise. */ + redeemInvite(tokenHash: string, now: number): Promise; + // Records putRecord(record: StoredRecord): Promise; getRecord(spaceUri: string, collection: string, authorDid: string, rkey: string): Promise; diff --git a/src/index.ts b/src/index.ts index 5eb1122..f6405ad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,10 @@ export type { ListResult, ListSpacesOptions, CollectionCount, + InviteRow, + CreateInviteInput, + RedeemInviteResult, } from "./core/spaces/types"; export { HostedAdapter } from "./core/spaces/adapter"; export { nextTid } from "./core/spaces/tid"; +export { generateInviteToken, hashInviteToken } from "./core/spaces/invite-token"; diff --git a/src/lexicons.ts b/src/lexicons.ts index 39ada9a..7af9431 100644 --- a/src/lexicons.ts +++ b/src/lexicons.ts @@ -9,3 +9,7 @@ export * as ToolsAtmoSpaceGetRecord from "./lexicon-types/types/tools/atmo/space export * as ToolsAtmoSpacePutRecord from "./lexicon-types/types/tools/atmo/space/putRecord.js"; export * as ToolsAtmoSpaceAdminCreateSpace from "./lexicon-types/types/tools/atmo/space/admin/createSpace.js"; export * as ToolsAtmoSpaceAdminAddMember from "./lexicon-types/types/tools/atmo/space/admin/addMember.js"; +export * as ToolsAtmoSpaceInviteCreate from "./lexicon-types/types/tools/atmo/space/invite/create.js"; +export * as ToolsAtmoSpaceInviteRedeem from "./lexicon-types/types/tools/atmo/space/invite/redeem.js"; +export * as ToolsAtmoSpaceInviteList from "./lexicon-types/types/tools/atmo/space/invite/list.js"; +export * as ToolsAtmoSpaceInviteRevoke from "./lexicon-types/types/tools/atmo/space/invite/revoke.js"; diff --git a/tests/spaces-invites.test.ts b/tests/spaces-invites.test.ts new file mode 100644 index 0000000..d95138b --- /dev/null +++ b/tests/spaces-invites.test.ts @@ -0,0 +1,178 @@ +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"; +import { generateInviteToken, hashInviteToken } from "../src/core/spaces/invite-token"; + +const ALICE = "did:plc:alice"; +const BOB = "did:plc:bob"; +const CHARLIE = "did:plc:charlie"; + +const CONFIG: ContrailConfig = { + namespace: "test.spaces", + collections: {}, + spaces: { + type: "tools.atmo.event.space", + serviceDid: "did:web:test.example#svc", + defaultPolicies: { + "app.event.message": { read: "member", write: "member" }, + }, + }, +}; + +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(); + }; +} + +function call(app: Hono, method: string, path: string, did: string, body?: any) { + return app.fetch( + new Request(`http://localhost${path}`, { + method, + headers: { + "X-Test-Did": did, + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + ); +} + +describe("invite token helpers", () => { + it("generates random tokens of consistent length", () => { + const a = generateInviteToken(); + const b = generateInviteToken(); + expect(a).not.toBe(b); + expect(a.length).toBeGreaterThanOrEqual(40); + expect(/^[A-Za-z0-9_-]+$/.test(a)).toBe(true); + }); + + it("hashes deterministically", async () => { + const token = generateInviteToken(); + const h1 = await hashInviteToken(token); + const h2 = await hashInviteToken(token); + expect(h1).toBe(h2); + expect(h1).toMatch(/^[a-f0-9]{64}$/); + }); +}); + +describe("invite e2e", () => { + let app: Hono; + let spaceUri: string; + + beforeAll(async () => { + const db = createSqliteDatabase(":memory:"); + const resolved = resolveConfig(CONFIG); + await initSchema(db, resolved); + app = createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); + + const res = await call(app, "POST", "/xrpc/tools.atmo.space.admin.createSpace", ALICE, { + key: "party", + }); + spaceUri = ((await res.json()) as any).space.uri; + }); + + it("non-owner cannot create an invite", async () => { + const res = await call(app, "POST", "/xrpc/tools.atmo.space.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/tools.atmo.space.invite.create", ALICE, { + spaceUri, + perms: "attendee", + }); + expect(create.status).toBe(200); + const { token, invite } = (await create.json()) as any; + expect(token).toBeTruthy(); + expect(invite.tokenHash).toBeTruthy(); + expect(invite.spaceUri).toBe(spaceUri); + expect(invite.usedCount).toBe(0); + + const redeem = await call(app, "POST", "/xrpc/tools.atmo.space.invite.redeem", BOB, { token }); + expect(redeem.status).toBe(200); + const body = (await redeem.json()) as any; + expect(body.spaceUri).toBe(spaceUri); + expect(body.perms).toBe("attendee"); + + // Bob is now a member — can write a message + const put = await call(app, "POST", "/xrpc/tools.atmo.space.putRecord", BOB, { + spaceUri, + collection: "app.event.message", + record: { text: "yay" }, + }); + expect(put.status).toBe(200); + }); + + it("single-use invite rejects second redemption", async () => { + const create = await call(app, "POST", "/xrpc/tools.atmo.space.invite.create", ALICE, { + spaceUri, maxUses: 1, + }); + const { token } = (await create.json()) as any; + + const first = await call(app, "POST", "/xrpc/tools.atmo.space.invite.redeem", BOB, { token }); + expect(first.status).toBe(200); + + const second = await call(app, "POST", "/xrpc/tools.atmo.space.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/tools.atmo.space.invite.create", ALICE, { + spaceUri, expiresAt: Date.now() - 1000, + }); + const { token } = (await create.json()) as any; + const res = await call(app, "POST", "/xrpc/tools.atmo.space.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/tools.atmo.space.invite.create", ALICE, { + spaceUri, + }); + const { token, invite } = (await create.json()) as any; + + const revoke = await call(app, "POST", "/xrpc/tools.atmo.space.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/tools.atmo.space.invite.redeem", CHARLIE, { token }); + expect(tryRedeem.status).toBe(400); + + const listActive = await call(app, "GET", `/xrpc/tools.atmo.space.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/tools.atmo.space.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/tools.atmo.space.invite.list?spaceUri=${encodeURIComponent(spaceUri)}`, BOB); + expect(listRes.status).toBe(403); + + const revokeRes = await call(app, "POST", "/xrpc/tools.atmo.space.invite.revoke", BOB, { + spaceUri, tokenHash: "nonexistent", + }); + expect(revokeRes.status).toBe(403); + }); +});