[READ-ONLY] Mirror of https://github.com/flo-bit/contrail. atproto backend in a bottle flo-bit.dev/contrail/
0

Configure Feed

Select the types of activity you want to include in your feed.

imrpove invites

Florian (Apr 15, 2026, 12:02 AM +0200) 341ef75c bca07080

+299 -55
+2 -1
.claude/settings.local.json
··· 9 9 "Bash(npx vitest:*)", 10 10 "Bash(npm test:*)", 11 11 "Bash(npx lex-cli:*)", 12 - "Bash(pnpm vitest:*)" 12 + "Bash(pnpm vitest:*)", 13 + "WebFetch(domain:flo-bit.dev)" 13 14 ] 14 15 } 15 16 }
+2 -1
spaces-lexicon-templates/defs.json
··· 51 51 }, 52 52 "inviteView": { 53 53 "type": "object", 54 - "required": ["tokenHash", "spaceUri", "perms", "usedCount", "createdBy", "createdAt"], 54 + "required": ["tokenHash", "spaceUri", "kind", "perms", "usedCount", "createdBy", "createdAt"], 55 55 "properties": { 56 56 "tokenHash": { "type": "string" }, 57 57 "spaceUri": { "type": "string", "format": "at-uri" }, 58 + "kind": { "type": "string", "knownValues": ["join", "read", "read-join"] }, 58 59 "perms": { "type": "string", "knownValues": ["read", "write"] }, 59 60 "expiresAt": { "type": "integer" }, 60 61 "maxUses": { "type": "integer" },
+5 -4
spaces-lexicon-templates/getRecord.json
··· 9 9 "type": "params", 10 10 "required": ["spaceUri", "collection", "author", "rkey"], 11 11 "properties": { 12 - "spaceUri": { "type": "string", "format": "at-uri" }, 13 - "collection": { "type": "string", "format": "nsid" }, 14 - "author": { "type": "string", "format": "did" }, 15 - "rkey": { "type": "string" } 12 + "spaceUri": { "type": "string", "format": "at-uri" }, 13 + "collection": { "type": "string", "format": "nsid" }, 14 + "author": { "type": "string", "format": "did" }, 15 + "rkey": { "type": "string" }, 16 + "inviteToken": { "type": "string", "description": "Read-grant invite token. When supplied, replaces JWT auth for this read." } 16 17 } 17 18 }, 18 19 "output": {
+3 -2
spaces-lexicon-templates/getSpace.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "query", 7 - "description": "Get metadata for a single space. Caller must be a member or the owner.", 7 + "description": "Get metadata for a single space. Caller must be a member, the owner, or hold a read-grant invite token.", 8 8 "parameters": { 9 9 "type": "params", 10 10 "required": ["uri"], 11 11 "properties": { 12 - "uri": { "type": "string", "format": "at-uri" } 12 + "uri": { "type": "string", "format": "at-uri" }, 13 + "inviteToken": { "type": "string", "description": "Read-grant invite token. When supplied, replaces JWT auth for this read." } 13 14 } 14 15 }, 15 16 "output": {
+6 -5
spaces-lexicon-templates/listRecords.json
··· 9 9 "type": "params", 10 10 "required": ["spaceUri", "collection"], 11 11 "properties": { 12 - "spaceUri": { "type": "string", "format": "at-uri" }, 13 - "collection": { "type": "string", "format": "nsid" }, 14 - "byUser": { "type": "string", "format": "did", "description": "Only return records authored by this DID." }, 15 - "cursor": { "type": "string" }, 16 - "limit": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 } 12 + "spaceUri": { "type": "string", "format": "at-uri" }, 13 + "collection": { "type": "string", "format": "nsid" }, 14 + "byUser": { "type": "string", "format": "did", "description": "Only return records authored by this DID." }, 15 + "cursor": { "type": "string" }, 16 + "limit": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 }, 17 + "inviteToken": { "type": "string", "description": "Read-grant invite token. When supplied, replaces JWT auth for this read." } 17 18 } 18 19 }, 19 20 "output": {
+10 -2
src/generate.ts
··· 547 547 spaceUri: { 548 548 type: "string", 549 549 format: "at-uri", 550 - description: "If set, query records inside this permissioned space (requires service-auth JWT).", 550 + description: "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token).", 551 551 }, 552 552 byUser: { 553 553 type: "string", 554 554 format: "did", 555 555 description: "Only used with spaceUri — filter to records authored by this DID.", 556 + }, 557 + inviteToken: { 558 + type: "string", 559 + description: "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied.", 556 560 }, 557 561 } 558 562 : {}), ··· 643 647 spaceUri: { 644 648 type: "string", 645 649 format: "at-uri", 646 - description: "If set, fetch from this permissioned space (requires service-auth JWT).", 650 + description: "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token).", 651 + }, 652 + inviteToken: { 653 + type: "string", 654 + description: "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied.", 647 655 }, 648 656 } 649 657 : {}),
+71
tests/spaces-invites.test.ts
··· 174 174 }); 175 175 expect(revokeRes.status).toBe(403); 176 176 }); 177 + 178 + it("read-token grants anonymous read but cannot be redeemed", async () => { 179 + // Owner writes a message so there's something to read. 180 + await call(app, "POST", "/xrpc/test.spaces.space.putRecord", ALICE, { 181 + spaceUri, collection: "app.event.message", record: { text: "hello world" }, 182 + }); 183 + 184 + const create = await call(app, "POST", "/xrpc/test.spaces.space.invite.create", ALICE, { 185 + spaceUri, kind: "read", 186 + }); 187 + const { token, invite } = (await create.json()) as any; 188 + expect(invite.kind).toBe("read"); 189 + 190 + // Anonymous request (no X-Test-Did header) reads via ?inviteToken= 191 + const url = `/xrpc/test.spaces.space.listRecords?spaceUri=${encodeURIComponent(spaceUri)}&collection=app.event.message&inviteToken=${token}`; 192 + const anon = await app.fetch(new Request(`http://localhost${url}`)); 193 + expect(anon.status).toBe(200); 194 + const body = (await anon.json()) as any; 195 + expect(body.records.length).toBeGreaterThan(0); 196 + 197 + // Read token cannot be redeemed for membership. 198 + const redeem = await call(app, "POST", "/xrpc/test.spaces.space.invite.redeem", CHARLIE, { token }); 199 + expect(redeem.status).toBe(400); 200 + }); 201 + 202 + it("read-join token grants anonymous read AND can be redeemed for membership", async () => { 203 + const create = await call(app, "POST", "/xrpc/test.spaces.space.invite.create", ALICE, { 204 + spaceUri, kind: "read-join", perms: "write", 205 + }); 206 + const { token, invite } = (await create.json()) as any; 207 + expect(invite.kind).toBe("read-join"); 208 + 209 + // Anonymous read works. 210 + const url = `/xrpc/test.spaces.space.listRecords?spaceUri=${encodeURIComponent(spaceUri)}&collection=app.event.message&inviteToken=${token}`; 211 + const anon = await app.fetch(new Request(`http://localhost${url}`)); 212 + expect(anon.status).toBe(200); 213 + 214 + // Same token can also be redeemed by a signed-in user. 215 + const redeem = await call(app, "POST", "/xrpc/test.spaces.space.invite.redeem", CHARLIE, { token }); 216 + expect(redeem.status).toBe(200); 217 + }); 218 + 219 + it("revoked read-token rejects anonymous read", async () => { 220 + const create = await call(app, "POST", "/xrpc/test.spaces.space.invite.create", ALICE, { 221 + spaceUri, kind: "read", 222 + }); 223 + const { token, invite } = (await create.json()) as any; 224 + await call(app, "POST", "/xrpc/test.spaces.space.invite.revoke", ALICE, { 225 + spaceUri, tokenHash: invite.tokenHash, 226 + }); 227 + const url = `/xrpc/test.spaces.space.listRecords?spaceUri=${encodeURIComponent(spaceUri)}&collection=app.event.message&inviteToken=${token}`; 228 + const res = await app.fetch(new Request(`http://localhost${url}`)); 229 + expect(res.status).toBe(403); 230 + }); 231 + 232 + it("read-token does not grant write", async () => { 233 + const create = await call(app, "POST", "/xrpc/test.spaces.space.invite.create", ALICE, { 234 + spaceUri, kind: "read", 235 + }); 236 + const { token } = (await create.json()) as any; 237 + // Anonymous put with the token in the query — write routes don't honor read-tokens. 238 + const url = `/xrpc/test.spaces.space.putRecord?inviteToken=${token}`; 239 + const res = await app.fetch( 240 + new Request(`http://localhost${url}`, { 241 + method: "POST", 242 + headers: { "Content-Type": "application/json" }, 243 + body: JSON.stringify({ spaceUri, collection: "app.event.message", record: { text: "nope" } }), 244 + }) 245 + ); 246 + expect(res.status).toBe(401); 247 + }); 177 248 });
+2 -1
spaces-lexicon-templates/invite/create.json
··· 12 12 "required": ["spaceUri"], 13 13 "properties": { 14 14 "spaceUri": { "type": "string", "format": "at-uri" }, 15 + "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." }, 15 16 "perms": { "type": "string", "knownValues": ["read", "write"], "default": "write" }, 16 17 "expiresAt": { "type": "integer", "description": "Unix ms timestamp. Omit for no expiry." }, 17 - "maxUses": { "type": "integer", "minimum": 1, "description": "Omit for unlimited uses." }, 18 + "maxUses": { "type": "integer", "minimum": 1, "description": "Caps join redemptions only — read-token reads are unlimited. Omit for unlimited joins." }, 18 19 "note": { "type": "string", "maxLength": 500 } 19 20 } 20 21 }
+1
src/core/db/schema.ts
··· 247 247 const MIGRATIONS = [ 248 248 "ALTER TABLE backfills ADD COLUMN retries INTEGER NOT NULL DEFAULT 0", 249 249 "ALTER TABLE backfills ADD COLUMN last_error TEXT", 250 + "ALTER TABLE spaces_invites ADD COLUMN kind TEXT NOT NULL DEFAULT 'join'", 250 251 ]; 251 252 252 253 async function runMigrations(db: Database): Promise<void> {
+31 -3
src/core/router/collection.ts
··· 16 16 import { resolveActor } from "../identity"; 17 17 import type { FormattedRecord } from "./helpers"; 18 18 import { formatRecord, parseIntParam, fieldToParam } from "./helpers"; 19 - import { verifyServiceAuthRequest } from "../spaces/auth"; 19 + import { verifyServiceAuthRequest, extractInviteToken, checkInviteReadGrant } from "../spaces/auth"; 20 20 import { checkAccess } from "../spaces/acl"; 21 + import { hashInviteToken } from "../spaces/invite-token"; 21 22 import type { SpacesContext } from "."; 22 23 import type { Nsid } from "@atcute/lexicons"; 23 24 ··· 206 207 c: Context, 207 208 spaceUri: string, 208 209 op: "read" 209 - ): Promise<Response | { callerDid: string; clientId?: string }> { 210 + ): Promise<Response | { callerDid?: string; clientId?: string; viaInviteToken?: boolean }> { 210 211 if (!spacesCtx) { 211 212 return c.json( 212 213 { error: "InvalidRequest", message: "spaces not configured on this service" }, 213 214 501 214 215 ); 215 216 } 217 + 218 + // Read-token path: anonymous bearer access via `?inviteToken=...` (or 219 + // `Authorization: Bearer atmo-invite:<token>`). Token must exist, be 220 + // unexpired/unrevoked, scoped to this space, and have a kind that grants 221 + // read (`read` or `read-join`). Token kind cannot grant write — caller must 222 + // separately redeem to become a member for any non-read op. 223 + if (op === "read") { 224 + const rawToken = extractInviteToken(c.req.raw); 225 + if (rawToken) { 226 + const ok = await checkInviteReadGrant( 227 + spacesCtx.adapter, 228 + rawToken, 229 + spaceUri, 230 + hashInviteToken 231 + ); 232 + if (ok) { 233 + const space = await spacesCtx.adapter.getSpace(spaceUri); 234 + if (!space) return c.json({ error: "NotFound" }, 404); 235 + return { viaInviteToken: true }; 236 + } 237 + return c.json( 238 + { error: "Forbidden", reason: "invalid-invite-token" }, 239 + 403 240 + ); 241 + } 242 + } 243 + 216 244 const nsid = new URL(c.req.url).pathname.match(/\/xrpc\/([^?]+)/)?.[1] as Nsid | null; 217 245 const auth = await verifyServiceAuthRequest(spacesCtx.verifier, c.req.raw, nsid); 218 246 if (!auth) { 219 247 return c.json( 220 - { error: "AuthRequired", message: "spaceUri requires a valid service-auth JWT" }, 248 + { error: "AuthRequired", message: "spaceUri requires a valid service-auth JWT or read-grant invite token" }, 221 249 401 222 250 ); 223 251 }
+17 -3
src/core/spaces/adapter.ts
··· 12 12 AppPolicy, 13 13 CollectionCount, 14 14 CreateInviteInput, 15 + InviteKind, 15 16 InviteRow, 16 17 ListOptions, 17 18 ListResult, ··· 68 69 return { 69 70 tokenHash: row.token_hash, 70 71 spaceUri: row.space_uri, 72 + kind: (row.kind ?? "join") as InviteKind, 71 73 perms: row.perms, 72 74 expiresAt: row.expires_at == null ? null : toNum(row.expires_at), 73 75 maxUses: row.max_uses == null ? null : Number(row.max_uses), ··· 238 240 const now = Date.now(); 239 241 await this.db 240 242 .prepare( 241 - `INSERT INTO spaces_invites (token_hash, space_uri, perms, expires_at, max_uses, used_count, created_by, created_at, note) 242 - VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)` 243 + `INSERT INTO spaces_invites (token_hash, space_uri, kind, perms, expires_at, max_uses, used_count, created_by, created_at, note) 244 + VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)` 243 245 ) 244 246 .bind( 245 247 input.tokenHash, 246 248 input.spaceUri, 249 + input.kind, 247 250 input.perms, 248 251 input.expiresAt, 249 252 input.maxUses, ··· 255 258 return { 256 259 tokenHash: input.tokenHash, 257 260 spaceUri: input.spaceUri, 261 + kind: input.kind, 258 262 perms: input.perms, 259 263 expiresAt: input.expiresAt, 260 264 maxUses: input.maxUses, ··· 264 268 revokedAt: null, 265 269 note: input.note, 266 270 }; 271 + } 272 + 273 + async getInvite(tokenHash: string): Promise<InviteRow | null> { 274 + const row = await this.db 275 + .prepare(`SELECT * FROM spaces_invites WHERE token_hash = ?`) 276 + .bind(tokenHash) 277 + .first<any>(); 278 + return row ? mapInviteRow(row) : null; 267 279 } 268 280 269 281 async listInvites( ··· 287 299 } 288 300 289 301 async redeemInvite(tokenHash: string, now: number): Promise<InviteRow | null> { 290 - // Atomic: increment used_count only if the invite is usable right now. 302 + // Atomic: increment used_count only if the invite is usable right now AND 303 + // its kind allows redemption (read-only tokens cannot be consumed for membership). 291 304 const res = await this.db 292 305 .prepare( 293 306 `UPDATE spaces_invites 294 307 SET used_count = used_count + 1 295 308 WHERE token_hash = ? 309 + AND kind IN ('join', 'read-join') 296 310 AND revoked_at IS NULL 297 311 AND (expires_at IS NULL OR expires_at > ?) 298 312 AND (max_uses IS NULL OR used_count < max_uses)`
+33
src/core/spaces/auth.ts
··· 102 102 lxm: result.value.lxm, 103 103 }; 104 104 } 105 + 106 + /** Pull a read-grant invite token off the request — query string `?inviteToken=` 107 + * or `Authorization: Bearer atmo-invite:<token>`. Returns the raw token (not 108 + * hashed) or null. Routes hash + look up via the adapter. */ 109 + export function extractInviteToken(request: Request): string | null { 110 + const url = new URL(request.url); 111 + const q = url.searchParams.get("inviteToken"); 112 + if (q) return q.trim(); 113 + const header = request.headers.get("Authorization"); 114 + if (header?.startsWith("Bearer atmo-invite:")) { 115 + return header.slice("Bearer atmo-invite:".length).trim(); 116 + } 117 + return null; 118 + } 119 + 120 + /** Validate a read-grant invite token against a target spaceUri. Returns true 121 + * if the token exists, scopes to this space, has a kind that grants read 122 + * (`read` or `read-join`), and is not expired/revoked. */ 123 + export async function checkInviteReadGrant( 124 + adapter: { getInvite(tokenHash: string): Promise<{ spaceUri: string; kind: string; revokedAt: number | null; expiresAt: number | null } | null> }, 125 + rawToken: string, 126 + spaceUri: string, 127 + hashFn: (token: string) => Promise<string> 128 + ): Promise<boolean> { 129 + const tokenHash = await hashFn(rawToken); 130 + const invite = await adapter.getInvite(tokenHash); 131 + if (!invite) return false; 132 + if (invite.spaceUri !== spaceUri) return false; 133 + if (invite.kind !== "read" && invite.kind !== "read-join") return false; 134 + if (invite.revokedAt != null) return false; 135 + if (invite.expiresAt != null && invite.expiresAt <= Date.now()) return false; 136 + return true; 137 + }
+103 -32
src/core/spaces/router.ts
··· 1 - import type { Hono, MiddlewareHandler } from "hono"; 1 + import type { Context, Hono, MiddlewareHandler } from "hono"; 2 2 import type { ContrailConfig, Database } from "../types"; 3 3 import { HostedAdapter } from "./adapter"; 4 4 import { checkAccess } from "./acl"; 5 5 import type { ServiceAuth } from "./auth"; 6 - import { buildVerifier, createServiceAuthMiddleware } from "./auth"; 6 + import { 7 + buildVerifier, 8 + checkInviteReadGrant, 9 + createServiceAuthMiddleware, 10 + extractInviteToken, 11 + verifyServiceAuthRequest, 12 + } from "./auth"; 7 13 import { nextTid } from "./tid"; 8 14 import { generateInviteToken, hashInviteToken } from "./invite-token"; 9 - import type { InviteRow, MemberPerm, SpaceRow, SpacesConfig, StorageAdapter } from "./types"; 15 + import type { InviteKind, InviteRow, MemberPerm, SpaceRow, SpacesConfig, StorageAdapter } from "./types"; 10 16 import type { Did } from "@atcute/lexicons"; 11 17 12 18 export interface SpacesRoutesOptions { ··· 27 33 if (!spacesConfig) return; 28 34 29 35 const adapter = options.adapter ?? ctx?.adapter ?? new HostedAdapter(db, config); 36 + const verifier = ctx?.verifier ?? buildVerifier(spacesConfig); 30 37 const auth = 31 - options.authMiddleware ?? 32 - (ctx ? createServiceAuthMiddleware(ctx.verifier) : buildAuthMiddleware(spacesConfig)); 38 + options.authMiddleware ?? createServiceAuthMiddleware(verifier); 39 + 40 + /** Read-route auth: skip the JWT middleware when an `?inviteToken=` is 41 + * present so anonymous bearer reads don't 401 before the route handler can 42 + * validate the token. The route handler is responsible for actually checking 43 + * the token (via `authorizeRead`). */ 44 + const readAuth: MiddlewareHandler = async (c, next) => { 45 + if (extractInviteToken(c.req.raw)) { 46 + await next(); 47 + return; 48 + } 49 + return auth(c, next); 50 + }; 51 + 52 + /** Authorize a read request: either a valid service-auth JWT (which also 53 + * identifies the caller for member checks downstream) or a valid read-grant 54 + * invite token bearer (`?inviteToken=...` or 55 + * `Authorization: Bearer atmo-invite:<token>`). */ 56 + async function authorizeRead( 57 + c: Context, 58 + spaceUri: string 59 + ): Promise<{ via: "token" } | { via: "jwt"; sa: ServiceAuth } | Response> { 60 + const rawToken = extractInviteToken(c.req.raw); 61 + if (rawToken) { 62 + const ok = await checkInviteReadGrant(adapter, rawToken, spaceUri, hashInviteToken); 63 + if (!ok) return c.json({ error: "Forbidden", reason: "invalid-invite-token" }, 403); 64 + return { via: "token" }; 65 + } 66 + const sa = c.get("serviceAuth") as ServiceAuth | undefined; 67 + if (sa) return { via: "jwt", sa }; 68 + return c.json( 69 + { error: "AuthRequired", message: "JWT or read-grant invite token required" }, 70 + 401 71 + ); 72 + } 33 73 34 74 /** Space endpoints are emitted per-deployment under the configured namespace; 35 75 * the deployment owns and publishes its own lexicons. The library ships ··· 72 112 return c.json({ members }); 73 113 }); 74 114 75 - app.get(`/xrpc/${SPACE}.getSpace`, auth, async (c) => { 115 + app.get(`/xrpc/${SPACE}.getSpace`, readAuth, async (c) => { 76 116 const uri = c.req.query("uri"); 77 117 if (!uri) return c.json({ error: "InvalidRequest", message: "uri required" }, 400); 78 118 const space = await adapter.getSpace(uri); 79 119 if (!space) return c.json({ error: "NotFound" }, 404); 80 120 81 - const sa = getAuth(c); 121 + const authz = await authorizeRead(c, uri); 122 + if (authz instanceof Response) return authz; 123 + 124 + if (authz.via === "token") { 125 + // Anonymous read-token bearer — show non-owner space view. 126 + return c.json({ space: publicSpaceView(space, false) }); 127 + } 128 + 129 + const sa = authz.sa; 82 130 const isOwner = sa.issuer === space.ownerDid; 83 131 const member = isOwner ? null : await adapter.getMember(uri, sa.issuer); 84 132 if (!isOwner && !member) { ··· 87 135 return c.json({ space: publicSpaceView(space, isOwner) }); 88 136 }); 89 137 90 - app.get(`/xrpc/${SPACE}.listRecords`, auth, async (c) => { 91 - const sa = getAuth(c); 138 + app.get(`/xrpc/${SPACE}.listRecords`, readAuth, async (c) => { 92 139 const spaceUri = c.req.query("spaceUri"); 93 140 const collection = c.req.query("collection"); 94 141 if (!spaceUri || !collection) { ··· 97 144 const space = await adapter.getSpace(spaceUri); 98 145 if (!space) return c.json({ error: "NotFound" }, 404); 99 146 100 - const member = await adapter.getMember(spaceUri, sa.issuer); 101 - const result = checkAccess({ 102 - op: "read", 103 - space, 104 - callerDid: sa.issuer, 105 - member, 106 - clientId: sa.clientId, 107 - }); 108 - if (!result.allow) { 109 - return c.json({ error: "Forbidden", reason: result.reason }, 403); 147 + const authz = await authorizeRead(c, spaceUri); 148 + if (authz instanceof Response) return authz; 149 + 150 + if (authz.via === "jwt") { 151 + const sa = authz.sa; 152 + const member = await adapter.getMember(spaceUri, sa.issuer); 153 + const result = checkAccess({ 154 + op: "read", 155 + space, 156 + callerDid: sa.issuer, 157 + member, 158 + clientId: sa.clientId, 159 + }); 160 + if (!result.allow) { 161 + return c.json({ error: "Forbidden", reason: result.reason }, 403); 162 + } 110 163 } 111 164 112 165 const list = await adapter.listRecords(spaceUri, collection, { ··· 117 170 return c.json(list); 118 171 }); 119 172 120 - app.get(`/xrpc/${SPACE}.getRecord`, auth, async (c) => { 121 - const sa = getAuth(c); 173 + app.get(`/xrpc/${SPACE}.getRecord`, readAuth, async (c) => { 122 174 const spaceUri = c.req.query("spaceUri"); 123 175 const collection = c.req.query("collection"); 124 176 const author = c.req.query("author"); ··· 129 181 const space = await adapter.getSpace(spaceUri); 130 182 if (!space) return c.json({ error: "NotFound" }, 404); 131 183 132 - const member = await adapter.getMember(spaceUri, sa.issuer); 133 - const result = checkAccess({ 134 - op: "read", 135 - space, 136 - callerDid: sa.issuer, 137 - member, 138 - clientId: sa.clientId, 139 - targetAuthorDid: author, 140 - }); 141 - if (!result.allow) return c.json({ error: "Forbidden", reason: result.reason }, 403); 184 + const authz = await authorizeRead(c, spaceUri); 185 + if (authz instanceof Response) return authz; 186 + 187 + if (authz.via === "jwt") { 188 + const sa = authz.sa; 189 + const member = await adapter.getMember(spaceUri, sa.issuer); 190 + const result = checkAccess({ 191 + op: "read", 192 + space, 193 + callerDid: sa.issuer, 194 + member, 195 + clientId: sa.clientId, 196 + targetAuthorDid: author, 197 + }); 198 + if (!result.allow) return c.json({ error: "Forbidden", reason: result.reason }, 403); 199 + } 142 200 143 201 const record = await adapter.getRecord(spaceUri, collection, author, rkey); 144 202 if (!record) return c.json({ error: "NotFound" }, 404); ··· 245 303 app.post(`/xrpc/${SPACE}.invite.create`, auth, async (c) => { 246 304 const sa = getAuth(c); 247 305 const body = (await c.req.json().catch(() => null)) as 248 - | { spaceUri?: string; perms?: MemberPerm; expiresAt?: number; maxUses?: number; note?: string } 306 + | { 307 + spaceUri?: string; 308 + kind?: InviteKind; 309 + perms?: MemberPerm; 310 + expiresAt?: number; 311 + maxUses?: number; 312 + note?: string; 313 + } 249 314 | null; 250 315 if (!body?.spaceUri) { 251 316 return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); 317 + } 318 + const kind: InviteKind = body.kind ?? "join"; 319 + if (kind !== "join" && kind !== "read" && kind !== "read-join") { 320 + return c.json({ error: "InvalidRequest", message: "kind must be 'join', 'read', or 'read-join'" }, 400); 252 321 } 253 322 const space = await adapter.getSpace(body.spaceUri); 254 323 if (!space) return c.json({ error: "NotFound" }, 404); ··· 261 330 const invite = await adapter.createInvite({ 262 331 spaceUri: body.spaceUri, 263 332 tokenHash, 333 + kind, 264 334 perms: body.perms ?? "write", 265 335 expiresAt: body.expiresAt ?? null, 266 336 maxUses: body.maxUses ?? null, ··· 369 439 return { 370 440 tokenHash: invite.tokenHash, 371 441 spaceUri: invite.spaceUri, 442 + kind: invite.kind, 372 443 perms: invite.perms, 373 444 expiresAt: invite.expiresAt, 374 445 maxUses: invite.maxUses,
+1
src/core/spaces/schema.ts
··· 39 39 `CREATE TABLE IF NOT EXISTS spaces_invites ( 40 40 token_hash TEXT PRIMARY KEY, 41 41 space_uri TEXT NOT NULL, 42 + kind TEXT NOT NULL DEFAULT 'join', 42 43 perms TEXT NOT NULL, 43 44 expires_at ${dialect.bigintType}, 44 45 max_uses INTEGER,
+12 -1
src/core/spaces/types.ts
··· 78 78 count: number; 79 79 } 80 80 81 + /** What a token holder can do with this invite. 82 + * - `'join'`: must be redeemed while signed in; becomes a member with `perms`. 83 + * - `'read'`: bearer-only — token itself grants read access to the space; cannot be redeemed. 84 + * - `'read-join'`: both — anonymous holders read; signed-in holders may also redeem to join. */ 85 + export type InviteKind = "join" | "read" | "read-join"; 86 + 81 87 export interface InviteRow { 82 88 tokenHash: string; 83 89 spaceUri: string; 90 + kind: InviteKind; 84 91 perms: MemberPerm; 85 92 expiresAt: number | null; 86 93 maxUses: number | null; ··· 94 101 export interface CreateInviteInput { 95 102 spaceUri: string; 96 103 tokenHash: string; 104 + kind: InviteKind; 97 105 perms: MemberPerm; 98 106 expiresAt: number | null; 99 107 maxUses: number | null; ··· 124 132 createInvite(input: CreateInviteInput): Promise<InviteRow>; 125 133 listInvites(spaceUri: string, options?: { includeRevoked?: boolean }): Promise<InviteRow[]>; 126 134 revokeInvite(tokenHash: string): Promise<boolean>; 127 - /** Atomically mark an invite as used (one atomic UPDATE). Returns the row if usable, null otherwise. */ 135 + /** Look up an invite without consuming it. Used to validate read-token bearer access. */ 136 + getInvite(tokenHash: string): Promise<InviteRow | null>; 137 + /** Atomically mark a join-capable invite as used. Returns the row if usable 138 + * (kind allows join, not expired/revoked/exhausted), null otherwise. */ 128 139 redeemInvite(tokenHash: string, now: number): Promise<InviteRow | null>; 129 140 130 141 // Records