···11+/** Membership manifest: a short-lived signed list of spaces a caller is a
22+ * member of, issued by an authority. Lets appviews filter unioned queries
33+ * without syncing the full member list — each user carries their own
44+ * bounded slice as they hit the appview.
55+ *
66+ * Same signing infrastructure as space credentials (ES256 JWT), different
77+ * payload + endpoint. */
88+99+import {
1010+ signCredential,
1111+ verifyCredential,
1212+ decodeUnverifiedClaims,
1313+ type CredentialKeyMaterial,
1414+} from "./credentials";
1515+1616+const ALG = "ES256";
1717+const TYP = "JWT";
1818+const DEFAULT_KEY_ID = "atproto_space_authority";
1919+2020+/** Default manifest TTL — same 2h as credentials. */
2121+export const DEFAULT_MANIFEST_TTL_MS = 2 * 60 * 60 * 1000;
2222+2323+export interface MembershipManifestClaims {
2424+ /** Authority DID that issued (and signed) the manifest. */
2525+ iss: string;
2626+ /** User DID this manifest is for. */
2727+ sub: string;
2828+ /** Space URIs the user is a member of (according to this authority). */
2929+ spaces: string[];
3030+ /** Seconds since epoch. */
3131+ iat: number;
3232+ exp: number;
3333+}
3434+3535+/** Sign a manifest using the authority's signing key. */
3636+export async function signMembershipManifest(
3737+ payload: MembershipManifestClaims,
3838+ key: CredentialKeyMaterial
3939+): Promise<string> {
4040+ // signCredential internally builds the JWT given a CredentialClaims-like
4141+ // shape. The manifest payload has different fields (`spaces` instead of
4242+ // `space`/`scope`) so we hand-roll the JWT here using the same utilities.
4343+ const enc = new TextEncoder();
4444+ const kid = `${payload.iss}#${key.keyId ?? DEFAULT_KEY_ID}`;
4545+ const header = { alg: ALG, typ: TYP, kid };
4646+ const head = base64urlEncode(enc.encode(JSON.stringify(header)));
4747+ const body = base64urlEncode(enc.encode(JSON.stringify(payload)));
4848+ const signingInput = `${head}.${body}`;
4949+ const privateKey = await crypto.subtle.importKey(
5050+ "jwk",
5151+ key.privateKey,
5252+ { name: "ECDSA", namedCurve: "P-256" },
5353+ false,
5454+ ["sign"]
5555+ );
5656+ const sig = await crypto.subtle.sign(
5757+ { name: "ECDSA", hash: "SHA-256" },
5858+ privateKey,
5959+ enc.encode(signingInput)
6060+ );
6161+ return `${signingInput}.${base64urlEncode(new Uint8Array(sig))}`;
6262+}
6363+6464+/** Issue a manifest with current iat/exp. */
6565+export async function issueMembershipManifest(
6666+ args: Omit<MembershipManifestClaims, "iat" | "exp"> & { ttlMs: number },
6767+ key: CredentialKeyMaterial
6868+): Promise<{ manifest: string; expiresAt: number }> {
6969+ const now = Math.floor(Date.now() / 1000);
7070+ const expSec = now + Math.floor(args.ttlMs / 1000);
7171+ const payload: MembershipManifestClaims = {
7272+ iss: args.iss,
7373+ sub: args.sub,
7474+ spaces: args.spaces,
7575+ iat: now,
7676+ exp: expSec,
7777+ };
7878+ const manifest = await signMembershipManifest(payload, key);
7979+ return { manifest, expiresAt: expSec * 1000 };
8080+}
8181+8282+export type ManifestVerifyOk = { ok: true; claims: MembershipManifestClaims };
8383+export type ManifestVerifyErr = {
8484+ ok: false;
8585+ reason: "malformed" | "bad-alg" | "bad-signature" | "expired" | "not-yet-valid" | "unknown-issuer";
8686+};
8787+8888+export interface VerifyManifestOptions {
8989+ /** Resolve the issuer's verification key. */
9090+ resolveKey: (iss: string, kid: string | undefined) => Promise<JsonWebKey | null>;
9191+ /** Time provider for tests. */
9292+ now?: () => number;
9393+}
9494+9595+export async function verifyMembershipManifest(
9696+ jwt: string,
9797+ opts: VerifyManifestOptions
9898+): Promise<ManifestVerifyOk | ManifestVerifyErr> {
9999+ const parts = jwt.split(".");
100100+ if (parts.length !== 3) return { ok: false, reason: "malformed" };
101101+ const [headSeg, bodySeg, sigSeg] = parts as [string, string, string];
102102+103103+ let header: { alg?: string; kid?: string };
104104+ let claims: MembershipManifestClaims;
105105+ try {
106106+ header = JSON.parse(new TextDecoder().decode(base64urlDecode(headSeg)));
107107+ claims = JSON.parse(new TextDecoder().decode(base64urlDecode(bodySeg)));
108108+ } catch {
109109+ return { ok: false, reason: "malformed" };
110110+ }
111111+ if (header.alg !== ALG) return { ok: false, reason: "bad-alg" };
112112+ if (!Array.isArray(claims.spaces)) return { ok: false, reason: "malformed" };
113113+114114+ const nowMs = (opts.now ?? Date.now)();
115115+ const nowSec = Math.floor(nowMs / 1000);
116116+ if (claims.exp <= nowSec) return { ok: false, reason: "expired" };
117117+ if (claims.iat > nowSec + 60) return { ok: false, reason: "not-yet-valid" };
118118+119119+ const jwk = await opts.resolveKey(claims.iss, header.kid);
120120+ if (!jwk) return { ok: false, reason: "unknown-issuer" };
121121+122122+ const publicKey = await crypto.subtle.importKey(
123123+ "jwk",
124124+ jwk,
125125+ { name: "ECDSA", namedCurve: "P-256" },
126126+ false,
127127+ ["verify"]
128128+ );
129129+ const sigBytes = base64urlDecode(sigSeg);
130130+ const enc = new TextEncoder();
131131+ const valid = await crypto.subtle.verify(
132132+ { name: "ECDSA", hash: "SHA-256" },
133133+ publicKey,
134134+ sigBytes as BufferSource,
135135+ enc.encode(`${headSeg}.${bodySeg}`)
136136+ );
137137+ if (!valid) return { ok: false, reason: "bad-signature" };
138138+ return { ok: true, claims };
139139+}
140140+141141+/** Peek at claims without verifying — useful for routing decisions. */
142142+export function decodeUnverifiedManifest(jwt: string): MembershipManifestClaims | null {
143143+ const parts = jwt.split(".");
144144+ if (parts.length !== 3) return null;
145145+ try {
146146+ return JSON.parse(new TextDecoder().decode(base64urlDecode(parts[1]!))) as MembershipManifestClaims;
147147+ } catch {
148148+ return null;
149149+ }
150150+}
151151+152152+// Local base64url helpers — mirror the credentials module so we don't expose
153153+// these as public utilities.
154154+function base64urlEncode(bytes: Uint8Array): string {
155155+ const s = btoa(String.fromCharCode(...bytes));
156156+ return s.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
157157+}
158158+function base64urlDecode(s: string): Uint8Array {
159159+ const padded = s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "=");
160160+ const bin = atob(padded);
161161+ const out = new Uint8Array(bin.length);
162162+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
163163+ return out;
164164+}
+10
packages/contrail-base/src/spaces/types.ts
···5252 signing?: CredentialKeyMaterial;
5353 /** Credential lifetime in ms. Defaults to {@link DEFAULT_CREDENTIAL_TTL_MS}. */
5454 credentialTtlMs?: number;
5555+ /** Membership-manifest lifetime in ms. Manifests carry a user's full
5656+ * member-of list and let appviews filter unioned queries without syncing
5757+ * the authority's full member tables. Same key material as credentials.
5858+ * Defaults to {@link DEFAULT_MANIFEST_TTL_MS}. */
5959+ manifestTtlMs?: number;
6060+ /** Maximum number of spaces returned in a manifest. The endpoint paginates
6161+ * through `listSpaces` up to this cap; users with more spaces get a
6262+ * truncated manifest (the remainder won't be unioned in queries). Defaults
6363+ * to 500. */
6464+ manifestMaxSpaces?: number;
5565}
56665767/** Configuration for the **record host** role: stores per-space records and
···11+{
22+ "lexicon": 1,
33+ "id": "tools.atmo.space.getMembershipManifest",
44+ "defs": {
55+ "main": {
66+ "type": "procedure",
77+ "description": "Mint a signed list of every space the caller is a member of (or owns) according to this authority. Appviews can carry the manifest on inbound requests so unioned listRecords queries are filtered against the caller's bounded slice without the appview having to sync the authority's full member list. Same key material as `getCredential`; different payload (an array of space URIs rather than a single space + scope). Truncated to `manifestMaxSpaces` (default 500) — the response sets `truncated: true` when the cap was hit.",
88+ "input": {
99+ "encoding": "application/json",
1010+ "schema": {
1111+ "type": "object",
1212+ "properties": {}
1313+ }
1414+ },
1515+ "output": {
1616+ "encoding": "application/json",
1717+ "schema": {
1818+ "type": "object",
1919+ "required": ["manifest", "expiresAt", "truncated"],
2020+ "properties": {
2121+ "manifest": {
2222+ "type": "string",
2323+ "description": "Compact JWS (ES256) signed by the authority's key. Payload claims: `iss` (authority DID), `sub` (caller DID), `spaces` (array of space URIs), `iat`, `exp`."
2424+ },
2525+ "expiresAt": {
2626+ "type": "integer",
2727+ "description": "Expiry as ms since epoch."
2828+ },
2929+ "truncated": {
3030+ "type": "boolean",
3131+ "description": "True if the caller is in more spaces than fit in one manifest. The remainder won't be unioned in queries until the next refresh."
3232+ }
3333+ }
3434+ }
3535+ },
3636+ "errors": [
3737+ { "name": "NotImplemented", "description": "Authority is not configured to sign manifests." }
3838+ ]
3939+ }
4040+ }
4141+}