···44 "defs": {
55 "main": {
66 "type": "query",
77- "description": "Get metadata for a single space. Caller must be a member or the owner.",
77+ "description": "Get metadata for a single space. Caller must be a member, the owner, or hold a read-grant invite token.",
88 "parameters": {
99 "type": "params",
1010 "required": ["uri"],
1111 "properties": {
1212- "uri": { "type": "string", "format": "at-uri" }
1212+ "uri": { "type": "string", "format": "at-uri" },
1313+ "inviteToken": { "type": "string", "description": "Read-grant invite token. When supplied, replaces JWT auth for this read." }
1314 }
1415 },
1516 "output": {
···1616import { resolveActor } from "../identity";
1717import type { FormattedRecord } from "./helpers";
1818import { formatRecord, parseIntParam, fieldToParam } from "./helpers";
1919-import { verifyServiceAuthRequest } from "../spaces/auth";
1919+import { verifyServiceAuthRequest, extractInviteToken, checkInviteReadGrant } from "../spaces/auth";
2020import { checkAccess } from "../spaces/acl";
2121+import { hashInviteToken } from "../spaces/invite-token";
2122import type { SpacesContext } from ".";
2223import type { Nsid } from "@atcute/lexicons";
2324···206207 c: Context,
207208 spaceUri: string,
208209 op: "read"
209209- ): Promise<Response | { callerDid: string; clientId?: string }> {
210210+ ): Promise<Response | { callerDid?: string; clientId?: string; viaInviteToken?: boolean }> {
210211 if (!spacesCtx) {
211212 return c.json(
212213 { error: "InvalidRequest", message: "spaces not configured on this service" },
213214 501
214215 );
215216 }
217217+218218+ // Read-token path: anonymous bearer access via `?inviteToken=...` (or
219219+ // `Authorization: Bearer atmo-invite:<token>`). Token must exist, be
220220+ // unexpired/unrevoked, scoped to this space, and have a kind that grants
221221+ // read (`read` or `read-join`). Token kind cannot grant write — caller must
222222+ // separately redeem to become a member for any non-read op.
223223+ if (op === "read") {
224224+ const rawToken = extractInviteToken(c.req.raw);
225225+ if (rawToken) {
226226+ const ok = await checkInviteReadGrant(
227227+ spacesCtx.adapter,
228228+ rawToken,
229229+ spaceUri,
230230+ hashInviteToken
231231+ );
232232+ if (ok) {
233233+ const space = await spacesCtx.adapter.getSpace(spaceUri);
234234+ if (!space) return c.json({ error: "NotFound" }, 404);
235235+ return { viaInviteToken: true };
236236+ }
237237+ return c.json(
238238+ { error: "Forbidden", reason: "invalid-invite-token" },
239239+ 403
240240+ );
241241+ }
242242+ }
243243+216244 const nsid = new URL(c.req.url).pathname.match(/\/xrpc\/([^?]+)/)?.[1] as Nsid | null;
217245 const auth = await verifyServiceAuthRequest(spacesCtx.verifier, c.req.raw, nsid);
218246 if (!auth) {
219247 return c.json(
220220- { error: "AuthRequired", message: "spaceUri requires a valid service-auth JWT" },
248248+ { error: "AuthRequired", message: "spaceUri requires a valid service-auth JWT or read-grant invite token" },
221249 401
222250 );
223251 }
+17-3
src/core/spaces/adapter.ts
···1212 AppPolicy,
1313 CollectionCount,
1414 CreateInviteInput,
1515+ InviteKind,
1516 InviteRow,
1617 ListOptions,
1718 ListResult,
···6869 return {
6970 tokenHash: row.token_hash,
7071 spaceUri: row.space_uri,
7272+ kind: (row.kind ?? "join") as InviteKind,
7173 perms: row.perms,
7274 expiresAt: row.expires_at == null ? null : toNum(row.expires_at),
7375 maxUses: row.max_uses == null ? null : Number(row.max_uses),
···238240 const now = Date.now();
239241 await this.db
240242 .prepare(
241241- `INSERT INTO spaces_invites (token_hash, space_uri, perms, expires_at, max_uses, used_count, created_by, created_at, note)
242242- VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)`
243243+ `INSERT INTO spaces_invites (token_hash, space_uri, kind, perms, expires_at, max_uses, used_count, created_by, created_at, note)
244244+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`
243245 )
244246 .bind(
245247 input.tokenHash,
246248 input.spaceUri,
249249+ input.kind,
247250 input.perms,
248251 input.expiresAt,
249252 input.maxUses,
···255258 return {
256259 tokenHash: input.tokenHash,
257260 spaceUri: input.spaceUri,
261261+ kind: input.kind,
258262 perms: input.perms,
259263 expiresAt: input.expiresAt,
260264 maxUses: input.maxUses,
···264268 revokedAt: null,
265269 note: input.note,
266270 };
271271+ }
272272+273273+ async getInvite(tokenHash: string): Promise<InviteRow | null> {
274274+ const row = await this.db
275275+ .prepare(`SELECT * FROM spaces_invites WHERE token_hash = ?`)
276276+ .bind(tokenHash)
277277+ .first<any>();
278278+ return row ? mapInviteRow(row) : null;
267279 }
268280269281 async listInvites(
···287299 }
288300289301 async redeemInvite(tokenHash: string, now: number): Promise<InviteRow | null> {
290290- // Atomic: increment used_count only if the invite is usable right now.
302302+ // Atomic: increment used_count only if the invite is usable right now AND
303303+ // its kind allows redemption (read-only tokens cannot be consumed for membership).
291304 const res = await this.db
292305 .prepare(
293306 `UPDATE spaces_invites
294307 SET used_count = used_count + 1
295308 WHERE token_hash = ?
309309+ AND kind IN ('join', 'read-join')
296310 AND revoked_at IS NULL
297311 AND (expires_at IS NULL OR expires_at > ?)
298312 AND (max_uses IS NULL OR used_count < max_uses)`
+33
src/core/spaces/auth.ts
···102102 lxm: result.value.lxm,
103103 };
104104}
105105+106106+/** Pull a read-grant invite token off the request — query string `?inviteToken=`
107107+ * or `Authorization: Bearer atmo-invite:<token>`. Returns the raw token (not
108108+ * hashed) or null. Routes hash + look up via the adapter. */
109109+export function extractInviteToken(request: Request): string | null {
110110+ const url = new URL(request.url);
111111+ const q = url.searchParams.get("inviteToken");
112112+ if (q) return q.trim();
113113+ const header = request.headers.get("Authorization");
114114+ if (header?.startsWith("Bearer atmo-invite:")) {
115115+ return header.slice("Bearer atmo-invite:".length).trim();
116116+ }
117117+ return null;
118118+}
119119+120120+/** Validate a read-grant invite token against a target spaceUri. Returns true
121121+ * if the token exists, scopes to this space, has a kind that grants read
122122+ * (`read` or `read-join`), and is not expired/revoked. */
123123+export async function checkInviteReadGrant(
124124+ adapter: { getInvite(tokenHash: string): Promise<{ spaceUri: string; kind: string; revokedAt: number | null; expiresAt: number | null } | null> },
125125+ rawToken: string,
126126+ spaceUri: string,
127127+ hashFn: (token: string) => Promise<string>
128128+): Promise<boolean> {
129129+ const tokenHash = await hashFn(rawToken);
130130+ const invite = await adapter.getInvite(tokenHash);
131131+ if (!invite) return false;
132132+ if (invite.spaceUri !== spaceUri) return false;
133133+ if (invite.kind !== "read" && invite.kind !== "read-join") return false;
134134+ if (invite.revokedAt != null) return false;
135135+ if (invite.expiresAt != null && invite.expiresAt <= Date.now()) return false;
136136+ return true;
137137+}
+103-32
src/core/spaces/router.ts
···11-import type { Hono, MiddlewareHandler } from "hono";
11+import type { Context, Hono, MiddlewareHandler } from "hono";
22import type { ContrailConfig, Database } from "../types";
33import { HostedAdapter } from "./adapter";
44import { checkAccess } from "./acl";
55import type { ServiceAuth } from "./auth";
66-import { buildVerifier, createServiceAuthMiddleware } from "./auth";
66+import {
77+ buildVerifier,
88+ checkInviteReadGrant,
99+ createServiceAuthMiddleware,
1010+ extractInviteToken,
1111+ verifyServiceAuthRequest,
1212+} from "./auth";
713import { nextTid } from "./tid";
814import { generateInviteToken, hashInviteToken } from "./invite-token";
99-import type { InviteRow, MemberPerm, SpaceRow, SpacesConfig, StorageAdapter } from "./types";
1515+import type { InviteKind, InviteRow, MemberPerm, SpaceRow, SpacesConfig, StorageAdapter } from "./types";
1016import type { Did } from "@atcute/lexicons";
11171218export interface SpacesRoutesOptions {
···2733 if (!spacesConfig) return;
28342935 const adapter = options.adapter ?? ctx?.adapter ?? new HostedAdapter(db, config);
3636+ const verifier = ctx?.verifier ?? buildVerifier(spacesConfig);
3037 const auth =
3131- options.authMiddleware ??
3232- (ctx ? createServiceAuthMiddleware(ctx.verifier) : buildAuthMiddleware(spacesConfig));
3838+ options.authMiddleware ?? createServiceAuthMiddleware(verifier);
3939+4040+ /** Read-route auth: skip the JWT middleware when an `?inviteToken=` is
4141+ * present so anonymous bearer reads don't 401 before the route handler can
4242+ * validate the token. The route handler is responsible for actually checking
4343+ * the token (via `authorizeRead`). */
4444+ const readAuth: MiddlewareHandler = async (c, next) => {
4545+ if (extractInviteToken(c.req.raw)) {
4646+ await next();
4747+ return;
4848+ }
4949+ return auth(c, next);
5050+ };
5151+5252+ /** Authorize a read request: either a valid service-auth JWT (which also
5353+ * identifies the caller for member checks downstream) or a valid read-grant
5454+ * invite token bearer (`?inviteToken=...` or
5555+ * `Authorization: Bearer atmo-invite:<token>`). */
5656+ async function authorizeRead(
5757+ c: Context,
5858+ spaceUri: string
5959+ ): Promise<{ via: "token" } | { via: "jwt"; sa: ServiceAuth } | Response> {
6060+ const rawToken = extractInviteToken(c.req.raw);
6161+ if (rawToken) {
6262+ const ok = await checkInviteReadGrant(adapter, rawToken, spaceUri, hashInviteToken);
6363+ if (!ok) return c.json({ error: "Forbidden", reason: "invalid-invite-token" }, 403);
6464+ return { via: "token" };
6565+ }
6666+ const sa = c.get("serviceAuth") as ServiceAuth | undefined;
6767+ if (sa) return { via: "jwt", sa };
6868+ return c.json(
6969+ { error: "AuthRequired", message: "JWT or read-grant invite token required" },
7070+ 401
7171+ );
7272+ }
33733474 /** Space endpoints are emitted per-deployment under the configured namespace;
3575 * the deployment owns and publishes its own lexicons. The library ships
···72112 return c.json({ members });
73113 });
741147575- app.get(`/xrpc/${SPACE}.getSpace`, auth, async (c) => {
115115+ app.get(`/xrpc/${SPACE}.getSpace`, readAuth, async (c) => {
76116 const uri = c.req.query("uri");
77117 if (!uri) return c.json({ error: "InvalidRequest", message: "uri required" }, 400);
78118 const space = await adapter.getSpace(uri);
79119 if (!space) return c.json({ error: "NotFound" }, 404);
801208181- const sa = getAuth(c);
121121+ const authz = await authorizeRead(c, uri);
122122+ if (authz instanceof Response) return authz;
123123+124124+ if (authz.via === "token") {
125125+ // Anonymous read-token bearer — show non-owner space view.
126126+ return c.json({ space: publicSpaceView(space, false) });
127127+ }
128128+129129+ const sa = authz.sa;
82130 const isOwner = sa.issuer === space.ownerDid;
83131 const member = isOwner ? null : await adapter.getMember(uri, sa.issuer);
84132 if (!isOwner && !member) {
···87135 return c.json({ space: publicSpaceView(space, isOwner) });
88136 });
891379090- app.get(`/xrpc/${SPACE}.listRecords`, auth, async (c) => {
9191- const sa = getAuth(c);
138138+ app.get(`/xrpc/${SPACE}.listRecords`, readAuth, async (c) => {
92139 const spaceUri = c.req.query("spaceUri");
93140 const collection = c.req.query("collection");
94141 if (!spaceUri || !collection) {
···97144 const space = await adapter.getSpace(spaceUri);
98145 if (!space) return c.json({ error: "NotFound" }, 404);
99146100100- const member = await adapter.getMember(spaceUri, sa.issuer);
101101- const result = checkAccess({
102102- op: "read",
103103- space,
104104- callerDid: sa.issuer,
105105- member,
106106- clientId: sa.clientId,
107107- });
108108- if (!result.allow) {
109109- return c.json({ error: "Forbidden", reason: result.reason }, 403);
147147+ const authz = await authorizeRead(c, spaceUri);
148148+ if (authz instanceof Response) return authz;
149149+150150+ if (authz.via === "jwt") {
151151+ const sa = authz.sa;
152152+ const member = await adapter.getMember(spaceUri, sa.issuer);
153153+ const result = checkAccess({
154154+ op: "read",
155155+ space,
156156+ callerDid: sa.issuer,
157157+ member,
158158+ clientId: sa.clientId,
159159+ });
160160+ if (!result.allow) {
161161+ return c.json({ error: "Forbidden", reason: result.reason }, 403);
162162+ }
110163 }
111164112165 const list = await adapter.listRecords(spaceUri, collection, {
···117170 return c.json(list);
118171 });
119172120120- app.get(`/xrpc/${SPACE}.getRecord`, auth, async (c) => {
121121- const sa = getAuth(c);
173173+ app.get(`/xrpc/${SPACE}.getRecord`, readAuth, async (c) => {
122174 const spaceUri = c.req.query("spaceUri");
123175 const collection = c.req.query("collection");
124176 const author = c.req.query("author");
···129181 const space = await adapter.getSpace(spaceUri);
130182 if (!space) return c.json({ error: "NotFound" }, 404);
131183132132- const member = await adapter.getMember(spaceUri, sa.issuer);
133133- const result = checkAccess({
134134- op: "read",
135135- space,
136136- callerDid: sa.issuer,
137137- member,
138138- clientId: sa.clientId,
139139- targetAuthorDid: author,
140140- });
141141- if (!result.allow) return c.json({ error: "Forbidden", reason: result.reason }, 403);
184184+ const authz = await authorizeRead(c, spaceUri);
185185+ if (authz instanceof Response) return authz;
186186+187187+ if (authz.via === "jwt") {
188188+ const sa = authz.sa;
189189+ const member = await adapter.getMember(spaceUri, sa.issuer);
190190+ const result = checkAccess({
191191+ op: "read",
192192+ space,
193193+ callerDid: sa.issuer,
194194+ member,
195195+ clientId: sa.clientId,
196196+ targetAuthorDid: author,
197197+ });
198198+ if (!result.allow) return c.json({ error: "Forbidden", reason: result.reason }, 403);
199199+ }
142200143201 const record = await adapter.getRecord(spaceUri, collection, author, rkey);
144202 if (!record) return c.json({ error: "NotFound" }, 404);
···245303 app.post(`/xrpc/${SPACE}.invite.create`, auth, async (c) => {
246304 const sa = getAuth(c);
247305 const body = (await c.req.json().catch(() => null)) as
248248- | { spaceUri?: string; perms?: MemberPerm; expiresAt?: number; maxUses?: number; note?: string }
306306+ | {
307307+ spaceUri?: string;
308308+ kind?: InviteKind;
309309+ perms?: MemberPerm;
310310+ expiresAt?: number;
311311+ maxUses?: number;
312312+ note?: string;
313313+ }
249314 | null;
250315 if (!body?.spaceUri) {
251316 return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400);
317317+ }
318318+ const kind: InviteKind = body.kind ?? "join";
319319+ if (kind !== "join" && kind !== "read" && kind !== "read-join") {
320320+ return c.json({ error: "InvalidRequest", message: "kind must be 'join', 'read', or 'read-join'" }, 400);
252321 }
253322 const space = await adapter.getSpace(body.spaceUri);
254323 if (!space) return c.json({ error: "NotFound" }, 404);
···261330 const invite = await adapter.createInvite({
262331 spaceUri: body.spaceUri,
263332 tokenHash,
333333+ kind,
264334 perms: body.perms ?? "write",
265335 expiresAt: body.expiresAt ?? null,
266336 maxUses: body.maxUses ?? null,
···369439 return {
370440 tokenHash: invite.tokenHash,
371441 spaceUri: invite.spaceUri,
442442+ kind: invite.kind,
372443 perms: invite.perms,
373444 expiresAt: invite.expiresAt,
374445 maxUses: invite.maxUses,
+1
src/core/spaces/schema.ts
···3939 `CREATE TABLE IF NOT EXISTS spaces_invites (
4040 token_hash TEXT PRIMARY KEY,
4141 space_uri TEXT NOT NULL,
4242+ kind TEXT NOT NULL DEFAULT 'join',
4243 perms TEXT NOT NULL,
4344 expires_at ${dialect.bigintType},
4445 max_uses INTEGER,
+12-1
src/core/spaces/types.ts
···7878 count: number;
7979}
80808181+/** What a token holder can do with this invite.
8282+ * - `'join'`: must be redeemed while signed in; becomes a member with `perms`.
8383+ * - `'read'`: bearer-only — token itself grants read access to the space; cannot be redeemed.
8484+ * - `'read-join'`: both — anonymous holders read; signed-in holders may also redeem to join. */
8585+export type InviteKind = "join" | "read" | "read-join";
8686+8187export interface InviteRow {
8288 tokenHash: string;
8389 spaceUri: string;
9090+ kind: InviteKind;
8491 perms: MemberPerm;
8592 expiresAt: number | null;
8693 maxUses: number | null;
···94101export interface CreateInviteInput {
95102 spaceUri: string;
96103 tokenHash: string;
104104+ kind: InviteKind;
97105 perms: MemberPerm;
98106 expiresAt: number | null;
99107 maxUses: number | null;
···124132 createInvite(input: CreateInviteInput): Promise<InviteRow>;
125133 listInvites(spaceUri: string, options?: { includeRevoked?: boolean }): Promise<InviteRow[]>;
126134 revokeInvite(tokenHash: string): Promise<boolean>;
127127- /** Atomically mark an invite as used (one atomic UPDATE). Returns the row if usable, null otherwise. */
135135+ /** Look up an invite without consuming it. Used to validate read-token bearer access. */
136136+ getInvite(tokenHash: string): Promise<InviteRow | null>;
137137+ /** Atomically mark a join-capable invite as used. Returns the row if usable
138138+ * (kind allows join, not expired/revoked/exhausted), null otherwise. */
128139 redeemInvite(tokenHash: string, now: number): Promise<InviteRow | null>;
129140130141 // Records