···11+import { Client } from "@atcute/client";
12import type { Contrail } from "./contrail";
23import type { Database } from "./core/types";
44+import { markInProcess } from "./core/spaces/in-process";
3546/**
57 * Create an HTTP handler from a Contrail instance.
···2729 return cached(request);
2830 };
2931}
3232+3333+/**
3434+ * Fully typed `@atcute/client` Client that routes XRPC calls through a
3535+ * contrail handler in-process — no HTTP roundtrip, no JWT minting.
3636+ *
3737+ * Pass `did` to act as that user; omit for anonymous calls (public endpoints
3838+ * only). Authentication is via the in-process WeakMap marker, which is
3939+ * unforgeable across network boundaries.
4040+ *
4141+ * // Same-process on Cloudflare Workers (per-request DB):
4242+ * const client = createServerClient(
4343+ * (req) => contrail.handler({ db: env.DB })(req),
4444+ * session.did,
4545+ * );
4646+ * await client.post('tools.atmo.chat.space.putRecord', { input: { ... } });
4747+ */
4848+export function createServerClient(
4949+ handle: (req: Request) => Promise<Response>,
5050+ did?: string
5151+): Client {
5252+ return new Client({
5353+ handler: async (pathname, init) => {
5454+ const req = new Request(new URL(pathname, "http://localhost"), init);
5555+ if (did !== undefined) markInProcess(req, did);
5656+ return handle(req);
5757+ },
5858+ });
5959+}
6060+6161+export { markInProcess };
+12-21
src/sync/index.ts
···3333 /** Transport. Default: 'sse'. Use 'ws' on Cloudflare for DO-terminated
3434 * long-lived subscriptions that hibernate while idle. */
3535 transport?: "sse" | "ws";
3636- /** Optional: provide a ticket string (for `?ticket=...`-style auth).
3737- * When omitted, the connection is opened without an explicit token —
3838- * rely on cookies / bearer tokens your fetch policy sends automatically. */
3636+ /** Mint a watch-scoped ticket to auth the connection. Called once per
3737+ * connect attempt; the returned ticket is sent as `?ticket=...` on the
3838+ * SSE connection or on the `mode=ws` handshake fetch. Omit for public
3939+ * (no-auth) endpoints.
4040+ *
4141+ * For atproto-based services, tickets are typically minted server-side
4242+ * via `<ns>.realtime.ticket` (or via an app-specific route that runs
4343+ * the `mode=ws` handshake in-process and returns the signed ticket). */
3944 fetchTicket?: () => Promise<string>;
4040- /** Optional: mint a short-lived bearer token (typically an atproto
4141- * service-auth JWT scoped to `<collection>.watchRecords`). Called once
4242- * per connect attempt; returned token is sent as `Authorization: Bearer`
4343- * on the handshake fetch (`mode=ws`) or on the SSE connection via a
4444- * `?auth=` query param the server doesn't use — browsers can't set
4545- * headers on EventSource, so for SSE the app should cookie-auth or
4646- * fall back to `fetchTicket`. */
4747- fetchAuthToken?: () => Promise<string>;
4845 /** Custom compare. Default: sort by `time_us` descending (newest first),
4946 * tie-breaking by rkey. */
5047 compareRecords?: (a: WatchRecord, b: WatchRecord) => number;
···312309 };
313310314311 const openWs = async () => {
315315- // Step 1: snapshot + handshake. Authenticated by Bearer token from
316316- // `fetchAuthToken` (an atproto service-auth JWT — browsers get this
317317- // from a same-origin helper that uses the OAuth session). The server
312312+ // Step 1: snapshot + handshake. Authenticated by the watch-scoped
313313+ // ticket from `fetchTicket`, passed as `?ticket=...`. The server
318314 // returns a ticket bound to (did, spaceUri, querySpec); we pass that
319315 // back embedded in the wsUrl on the WS upgrade so the WS itself
320320- // doesn't need a JWT (EventSource/WebSocket can't set headers).
316316+ // doesn't need any other auth.
321317 let snapshotUrl = options.url;
322318 const sep = snapshotUrl.includes("?") ? "&" : "?";
323319 snapshotUrl += `${sep}mode=ws`;
···325321 const ticket = await options.fetchTicket();
326322 snapshotUrl += `&ticket=${encodeURIComponent(ticket)}`;
327323 }
328328- const headers: Record<string, string> = { accept: "application/json" };
329329- if (options.fetchAuthToken) {
330330- const token = await options.fetchAuthToken();
331331- headers.authorization = `Bearer ${token}`;
332332- }
333333- const res = await fetch(snapshotUrl, { headers });
324324+ const res = await fetch(snapshotUrl, { headers: { accept: "application/json" } });
334325 if (!res.ok) throw new Error(`snapshot fetch failed (${res.status})`);
335326 const handshake = (await res.json()) as {
336327 transport: "ws";
···88} from "@atcute/identity-resolver";
99import type { Did, Nsid } from "@atcute/lexicons";
1010import type { SpacesConfig } from "./types";
1111+import { readInProcess } from "./in-process";
11121213export { ServiceJwtVerifier };
1314···4142 resolver: DidDocumentResolver;
4243}
43444444-/** Hono middleware that verifies the Authorization: Bearer <JWT> as an atproto
4545- * service-auth token. On success, attaches the decoded claims to c.var.serviceAuth.
4646- * Expected Nsid method is taken from the route pattern (last segment after /xrpc/).
4545+/** Hono middleware that authenticates XRPC requests. Order of precedence:
4646+ * 1. In-process marker (same-module calls; see `core/spaces/in-process.ts`)
4747+ * 2. Authorization: Bearer <JWT> as an atproto service-auth token
4748 *
4848- * If `authOverride` is provided and returns non-null claims, the JWT check is
4949- * skipped and the override's claims are attached instead — used to paper over
5050- * bsky.social's loopback-client restriction on `getServiceAuth` during dev.
5151- * The override must be gated in the caller (e.g. an env flag) since it
5252- * bypasses cryptographic verification. */
4949+ * On success, attaches the claims to `c.var.serviceAuth`. Expected Nsid is
5050+ * taken from the route pattern (last segment after `/xrpc/`). */
5351export function createServiceAuthMiddleware(
5454- verifier: ServiceJwtVerifier,
5555- options: {
5656- authOverride?: (
5757- req: Request
5858- ) => Promise<import("./types").AuthOverrideResult | null> | import("./types").AuthOverrideResult | null;
5959- } = {}
5252+ verifier: ServiceJwtVerifier
6053): MiddlewareHandler {
6154 return async (c, next) => {
6255 const lxm = extractLxmFromPath(c);
63566464- if (options.authOverride) {
6565- const override = await options.authOverride(c.req.raw);
6666- if (override) {
6767- c.set("serviceAuth", {
6868- issuer: override.issuer,
6969- audience: override.audience ?? "",
7070- lxm: override.lxm ?? lxm ?? undefined,
7171- clientId: override.clientId,
7272- } satisfies ServiceAuth);
7373- await next();
7474- return;
7575- }
5757+ const inProcess = readInProcess(c.req.raw);
5858+ if (inProcess) {
5959+ c.set("serviceAuth", {
6060+ issuer: inProcess.did,
6161+ audience: "",
6262+ lxm: lxm ?? undefined,
6363+ } satisfies ServiceAuth);
6464+ await next();
6565+ return;
7666 }
77677868 const header = c.req.header("Authorization");
···10999 return auth;
110100}
111101112112-/** Verify a request's Authorization: Bearer token out-of-band (e.g. from a
113113- * route handler that doesn't always require auth). Returns the claims on
114114- * success, or null if missing/invalid.
115115- *
116116- * If `authOverride` is provided and returns claims, the JWT path is skipped
117117- * and those claims are returned — same bypass as the middleware-level auth. */
102102+/** Out-of-band auth check for handlers that don't always require auth.
103103+ * Returns claims on success, or null if no valid credentials are present.
104104+ * Order of precedence: in-process marker → service-auth JWT. */
118105export async function verifyServiceAuthRequest(
119106 verifier: ServiceJwtVerifier,
120107 request: Request,
121121- lxm?: Nsid | null,
122122- options: {
123123- authOverride?: (
124124- req: Request
125125- ) => Promise<import("./types").AuthOverrideResult | null> | import("./types").AuthOverrideResult | null;
126126- } = {}
108108+ lxm?: Nsid | null
127109): Promise<ServiceAuth | null> {
128128- if (options.authOverride) {
129129- const override = await options.authOverride(request);
130130- if (override) {
131131- return {
132132- issuer: override.issuer,
133133- audience: override.audience ?? "",
134134- lxm: override.lxm ?? lxm ?? undefined,
135135- clientId: override.clientId,
136136- };
137137- }
110110+ const inProcess = readInProcess(request);
111111+ if (inProcess) {
112112+ return {
113113+ issuer: inProcess.did,
114114+ audience: "",
115115+ lxm: lxm ?? undefined,
116116+ };
138117 }
139118140119 const header = request.headers.get("Authorization");
+34
src/core/spaces/in-process.ts
···11+/** In-process auth marker.
22+ *
33+ * For same-module callers (e.g. a SvelteKit worker that imports contrail and
44+ * dispatches requests directly to the handler), service-auth JWTs are pure
55+ * overhead: no network boundary is crossed, so there's nothing for the JWT to
66+ * protect against. Instead, the caller tags the `Request` with a principal via
77+ * a module-private WeakMap, and the auth middleware reads it back.
88+ *
99+ * Security note: this is unforgeable from outside the module because
1010+ * - WeakMap keys are `Request` object identities, not serialized data;
1111+ * - no HTTP request crossing a network boundary can reach into this map;
1212+ * - exploiting it requires code execution inside the same isolate, at
1313+ * which point auth is already game over.
1414+ *
1515+ * This is the strongest auth adapter contrail offers — it has no secret to
1616+ * leak. */
1717+1818+export interface InProcessPrincipal {
1919+ did: string;
2020+}
2121+2222+const PRINCIPALS = new WeakMap<Request, InProcessPrincipal>();
2323+2424+/** Tag a Request with an in-process principal. The returned Request is the
2525+ * same reference; the return value is for ergonomics. */
2626+export function markInProcess(req: Request, did: string): Request {
2727+ PRINCIPALS.set(req, { did });
2828+ return req;
2929+}
3030+3131+/** Read the in-process principal for a Request, or null if unmarked. */
3232+export function readInProcess(req: Request): InProcessPrincipal | null {
3333+ return PRINCIPALS.get(req) ?? null;
3434+}
+1-5
src/core/spaces/router.ts
···45454646 const adapter = options.adapter ?? ctx?.adapter ?? new HostedAdapter(db, config);
4747 const verifier = ctx?.verifier ?? buildVerifier(spacesConfig);
4848- const auth =
4949- options.authMiddleware ??
5050- createServiceAuthMiddleware(verifier, {
5151- authOverride: spacesConfig.authOverride,
5252- });
4848+ const auth = options.authMiddleware ?? createServiceAuthMiddleware(verifier);
53495450 /** Read-route auth: skip the JWT middleware when an `?inviteToken=` is
5551 * present so anonymous bearer reads don't 401 before the route handler can
-18
src/core/spaces/types.ts
···3737 resolver?: DidDocumentResolver;
3838 /** Blob-upload backend. When omitted, blob XRPCs are not exposed. */
3939 blobs?: SpacesBlobsConfig;
4040- /** Dev-only auth bypass. Runs BEFORE JWT verification. If it returns
4141- * non-null claims, those are used as the authenticated caller and the
4242- * JWT check is skipped. Intended for local development where bsky.social
4343- * rejects `getServiceAuth` for loopback OAuth clients; pair with a
4444- * signed-cookie session check so only your own browser can trigger it.
4545- * Never set in production. */
4646- authOverride?: (
4747- req: Request
4848- ) => Promise<AuthOverrideResult | null> | AuthOverrideResult | null;
4949-}
5050-5151-/** Claims returned by an `authOverride`. Matches the ServiceAuth shape the
5252- * normal JWT path attaches to the request. */
5353-export interface AuthOverrideResult {
5454- issuer: string;
5555- audience?: string;
5656- lxm?: string;
5757- clientId?: string;
5840}
59416042export interface SpaceRow {
···1010 type PubSub,
1111 type R2BucketLike
1212} from '@atmo-dev/contrail';
1313-import { createHandler } from '@atmo-dev/contrail/server';
1414-import { Client } from '@atcute/client';
1313+import { createHandler, createServerClient } from '@atmo-dev/contrail/server';
1414+import type { Client } from '@atcute/client';
1515+import { dev } from '$app/environment';
1516import { baseConfig } from './config';
1616-import { getSignedCookieFromRequest } from '$lib/atproto/server/signed-cookie';
17171818type Env = App.Platform['env'];
1919···2626let cached: { env: Env; bundle: Bundle } | null = null;
27272828function build(env: Env): Bundle {
2929- const devAuth = env.DEV_AUTH === '1';
3030-3129 // In dev the DO class isn't wired (vite runs the worker directly, skipping
3230 // the post-build re-export), so fall back to InMemoryPubSub. Single-isolate
3331 // is fine for one-user dev; production uses the DO so publishes fan out
3432 // across isolates.
3535- const pubsub: PubSub = devAuth
3333+ const pubsub: PubSub = dev
3634 ? new InMemoryPubSub()
3735 : new DurableObjectPubSub(env.REALTIME as DurableObjectNamespace);
3836···5149 adapter: blobAdapter,
5250 maxSize: 2 * 1024 * 1024,
5351 accept: ['image/png', 'image/jpeg', 'image/webp', 'image/gif']
5454- },
5555- // Dev-only bypass: trust the HMAC-signed session cookie (or an
5656- // X-Dev-Did header for server-side synthetic requests) as auth, so
5757- // the loopback OAuth client doesn't need to round-trip through the
5858- // user's PDS for getServiceAuth (bsky.social rejects that on
5959- // loopback). In prod, DEV_AUTH is unset and every caller — browser
6060- // or third-party — must present a real atproto service-auth JWT.
6161- // Browsers get one by calling our SvelteKit helper which uses the
6262- // OAuth session to mint via the user's PDS.
6363- authOverride: devAuth
6464- ? (req: Request) => {
6565- const headerDid = req.headers.get('x-dev-did');
6666- const did = headerDid ?? getSignedCookieFromRequest(req, 'did');
6767- if (!did) return null;
6868- return { issuer: did, audience: env.SERVICE_DID };
6969- }
7070- : undefined
5252+ }
7153 },
7254 community: {
7355 masterKey: env.COMMUNITY_MASTER_KEY,
···10789 return (await b.handle(req, env.DB)) as Response;
10890}
10991110110-/** Server-side typed @atcute client that routes through contrail in-process.
111111- * When `asDid` is set, every request carries an Authorization header identifying
112112- * the caller — this is used only for server-originated bootstrap calls (e.g. the
113113- * community mint flow) where we already have the user's DID from the OAuth session
114114- * but want to bypass the atproto PDS round-trip. For normal user actions, route
115115- * through the user's OAuth client instead. */
116116-export function getServerClient(env: Env): Client {
117117- return new Client({
118118- handler: async (pathname, init) => {
119119- const b = getBundle(env);
120120- await b.ready;
121121- const url = new URL(pathname, 'http://localhost');
122122- return (await b.handle(new Request(url, init), env.DB)) as Response;
123123- }
124124- });
9292+/** Typed `@atcute/client` that calls contrail in-process. Pass `did` to act
9393+ * as that user (server-side principal via in-process WeakMap — no JWT, no
9494+ * PDS roundtrip). Omit for anonymous calls against public endpoints. */
9595+export function getServerClient(env: Env, did?: string): Client {
9696+ return createServerClient(async (req) => {
9797+ const b = getBundle(env);
9898+ await b.ready;
9999+ return (await b.handle(req, env.DB)) as Response;
100100+ }, did);
125101}
···11-import { error, json } from '@sveltejs/kit';
22-import type { RequestHandler } from './$types';
33-44-/** POST { lxm } → { token } — mint an atproto service-auth JWT scoped to one
55- * XRPC method, using the caller's OAuth session to call getServiceAuth on
66- * their PDS. Used by the browser sync engine to auth the watchRecords
77- * handshake fetch — it can't mint JWTs itself, but same-origin to us it
88- * can delegate.
99- *
1010- * Cross-origin apps skip this and mint their own JWTs server-side. */
1111-export const POST: RequestHandler = async ({ request, locals, platform }) => {
1212- if (!locals.did || !locals.client) error(401, 'Not authenticated');
1313- const body = (await request.json().catch(() => null)) as { lxm?: string } | null;
1414- if (!body?.lxm) error(400, 'lxm required');
1515-1616- const res = await locals.client.get('com.atproto.server.getServiceAuth', {
1717- params: {
1818- aud: platform!.env.SERVICE_DID as `did:${string}:${string}`,
1919- lxm: body.lxm as `${string}.${string}.${string}`,
2020- exp: Math.floor(Date.now() / 1000) + 120
2121- }
2222- });
2323- if (!res.ok) error(502, `getServiceAuth failed: ${JSON.stringify(res.data)}`);
2424- return json({ token: (res.data as { token: string }).token });
2525-};