[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.

improve auth for sync

Florian (Apr 23, 2026, 10:46 AM +0200) caca8c86 6c965fd0

+263 -351
-1
src/index.ts
··· 35 35 SpacesConfig, 36 36 AppPolicy, 37 37 AppPolicyMode, 38 - AuthOverrideResult, 39 38 SpaceRow, 40 39 SpaceMemberRow, 41 40 StoredRecord,
+32
src/server.ts
··· 1 + import { Client } from "@atcute/client"; 1 2 import type { Contrail } from "./contrail"; 2 3 import type { Database } from "./core/types"; 4 + import { markInProcess } from "./core/spaces/in-process"; 3 5 4 6 /** 5 7 * Create an HTTP handler from a Contrail instance. ··· 27 29 return cached(request); 28 30 }; 29 31 } 32 + 33 + /** 34 + * Fully typed `@atcute/client` Client that routes XRPC calls through a 35 + * contrail handler in-process — no HTTP roundtrip, no JWT minting. 36 + * 37 + * Pass `did` to act as that user; omit for anonymous calls (public endpoints 38 + * only). Authentication is via the in-process WeakMap marker, which is 39 + * unforgeable across network boundaries. 40 + * 41 + * // Same-process on Cloudflare Workers (per-request DB): 42 + * const client = createServerClient( 43 + * (req) => contrail.handler({ db: env.DB })(req), 44 + * session.did, 45 + * ); 46 + * await client.post('tools.atmo.chat.space.putRecord', { input: { ... } }); 47 + */ 48 + export function createServerClient( 49 + handle: (req: Request) => Promise<Response>, 50 + did?: string 51 + ): Client { 52 + return new Client({ 53 + handler: async (pathname, init) => { 54 + const req = new Request(new URL(pathname, "http://localhost"), init); 55 + if (did !== undefined) markInProcess(req, did); 56 + return handle(req); 57 + }, 58 + }); 59 + } 60 + 61 + export { markInProcess };
+12 -21
src/sync/index.ts
··· 33 33 /** Transport. Default: 'sse'. Use 'ws' on Cloudflare for DO-terminated 34 34 * long-lived subscriptions that hibernate while idle. */ 35 35 transport?: "sse" | "ws"; 36 - /** Optional: provide a ticket string (for `?ticket=...`-style auth). 37 - * When omitted, the connection is opened without an explicit token — 38 - * rely on cookies / bearer tokens your fetch policy sends automatically. */ 36 + /** Mint a watch-scoped ticket to auth the connection. Called once per 37 + * connect attempt; the returned ticket is sent as `?ticket=...` on the 38 + * SSE connection or on the `mode=ws` handshake fetch. Omit for public 39 + * (no-auth) endpoints. 40 + * 41 + * For atproto-based services, tickets are typically minted server-side 42 + * via `<ns>.realtime.ticket` (or via an app-specific route that runs 43 + * the `mode=ws` handshake in-process and returns the signed ticket). */ 39 44 fetchTicket?: () => Promise<string>; 40 - /** Optional: mint a short-lived bearer token (typically an atproto 41 - * service-auth JWT scoped to `<collection>.watchRecords`). Called once 42 - * per connect attempt; returned token is sent as `Authorization: Bearer` 43 - * on the handshake fetch (`mode=ws`) or on the SSE connection via a 44 - * `?auth=` query param the server doesn't use — browsers can't set 45 - * headers on EventSource, so for SSE the app should cookie-auth or 46 - * fall back to `fetchTicket`. */ 47 - fetchAuthToken?: () => Promise<string>; 48 45 /** Custom compare. Default: sort by `time_us` descending (newest first), 49 46 * tie-breaking by rkey. */ 50 47 compareRecords?: (a: WatchRecord, b: WatchRecord) => number; ··· 312 309 }; 313 310 314 311 const openWs = async () => { 315 - // Step 1: snapshot + handshake. Authenticated by Bearer token from 316 - // `fetchAuthToken` (an atproto service-auth JWT — browsers get this 317 - // from a same-origin helper that uses the OAuth session). The server 312 + // Step 1: snapshot + handshake. Authenticated by the watch-scoped 313 + // ticket from `fetchTicket`, passed as `?ticket=...`. The server 318 314 // returns a ticket bound to (did, spaceUri, querySpec); we pass that 319 315 // back embedded in the wsUrl on the WS upgrade so the WS itself 320 - // doesn't need a JWT (EventSource/WebSocket can't set headers). 316 + // doesn't need any other auth. 321 317 let snapshotUrl = options.url; 322 318 const sep = snapshotUrl.includes("?") ? "&" : "?"; 323 319 snapshotUrl += `${sep}mode=ws`; ··· 325 321 const ticket = await options.fetchTicket(); 326 322 snapshotUrl += `&ticket=${encodeURIComponent(ticket)}`; 327 323 } 328 - const headers: Record<string, string> = { accept: "application/json" }; 329 - if (options.fetchAuthToken) { 330 - const token = await options.fetchAuthToken(); 331 - headers.authorization = `Bearer ${token}`; 332 - } 333 - const res = await fetch(snapshotUrl, { headers }); 324 + const res = await fetch(snapshotUrl, { headers: { accept: "application/json" } }); 334 325 if (!res.ok) throw new Error(`snapshot fetch failed (${res.status})`); 335 326 const handshake = (await res.json()) as { 336 327 transport: "ws";
+4 -1
examples/sveltekit-group-chat/.claude/settings.local.json
··· 6 6 "mcp__plugin_svelte_svelte__get-documentation", 7 7 "Bash(pnpm check:*)", 8 8 "Bash(pnpm env:generate-secret:*)", 9 - "Bash(pnpm build:agent-setup:*)" 9 + "Bash(pnpm build:agent-setup:*)", 10 + "Read(//Users/florian/contrail/**)", 11 + "Bash(pnpm typecheck *)", 12 + "Bash(pnpm test *)" 10 13 ] 11 14 } 12 15 }
-1
examples/sveltekit-group-chat/src/app.d.ts
··· 28 28 COMMUNITY_MASTER_KEY: string; 29 29 REALTIME_TICKET_SECRET: string; 30 30 SERVICE_DID: string; 31 - DEV_AUTH?: string; 32 31 REALTIME: DurableObjectNamespace; 33 32 BLOBS?: R2Bucket; 34 33 };
+5 -11
src/core/router/collection.ts
··· 446 446 } 447 447 448 448 const nsid = new URL(c.req.url).pathname.match(/\/xrpc\/([^?]+)/)?.[1] as Nsid | null; 449 - const auth = await verifyServiceAuthRequest(spacesCtx.verifier, c.req.raw, nsid, { 450 - authOverride: config.spaces?.authOverride, 451 - }); 449 + const auth = await verifyServiceAuthRequest(spacesCtx.verifier, c.req.raw, nsid); 452 450 if (!auth) { 453 451 return c.json( 454 452 { error: "AuthRequired", message: "spaceUri requires a valid service-auth JWT or read-grant invite token" }, ··· 497 495 } 498 496 } 499 497 500 - // Union path: when Authorization is present (or the dev-mode override 501 - // can supply claims without one), verify and fold in records from 502 - // spaces the caller is a member of. 498 + // Union path: when the caller is authenticated, fold in records from 499 + // spaces they're a member of. Anonymous callers just get public results. 503 500 let spaceUris: string[] | undefined; 504 501 const hasAuthHeader = !!c.req.header("Authorization"); 505 - const hasOverride = !!config.spaces?.authOverride; 506 - if (spacesCtx && (hasAuthHeader || hasOverride)) { 502 + if (spacesCtx) { 507 503 const nsid = new URL(c.req.url).pathname.match(/\/xrpc\/([^?]+)/)?.[1] as Nsid | null; 508 - const auth = await verifyServiceAuthRequest(spacesCtx.verifier, c.req.raw, nsid, { 509 - authOverride: config.spaces?.authOverride, 510 - }); 504 + const auth = await verifyServiceAuthRequest(spacesCtx.verifier, c.req.raw, nsid); 511 505 if (auth) { 512 506 const { spaces } = await spacesCtx.adapter.listSpaces({ 513 507 memberDid: auth.issuer,
+3 -5
src/core/router/index.ts
··· 122 122 : null; 123 123 registerSpacesRoutes(app, spacesDb, config, options.spaces, spacesCtx, communityAdapterForSpaces); 124 124 125 - const authOverride = config.spaces?.authOverride; 126 - 127 125 if (config.community && spacesCtx) { 128 126 // Community routes reuse the spaces service-auth middleware (same JWT verifier). 129 127 const authMiddleware = 130 128 options.community?.authMiddleware ?? 131 129 options.spaces?.authMiddleware ?? 132 - createServiceAuthMiddleware(spacesCtx.verifier, { authOverride }); 130 + createServiceAuthMiddleware(spacesCtx.verifier); 133 131 registerCommunityRoutes( 134 132 app, 135 133 spacesDb, ··· 144 142 // space ownership (user-owned → addMember; community-owned → grant). 145 143 const authMiddleware = 146 144 options.spaces?.authMiddleware ?? 147 - createServiceAuthMiddleware(spacesCtx.verifier, { authOverride }); 145 + createServiceAuthMiddleware(spacesCtx.verifier); 148 146 const communityAdapter = config.community ? new CommunityAdapter(spacesDb) : null; 149 147 registerInviteRoutes(app, config, spacesCtx.adapter, communityAdapter, { authMiddleware }); 150 148 } ··· 157 155 const authMiddleware = spacesCtx 158 156 ? options.realtime?.authMiddleware ?? 159 157 options.spaces?.authMiddleware ?? 160 - createServiceAuthMiddleware(spacesCtx.verifier, { authOverride }) 158 + createServiceAuthMiddleware(spacesCtx.verifier) 161 159 : null; 162 160 const communityAdapter = config.community ? new CommunityAdapter(spacesDb) : null; 163 161 registerRealtimeRoutes(app, config, spacesCtx?.adapter ?? null, communityAdapter, {
+27 -48
src/core/spaces/auth.ts
··· 8 8 } from "@atcute/identity-resolver"; 9 9 import type { Did, Nsid } from "@atcute/lexicons"; 10 10 import type { SpacesConfig } from "./types"; 11 + import { readInProcess } from "./in-process"; 11 12 12 13 export { ServiceJwtVerifier }; 13 14 ··· 41 42 resolver: DidDocumentResolver; 42 43 } 43 44 44 - /** Hono middleware that verifies the Authorization: Bearer <JWT> as an atproto 45 - * service-auth token. On success, attaches the decoded claims to c.var.serviceAuth. 46 - * Expected Nsid method is taken from the route pattern (last segment after /xrpc/). 45 + /** Hono middleware that authenticates XRPC requests. Order of precedence: 46 + * 1. In-process marker (same-module calls; see `core/spaces/in-process.ts`) 47 + * 2. Authorization: Bearer <JWT> as an atproto service-auth token 47 48 * 48 - * If `authOverride` is provided and returns non-null claims, the JWT check is 49 - * skipped and the override's claims are attached instead — used to paper over 50 - * bsky.social's loopback-client restriction on `getServiceAuth` during dev. 51 - * The override must be gated in the caller (e.g. an env flag) since it 52 - * bypasses cryptographic verification. */ 49 + * On success, attaches the claims to `c.var.serviceAuth`. Expected Nsid is 50 + * taken from the route pattern (last segment after `/xrpc/`). */ 53 51 export function createServiceAuthMiddleware( 54 - verifier: ServiceJwtVerifier, 55 - options: { 56 - authOverride?: ( 57 - req: Request 58 - ) => Promise<import("./types").AuthOverrideResult | null> | import("./types").AuthOverrideResult | null; 59 - } = {} 52 + verifier: ServiceJwtVerifier 60 53 ): MiddlewareHandler { 61 54 return async (c, next) => { 62 55 const lxm = extractLxmFromPath(c); 63 56 64 - if (options.authOverride) { 65 - const override = await options.authOverride(c.req.raw); 66 - if (override) { 67 - c.set("serviceAuth", { 68 - issuer: override.issuer, 69 - audience: override.audience ?? "", 70 - lxm: override.lxm ?? lxm ?? undefined, 71 - clientId: override.clientId, 72 - } satisfies ServiceAuth); 73 - await next(); 74 - return; 75 - } 57 + const inProcess = readInProcess(c.req.raw); 58 + if (inProcess) { 59 + c.set("serviceAuth", { 60 + issuer: inProcess.did, 61 + audience: "", 62 + lxm: lxm ?? undefined, 63 + } satisfies ServiceAuth); 64 + await next(); 65 + return; 76 66 } 77 67 78 68 const header = c.req.header("Authorization"); ··· 109 99 return auth; 110 100 } 111 101 112 - /** Verify a request's Authorization: Bearer token out-of-band (e.g. from a 113 - * route handler that doesn't always require auth). Returns the claims on 114 - * success, or null if missing/invalid. 115 - * 116 - * If `authOverride` is provided and returns claims, the JWT path is skipped 117 - * and those claims are returned — same bypass as the middleware-level auth. */ 102 + /** Out-of-band auth check for handlers that don't always require auth. 103 + * Returns claims on success, or null if no valid credentials are present. 104 + * Order of precedence: in-process marker → service-auth JWT. */ 118 105 export async function verifyServiceAuthRequest( 119 106 verifier: ServiceJwtVerifier, 120 107 request: Request, 121 - lxm?: Nsid | null, 122 - options: { 123 - authOverride?: ( 124 - req: Request 125 - ) => Promise<import("./types").AuthOverrideResult | null> | import("./types").AuthOverrideResult | null; 126 - } = {} 108 + lxm?: Nsid | null 127 109 ): Promise<ServiceAuth | null> { 128 - if (options.authOverride) { 129 - const override = await options.authOverride(request); 130 - if (override) { 131 - return { 132 - issuer: override.issuer, 133 - audience: override.audience ?? "", 134 - lxm: override.lxm ?? lxm ?? undefined, 135 - clientId: override.clientId, 136 - }; 137 - } 110 + const inProcess = readInProcess(request); 111 + if (inProcess) { 112 + return { 113 + issuer: inProcess.did, 114 + audience: "", 115 + lxm: lxm ?? undefined, 116 + }; 138 117 } 139 118 140 119 const header = request.headers.get("Authorization");
+34
src/core/spaces/in-process.ts
··· 1 + /** In-process auth marker. 2 + * 3 + * For same-module callers (e.g. a SvelteKit worker that imports contrail and 4 + * dispatches requests directly to the handler), service-auth JWTs are pure 5 + * overhead: no network boundary is crossed, so there's nothing for the JWT to 6 + * protect against. Instead, the caller tags the `Request` with a principal via 7 + * a module-private WeakMap, and the auth middleware reads it back. 8 + * 9 + * Security note: this is unforgeable from outside the module because 10 + * - WeakMap keys are `Request` object identities, not serialized data; 11 + * - no HTTP request crossing a network boundary can reach into this map; 12 + * - exploiting it requires code execution inside the same isolate, at 13 + * which point auth is already game over. 14 + * 15 + * This is the strongest auth adapter contrail offers — it has no secret to 16 + * leak. */ 17 + 18 + export interface InProcessPrincipal { 19 + did: string; 20 + } 21 + 22 + const PRINCIPALS = new WeakMap<Request, InProcessPrincipal>(); 23 + 24 + /** Tag a Request with an in-process principal. The returned Request is the 25 + * same reference; the return value is for ergonomics. */ 26 + export function markInProcess(req: Request, did: string): Request { 27 + PRINCIPALS.set(req, { did }); 28 + return req; 29 + } 30 + 31 + /** Read the in-process principal for a Request, or null if unmarked. */ 32 + export function readInProcess(req: Request): InProcessPrincipal | null { 33 + return PRINCIPALS.get(req) ?? null; 34 + }
+1 -5
src/core/spaces/router.ts
··· 45 45 46 46 const adapter = options.adapter ?? ctx?.adapter ?? new HostedAdapter(db, config); 47 47 const verifier = ctx?.verifier ?? buildVerifier(spacesConfig); 48 - const auth = 49 - options.authMiddleware ?? 50 - createServiceAuthMiddleware(verifier, { 51 - authOverride: spacesConfig.authOverride, 52 - }); 48 + const auth = options.authMiddleware ?? createServiceAuthMiddleware(verifier); 53 49 54 50 /** Read-route auth: skip the JWT middleware when an `?inviteToken=` is 55 51 * present so anonymous bearer reads don't 401 before the route handler can
-18
src/core/spaces/types.ts
··· 37 37 resolver?: DidDocumentResolver; 38 38 /** Blob-upload backend. When omitted, blob XRPCs are not exposed. */ 39 39 blobs?: SpacesBlobsConfig; 40 - /** Dev-only auth bypass. Runs BEFORE JWT verification. If it returns 41 - * non-null claims, those are used as the authenticated caller and the 42 - * JWT check is skipped. Intended for local development where bsky.social 43 - * rejects `getServiceAuth` for loopback OAuth clients; pair with a 44 - * signed-cookie session check so only your own browser can trigger it. 45 - * Never set in production. */ 46 - authOverride?: ( 47 - req: Request 48 - ) => Promise<AuthOverrideResult | null> | AuthOverrideResult | null; 49 - } 50 - 51 - /** Claims returned by an `authOverride`. Matches the ServiceAuth shape the 52 - * normal JWT path attaches to the request. */ 53 - export interface AuthOverrideResult { 54 - issuer: string; 55 - audience?: string; 56 - lxm?: string; 57 - clientId?: string; 58 40 } 59 41 60 42 export interface SpaceRow {
+10 -12
examples/sveltekit-cloudflare-workers/src/lib/contrail/index.ts
··· 1 1 import { Contrail } from '@atmo-dev/contrail'; 2 - import { createHandler } from '@atmo-dev/contrail/server'; 3 - import { Client } from '@atcute/client'; 2 + import { createHandler, createServerClient } from '@atmo-dev/contrail/server'; 3 + import type { Client } from '@atcute/client'; 4 4 import { config } from './config'; 5 5 6 6 export const contrail = new Contrail(config); ··· 17 17 const handle = createHandler(contrail); 18 18 19 19 /** 20 - * Server-side: fully typed @atcute/client that routes through contrail in-process. 21 - * No HTTP roundtrip — calls createHandler directly. 20 + * Typed `@atcute/client` that calls contrail in-process. Pass `did` to act 21 + * as that user (server-side principal via WeakMap marker — no JWT, no PDS 22 + * roundtrip). Omit for anonymous calls against public endpoints. 22 23 */ 23 - export function getServerClient(db: D1Database) { 24 - return new Client({ 25 - handler: async (pathname, init) => { 26 - await ensureInit(db); 27 - const url = new URL(pathname, 'http://localhost'); 28 - return handle(new Request(url, init), db) as Promise<Response>; 29 - } 30 - }); 24 + export function getServerClient(db: D1Database, did?: string): Client { 25 + return createServerClient(async (req) => { 26 + await ensureInit(db); 27 + return handle(req, db) as Promise<Response>; 28 + }, did); 31 29 }
+14 -38
examples/sveltekit-group-chat/src/lib/contrail/index.ts
··· 10 10 type PubSub, 11 11 type R2BucketLike 12 12 } from '@atmo-dev/contrail'; 13 - import { createHandler } from '@atmo-dev/contrail/server'; 14 - import { Client } from '@atcute/client'; 13 + import { createHandler, createServerClient } from '@atmo-dev/contrail/server'; 14 + import type { Client } from '@atcute/client'; 15 + import { dev } from '$app/environment'; 15 16 import { baseConfig } from './config'; 16 - import { getSignedCookieFromRequest } from '$lib/atproto/server/signed-cookie'; 17 17 18 18 type Env = App.Platform['env']; 19 19 ··· 26 26 let cached: { env: Env; bundle: Bundle } | null = null; 27 27 28 28 function build(env: Env): Bundle { 29 - const devAuth = env.DEV_AUTH === '1'; 30 - 31 29 // In dev the DO class isn't wired (vite runs the worker directly, skipping 32 30 // the post-build re-export), so fall back to InMemoryPubSub. Single-isolate 33 31 // is fine for one-user dev; production uses the DO so publishes fan out 34 32 // across isolates. 35 - const pubsub: PubSub = devAuth 33 + const pubsub: PubSub = dev 36 34 ? new InMemoryPubSub() 37 35 : new DurableObjectPubSub(env.REALTIME as DurableObjectNamespace); 38 36 ··· 51 49 adapter: blobAdapter, 52 50 maxSize: 2 * 1024 * 1024, 53 51 accept: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] 54 - }, 55 - // Dev-only bypass: trust the HMAC-signed session cookie (or an 56 - // X-Dev-Did header for server-side synthetic requests) as auth, so 57 - // the loopback OAuth client doesn't need to round-trip through the 58 - // user's PDS for getServiceAuth (bsky.social rejects that on 59 - // loopback). In prod, DEV_AUTH is unset and every caller — browser 60 - // or third-party — must present a real atproto service-auth JWT. 61 - // Browsers get one by calling our SvelteKit helper which uses the 62 - // OAuth session to mint via the user's PDS. 63 - authOverride: devAuth 64 - ? (req: Request) => { 65 - const headerDid = req.headers.get('x-dev-did'); 66 - const did = headerDid ?? getSignedCookieFromRequest(req, 'did'); 67 - if (!did) return null; 68 - return { issuer: did, audience: env.SERVICE_DID }; 69 - } 70 - : undefined 52 + } 71 53 }, 72 54 community: { 73 55 masterKey: env.COMMUNITY_MASTER_KEY, ··· 107 89 return (await b.handle(req, env.DB)) as Response; 108 90 } 109 91 110 - /** Server-side typed @atcute client that routes through contrail in-process. 111 - * When `asDid` is set, every request carries an Authorization header identifying 112 - * the caller — this is used only for server-originated bootstrap calls (e.g. the 113 - * community mint flow) where we already have the user's DID from the OAuth session 114 - * but want to bypass the atproto PDS round-trip. For normal user actions, route 115 - * through the user's OAuth client instead. */ 116 - export function getServerClient(env: Env): Client { 117 - return new Client({ 118 - handler: async (pathname, init) => { 119 - const b = getBundle(env); 120 - await b.ready; 121 - const url = new URL(pathname, 'http://localhost'); 122 - return (await b.handle(new Request(url, init), env.DB)) as Response; 123 - } 124 - }); 92 + /** Typed `@atcute/client` that calls contrail in-process. Pass `did` to act 93 + * as that user (server-side principal via in-process WeakMap — no JWT, no 94 + * PDS roundtrip). Omit for anonymous calls against public endpoints. */ 95 + export function getServerClient(env: Env, did?: string): Client { 96 + return createServerClient(async (req) => { 97 + const b = getBundle(env); 98 + await b.ready; 99 + return (await b.handle(req, env.DB)) as Response; 100 + }, did); 125 101 }
+17
examples/sveltekit-group-chat/src/lib/rooms/rooms.remote.ts
··· 11 11 communityPutRecord, 12 12 grantAccess, 13 13 mintCommunity, 14 + mintWatchTicket, 14 15 spacePutRecord, 15 16 revokeAccess, 16 17 setAccessLevel, ··· 176 177 } 177 178 }); 178 179 return res; 180 + }); 181 + 182 + // --------------------------------------------------------------------------- 183 + // mintWatchTicket — browser calls on reconnect to get a fresh watch-scoped 184 + // ticket. The initial ticket is pre-minted in +page.server.ts. 185 + // --------------------------------------------------------------------------- 186 + 187 + const MintWatchTicketInput = v.object({ 188 + spaceUri: v.pipe(v.string(), v.minLength(1)), 189 + watchRecordsNsid: v.pipe(v.string(), v.minLength(1)), 190 + limit: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(200))) 191 + }); 192 + 193 + export const mintWatchTicketCmd = command(MintWatchTicketInput, async (input) => { 194 + const ctx = requireAuthed(); 195 + return mintWatchTicket(ctx, input); 179 196 }); 180 197 181 198 // ---------------------------------------------------------------------------
+42 -69
examples/sveltekit-group-chat/src/lib/rooms/server.ts
··· 1 1 /** Server-side helpers for calling contrail's authenticated XRPCs. 2 2 * 3 - * Contrail's community/space/realtime endpoints authenticate via atproto 4 - * service-auth JWTs. The caller's OAuth session on their PDS can mint those 5 - * JWTs via `com.atproto.server.getServiceAuth`. This module wraps that dance: 6 - * mint → dispatch → return the typed body. 7 - */ 3 + * All calls route through contrail in-process using the WeakMap-backed 4 + * in-process auth marker (see `@atmo-dev/contrail/server` → `markInProcess`). 5 + * No JWT minting, no PDS roundtrip — the principal is stamped onto the 6 + * Request by `ctx.did` and the auth middleware reads it directly. */ 8 7 9 - import type { Client } from '@atcute/client'; 8 + import { markInProcess } from '@atmo-dev/contrail/server'; 10 9 import { dispatch } from '$lib/contrail'; 11 10 12 11 type Env = App.Platform['env']; 13 12 14 13 interface AuthedCallContext { 15 14 env: Env; 16 - /** Caller's OAuth-authenticated atproto client (talks to their PDS). */ 17 - client: Client; 18 - /** Caller's DID. */ 15 + /** Caller's DID — the principal attributed to each request. */ 19 16 did: string; 20 17 } 21 18 22 - /** Authenticated fetch for page loaders — mints a service-auth JWT 23 - * (or takes the dev bypass) and dispatches to contrail in-process. 24 - * Returns the parsed JSON body, or throws with status + payload. */ 19 + /** Authenticated fetch for page loaders. Returns the parsed JSON body, 20 + * or throws with status + payload on non-2xx. */ 25 21 export async function authedFetch<T = unknown>( 26 22 ctx: AuthedCallContext, 27 23 method: string, ··· 30 26 return callContrail<T>(ctx, method, opts); 31 27 } 32 28 33 - async function mintServiceAuth( 34 - ctx: AuthedCallContext, 35 - lxm: string, 36 - expSeconds = 60 37 - ): Promise<string> { 38 - // Dev bypass: the contrail authOverride trusts our signed session cookie 39 - // instead of a real JWT, so we don't need to hit the PDS. Returns a sentinel 40 - // the override ignores (any string is fine — it's never verified). 41 - if (ctx.env.DEV_AUTH === '1') { 42 - return 'dev'; 43 - } 44 - const res = await ctx.client.get('com.atproto.server.getServiceAuth', { 45 - params: { 46 - aud: ctx.env.SERVICE_DID as `did:${string}:${string}`, 47 - lxm: lxm as `${string}.${string}.${string}`, 48 - exp: Math.floor(Date.now() / 1000) + expSeconds 49 - } 50 - }); 51 - if (!res.ok) { 52 - throw new Error(`getServiceAuth failed: ${JSON.stringify(res.data)}`); 53 - } 54 - const data = res.data as { token: string }; 55 - return data.token; 56 - } 57 - 58 29 async function callContrail<T = unknown>( 59 30 ctx: AuthedCallContext, 60 31 method: string, 61 32 opts: { body?: unknown; query?: Record<string, string>; httpMethod?: 'GET' | 'POST' } 62 33 ): Promise<T> { 63 - const jwt = await mintServiceAuth(ctx, method); 64 34 const url = new URL(`http://localhost/xrpc/${method}`); 65 35 for (const [k, v] of Object.entries(opts.query ?? {})) { 66 36 url.searchParams.set(k, v); 67 37 } 68 38 const httpMethod = opts.httpMethod ?? (opts.body === undefined ? 'GET' : 'POST'); 69 - const devAuth = ctx.env.DEV_AUTH === '1'; 70 - const headers: Record<string, string> = { 71 - Authorization: `Bearer ${jwt}`, 72 - ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}) 73 - }; 74 - if (devAuth) headers['X-Dev-Did'] = ctx.did; 75 39 const req = new Request(url, { 76 40 method: httpMethod, 77 - headers, 41 + headers: opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}, 78 42 body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined 79 43 }); 44 + markInProcess(req, ctx.did); 80 45 const res = await dispatch(req, ctx.env); 81 46 const text = await res.text(); 82 47 const json = text ? JSON.parse(text) : {}; ··· 225 190 return callContrail(ctx, 'tools.atmo.chat.realtime.ticket', { body: input }); 226 191 } 227 192 193 + /** Mint a watch-scoped ticket for a `<collection>.watchRecords` stream by 194 + * running the `mode=ws` handshake in-process. The response's `ticket` carries 195 + * the (did, topic, querySpec) binding that watchRecords verifies on SSE or 196 + * WS connect, so the browser can attach it as `?ticket=...` with no other 197 + * auth. The snapshot body is discarded — the sync engine will run its own 198 + * snapshot on the ticketed connection. */ 199 + export async function mintWatchTicket( 200 + ctx: AuthedCallContext, 201 + input: { watchRecordsNsid: string; spaceUri: string; limit?: number } 202 + ): Promise<{ ticket: string; expiresAt: number }> { 203 + const url = new URL(`http://localhost/xrpc/${input.watchRecordsNsid}`); 204 + url.searchParams.set('spaceUri', input.spaceUri); 205 + url.searchParams.set('mode', 'ws'); 206 + if (input.limit) url.searchParams.set('limit', String(input.limit)); 207 + const req = new Request(url, { headers: { accept: 'application/json' } }); 208 + markInProcess(req, ctx.did); 209 + const res = await dispatch(req, ctx.env); 210 + const text = await res.text(); 211 + if (!res.ok) throw new Error(`mintWatchTicket failed (${res.status}): ${text}`); 212 + const data = JSON.parse(text) as { ticket?: string; ticketTtlMs?: number }; 213 + if (!data.ticket) throw new Error('mintWatchTicket: handshake did not return a ticket'); 214 + return { 215 + ticket: data.ticket, 216 + expiresAt: Date.now() + (data.ticketTtlMs ?? 120_000) 217 + }; 218 + } 219 + 228 220 // --- invites --------------------------------------------------------------- 229 221 230 222 export interface InviteView { ··· 301 293 input: { spaceUri: string; mimeType: string; bytes: Uint8Array } 302 294 ): Promise<BlobRef> { 303 295 const method = 'tools.atmo.chat.space.uploadBlob'; 304 - const jwt = ctx.env.DEV_AUTH === '1' ? 'dev' : await mintServiceAuthForUpload(ctx, method); 305 296 const url = new URL(`http://localhost/xrpc/${method}`); 306 297 url.searchParams.set('spaceUri', input.spaceUri); 307 - const headers: Record<string, string> = { 308 - Authorization: `Bearer ${jwt}`, 309 - 'Content-Type': input.mimeType, 310 - 'Content-Length': String(input.bytes.byteLength) 311 - }; 312 - if (ctx.env.DEV_AUTH === '1') headers['X-Dev-Did'] = ctx.did; 313 298 314 299 const req = new Request(url, { 315 300 method: 'POST', 316 - headers, 301 + headers: { 302 + 'Content-Type': input.mimeType, 303 + 'Content-Length': String(input.bytes.byteLength) 304 + }, 317 305 body: new Blob([new Uint8Array(input.bytes)], { type: input.mimeType }) 318 306 }); 307 + markInProcess(req, ctx.did); 319 308 const res = await dispatch(req, ctx.env); 320 309 const text = await res.text(); 321 310 const json = text ? JSON.parse(text) : {}; 322 311 if (!res.ok) throw new Error(`${method} failed (${res.status}): ${text}`); 323 312 return (json as { blob: BlobRef }).blob; 324 - } 325 - 326 - async function mintServiceAuthForUpload( 327 - ctx: AuthedCallContext, 328 - lxm: string, 329 - expSeconds = 60 330 - ): Promise<string> { 331 - const res = await ctx.client.get('com.atproto.server.getServiceAuth', { 332 - params: { 333 - aud: ctx.env.SERVICE_DID as `did:${string}:${string}`, 334 - lxm: lxm as `${string}.${string}.${string}`, 335 - exp: Math.floor(Date.now() / 1000) + expSeconds 336 - } 337 - }); 338 - if (!res.ok) throw new Error('getServiceAuth failed'); 339 - return (res.data as { token: string }).token; 340 313 }
-37
examples/sveltekit-group-chat/src/lib/atproto/server/signed-cookie.ts
··· 67 67 const signed = `${value}${SEPARATOR}${sig}`; 68 68 cookies.set(name, signed, options); 69 69 } 70 - 71 - /** Verify + read a signed cookie from a raw Request's Cookie header. 72 - * Used from places that don't have a SvelteKit `Cookies` instance 73 - * (e.g. contrail auth override). */ 74 - export function getSignedCookieFromRequest(req: Request, name: string): string | null { 75 - const header = req.headers.get('cookie'); 76 - if (!header) return null; 77 - const pairs = header.split(/;\s*/); 78 - let raw: string | null = null; 79 - for (const pair of pairs) { 80 - const eq = pair.indexOf('='); 81 - if (eq === -1) continue; 82 - const key = pair.slice(0, eq); 83 - if (key === name) { 84 - raw = decodeURIComponent(pair.slice(eq + 1)); 85 - break; 86 - } 87 - } 88 - if (!raw) return null; 89 - 90 - const idx = raw.lastIndexOf(SEPARATOR); 91 - if (idx === -1) return null; 92 - const value = raw.slice(0, idx); 93 - const sig = raw.slice(idx + 1); 94 - 95 - let expected: Uint8Array; 96 - let got: Uint8Array; 97 - try { 98 - expected = hmacSha256(value); 99 - got = fromBase64Url(sig); 100 - } catch { 101 - return null; 102 - } 103 - 104 - if (got.length !== expected.length || !timingSafeEqual(got, expected)) return null; 105 - return value; 106 - }
+2 -3
examples/sveltekit-group-chat/src/routes/api/blob-upload/+server.ts
··· 1 1 import { error, json } from '@sveltejs/kit'; 2 2 import type { RequestHandler } from './$types'; 3 - import type { Client } from '@atcute/client'; 4 3 import { uploadBlob } from '$lib/rooms/server'; 5 4 6 5 /** POST raw bytes (body = bytes) with ?spaceUri=<uri>&mimeType=<type>. ··· 8 7 * Plain +server.ts so we can pass binary without remote-function JSON schema, 9 8 * and so there's no auto-revalidation. */ 10 9 export const POST: RequestHandler = async ({ request, url, locals, platform }) => { 11 - if (!locals.did || !locals.client) error(401, 'Not authenticated'); 10 + if (!locals.did) error(401, 'Not authenticated'); 12 11 const spaceUri = url.searchParams.get('spaceUri'); 13 12 const mimeType = url.searchParams.get('mimeType') ?? request.headers.get('content-type'); 14 13 if (!spaceUri) error(400, 'spaceUri required'); ··· 16 15 17 16 const bytes = new Uint8Array(await request.arrayBuffer()); 18 17 const blob = await uploadBlob( 19 - { env: platform!.env, client: locals.client as Client, did: locals.did as string }, 18 + { env: platform!.env, did: locals.did as string }, 20 19 { spaceUri, mimeType, bytes } 21 20 ); 22 21 return json(blob);
+9 -25
examples/sveltekit-group-chat/src/routes/api/blob/+server.ts
··· 1 1 import { error } from '@sveltejs/kit'; 2 2 import type { RequestHandler } from './$types'; 3 + import { markInProcess } from '@atmo-dev/contrail/server'; 3 4 import { dispatch } from '$lib/contrail'; 4 - import { getSignedCookie } from '$lib/atproto/server/signed-cookie'; 5 5 6 6 /** GET ?spaceUri=<uri>&cid=<cid> → blob bytes. 7 - * Proxies through contrail's space.getBlob endpoint with the caller's 8 - * (cookie-based in dev / JWT in prod) auth attached. */ 9 - export const GET: RequestHandler = async ({ url, locals, platform, request, cookies }) => { 7 + * Proxies through contrail's space.getBlob endpoint, attributing the call 8 + * to the session's DID via the in-process auth marker. Browsers can't 9 + * attach headers to `<img src>`, which is why this proxy exists. */ 10 + export const GET: RequestHandler = async ({ url, locals, platform }) => { 11 + if (!locals.did) error(401, 'Not authenticated'); 10 12 const spaceUri = url.searchParams.get('spaceUri'); 11 13 const cid = url.searchParams.get('cid'); 12 14 if (!spaceUri || !cid) error(400, 'spaceUri and cid required'); ··· 15 17 target.searchParams.set('spaceUri', spaceUri); 16 18 target.searchParams.set('cid', cid); 17 19 18 - // Forward the session cookie so dev auth override works; in prod the 19 - // caller's OAuth session can mint a real JWT — but for <img src> we can't 20 - // attach headers from the browser, so we mint server-side. 21 - const headers: Record<string, string> = {}; 22 - if (platform!.env.DEV_AUTH === '1') { 23 - const did = getSignedCookie(cookies, 'did'); 24 - if (did) headers['X-Dev-Did'] = did; 25 - } else { 26 - if (!locals.client || !locals.did) error(401, 'Not authenticated'); 27 - const authRes = await locals.client.get('com.atproto.server.getServiceAuth', { 28 - params: { 29 - aud: platform!.env.SERVICE_DID as `did:${string}:${string}`, 30 - lxm: 'tools.atmo.chat.space.getBlob' as `${string}.${string}.${string}`, 31 - exp: Math.floor(Date.now() / 1000) + 120 32 - } 33 - }); 34 - if (authRes.ok) headers.Authorization = `Bearer ${(authRes.data as { token: string }).token}`; 35 - } 36 - 37 - const proxied = await dispatch(new Request(target, { headers }), platform!.env); 38 - // Pass through status + body + content-type. 20 + const req = new Request(target); 21 + markInProcess(req, locals.did); 22 + const proxied = await dispatch(req, platform!.env); 39 23 return new Response(proxied.body, { 40 24 status: proxied.status, 41 25 headers: {
+2 -7
examples/sveltekit-group-chat/src/routes/api/ticket/+server.ts
··· 1 1 import { error, json } from '@sveltejs/kit'; 2 2 import type { RequestHandler } from './$types'; 3 - import type { Client } from '@atcute/client'; 4 3 import { getRealtimeTicket } from '$lib/rooms/server'; 5 4 6 5 /** POST { topic } → realtime ticket. ··· 8 7 * calling it doesn't trigger SvelteKit's auto-revalidation (which would 9 8 * cycle with the layout effect that opens the EventSource). */ 10 9 export const POST: RequestHandler = async ({ request, locals, platform }) => { 11 - if (!locals.did || !locals.client) error(401, 'Not authenticated'); 10 + if (!locals.did) error(401, 'Not authenticated'); 12 11 const body = (await request.json().catch(() => null)) as { topic?: string } | null; 13 12 if (!body?.topic) error(400, 'topic required'); 14 13 15 14 const res = await getRealtimeTicket( 16 - { 17 - env: platform!.env, 18 - client: locals.client as Client, 19 - did: locals.did as string 20 - }, 15 + { env: platform!.env, did: locals.did as string }, 21 16 { topic: body.topic } 22 17 ); 23 18 return json(res);
-25
examples/sveltekit-group-chat/src/routes/api/watch-token/+server.ts
··· 1 - import { error, json } from '@sveltejs/kit'; 2 - import type { RequestHandler } from './$types'; 3 - 4 - /** POST { lxm } → { token } — mint an atproto service-auth JWT scoped to one 5 - * XRPC method, using the caller's OAuth session to call getServiceAuth on 6 - * their PDS. Used by the browser sync engine to auth the watchRecords 7 - * handshake fetch — it can't mint JWTs itself, but same-origin to us it 8 - * can delegate. 9 - * 10 - * Cross-origin apps skip this and mint their own JWTs server-side. */ 11 - export const POST: RequestHandler = async ({ request, locals, platform }) => { 12 - if (!locals.did || !locals.client) error(401, 'Not authenticated'); 13 - const body = (await request.json().catch(() => null)) as { lxm?: string } | null; 14 - if (!body?.lxm) error(400, 'lxm required'); 15 - 16 - const res = await locals.client.get('com.atproto.server.getServiceAuth', { 17 - params: { 18 - aud: platform!.env.SERVICE_DID as `did:${string}:${string}`, 19 - lxm: body.lxm as `${string}.${string}.${string}`, 20 - exp: Math.floor(Date.now() / 1000) + 120 21 - } 22 - }); 23 - if (!res.ok) error(502, `getServiceAuth failed: ${JSON.stringify(res.data)}`); 24 - return json({ token: (res.data as { token: string }).token }); 25 - };
+26 -3
examples/sveltekit-group-chat/src/routes/c/[communityDid]/[channelKey]/+page.server.ts
··· 1 1 import type { PageServerLoad } from './$types'; 2 2 import { error } from '@sveltejs/kit'; 3 3 import { buildSpaceUri } from '$lib/rooms/uri'; 4 + import { mintWatchTicket } from '$lib/rooms/server'; 4 5 5 - export const load: PageServerLoad = async ({ locals, params }) => { 6 - if (!locals.did || !locals.client) error(401, 'Not authenticated'); 6 + const WATCH_RECORDS_NSID = 'tools.atmo.chat.message.watchRecords'; 7 + 8 + export const load: PageServerLoad = async ({ locals, params, platform }) => { 9 + if (!locals.did) error(401, 'Not authenticated'); 7 10 const communityDid = decodeURIComponent(params.communityDid); 8 11 const spaceUri = buildSpaceUri(communityDid, params.channelKey); 9 - return { spaceUri, channelKey: params.channelKey, myDid: locals.did }; 12 + 13 + // Mint the first watch-scoped ticket alongside the page data so the browser 14 + // can open the stream on first paint without a second roundtrip. Reconnects 15 + // call the `mintWatchTicket` remote function. 16 + let initialTicket: string | null = null; 17 + try { 18 + const t = await mintWatchTicket( 19 + { env: platform!.env, did: locals.did as string }, 20 + { watchRecordsNsid: WATCH_RECORDS_NSID, spaceUri, limit: 50 } 21 + ); 22 + initialTicket = t.ticket; 23 + } catch { 24 + // Non-fatal: the browser will mint on demand via the remote function. 25 + } 26 + 27 + return { 28 + spaceUri, 29 + channelKey: params.channelKey, 30 + myDid: locals.did, 31 + initialTicket 32 + }; 10 33 };
+21 -14
examples/sveltekit-group-chat/src/routes/c/[communityDid]/[channelKey]/+page.svelte
··· 2 2 import { tick } from 'svelte'; 3 3 import { Button, Input, Navbar } from '@foxui/core'; 4 4 import { RelativeTime } from '@foxui/time'; 5 - import { postMessage } from '$lib/rooms/rooms.remote'; 5 + import { postMessage, mintWatchTicketCmd } from '$lib/rooms/rooms.remote'; 6 6 import { setCurrentChannel } from '$lib/rooms/realtime.svelte'; 7 7 import { markLastRead } from '$lib/rooms/unread.svelte'; 8 8 import { displayName } from '$lib/rooms/profiles.svelte'; ··· 10 10 import type { WatchRecord } from '@atmo-dev/contrail/sync'; 11 11 import { dev } from '$app/environment'; 12 12 import { setConnectionStatus, resetConnectionStatus } from '$lib/rooms/connection.svelte'; 13 + 14 + const WATCH_RECORDS_NSID = 'tools.atmo.chat.message.watchRecords'; 13 15 14 16 let { data } = $props(); 15 17 ··· 32 34 if (uri === currentUri) return; 33 35 query?.stop(); 34 36 currentUri = uri; 37 + 38 + // First connect reuses the ticket minted during SSR (landed on page 39 + // data). Reconnects mint a fresh one via the remote function. 40 + let pending: string | null = data.initialTicket; 35 41 query = createWatchQuery({ 36 - url: `/xrpc/tools.atmo.chat.message.watchRecords?spaceUri=${encodeURIComponent(uri)}&limit=50`, 42 + url: `/xrpc/${WATCH_RECORDS_NSID}?spaceUri=${encodeURIComponent(uri)}&limit=50`, 37 43 transport: dev ? 'sse' : 'ws', 38 - fetchAuthToken: dev 39 - ? undefined 40 - : async () => { 41 - const res = await fetch('/api/watch-token', { 42 - method: 'POST', 43 - headers: { 'Content-Type': 'application/json' }, 44 - body: JSON.stringify({ lxm: 'tools.atmo.chat.message.watchRecords' }) 45 - }); 46 - if (!res.ok) throw new Error(`watch-token mint failed: ${res.status}`); 47 - const d = (await res.json()) as { token: string }; 48 - return d.token; 49 - }, 44 + fetchTicket: async () => { 45 + if (pending) { 46 + const t = pending; 47 + pending = null; 48 + return t; 49 + } 50 + const res = await mintWatchTicketCmd({ 51 + spaceUri: uri, 52 + watchRecordsNsid: WATCH_RECORDS_NSID, 53 + limit: 50 54 + }); 55 + return res.ticket; 56 + }, 50 57 compareRecords: (a: WatchRecord, b: WatchRecord) => 51 58 (a.time_us ?? 0) - (b.time_us ?? 0) 52 59 });
+2 -7
examples/sveltekit-group-chat/src/routes/c/[communityDid]/settings/invites/+page.server.ts
··· 1 1 import type { PageServerLoad } from './$types'; 2 2 import { error } from '@sveltejs/kit'; 3 - import type { Client } from '@atcute/client'; 4 3 import { listCommunityInvites } from '$lib/rooms/server'; 5 4 import { buildMembersUri } from '$lib/rooms/uri'; 6 5 7 6 export const load: PageServerLoad = async ({ locals, params, platform }) => { 8 - if (!locals.did || !locals.client) error(401, 'Not authenticated'); 7 + if (!locals.did) error(401, 'Not authenticated'); 9 8 const communityDid = decodeURIComponent(params.communityDid); 10 9 const membersUri = buildMembersUri(communityDid); 11 10 ··· 23 22 }> = []; 24 23 try { 25 24 const res = await listCommunityInvites( 26 - { 27 - env: platform!.env, 28 - client: locals.client as Client, 29 - did: locals.did as string 30 - }, 25 + { env: platform!.env, did: locals.did as string }, 31 26 { spaceUri: membersUri, includeRevoked: true } 32 27 ); 33 28 invites = res.invites.map((r) => ({