@somewhatintelligent/kit 0.0.1-beta.1783705886.297c5be

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,382 @@
1
+ /**
2
+ * `createPlatformStartApp` — thin compositor that ties together the
3
+ * platform's session + envelope + cross-worker primitives behind one
4
+ * import surface, so apps don't repeat the wiring.
5
+ *
6
+ * Two distinct ways to ask "who is the request?" — kept as separate
7
+ * functions because the answers are different types:
8
+ *
9
+ * - `getEnvelope(headers)` returns the signed, narrow envelope payload
10
+ * ({ actor, session } with id/role/name/email/image + id/userId/expiresAt).
11
+ * No I/O. Use for auth gates, log correlation, header chrome — anywhere
12
+ * you only need identity. Verifier still runs (prod: throws 403 on
13
+ * missing/invalid; dev: returns null) and feeds request-context.
14
+ *
15
+ * - `getSession(headers)` returns the BA-inferred full `PlatformSession`
16
+ * by RPC to guestlist. Use for plugin-extended fields (`twoFactorEnabled`,
17
+ * `createdAt`, `username`, etc.). Envelope is verified as a
18
+ * precondition; guestlist is the source of truth.
19
+ *
20
+ * Apps wire `getGuestlist` via `createGuestlistFactory` and pass it in here
21
+ * alongside the env-label thunk. Kit builds the bouncer envelope verifier
22
+ * lazily on first server-only call (env access cannot happen at module-eval
23
+ * time — that leaks values into the client bundle).
24
+ */
25
+ import { createServerOnlyFn } from "@tanstack/react-start";
26
+ import {
27
+ createBouncerEnvelopeVerifier,
28
+ EnvelopeRejection,
29
+ PLATFORM_HEADERS,
30
+ type EnvelopeData,
31
+ type EnvelopeResult,
32
+ type BouncerEnvelopeVerifier,
33
+ type PlatformEnvironment,
34
+ type PlatformSession,
35
+ } from "@somewhatintelligent/auth";
36
+ import { getRequestContext, updateRequestContext } from "../request-context";
37
+ import { createAuthContext } from "../react/auth";
38
+ import { createReactStartAuthProvider } from "./auth-provider";
39
+ import { createEnvelopeMiddleware } from "./envelope-middleware";
40
+ import { createDevEnvelopeStamper, type DevEnvelopeStamper } from "./dev-envelope";
41
+
42
+ /** Generic guestlist-client shape — kit needs `.getSession()`. */
43
+ interface GuestlistClientShape {
44
+ getSession: () => Promise<PlatformSession | null>;
45
+ }
46
+
47
+ export interface PlatformStartAppOpts<C extends GuestlistClientShape> {
48
+ /** App identifier (`"identity"`, etc.). Threaded into the apiProxyHandlers' `x-platform-caller` header. */
49
+ name: string;
50
+ /** Pre-built `getGuestlist` from `createGuestlistFactory`. */
51
+ getGuestlist: () => C;
52
+ /**
53
+ * `createServerOnlyFn`-wrapped fetcher into the guestlist service binding —
54
+ * the same thunk apps pass to `createGuestlistFactory` as `fetcher`.
55
+ * Used by `apiProxyHandlers` to forward raw HTTP to guestlist without
56
+ * recreating the guestlist client.
57
+ */
58
+ guestlistFetcher: () => typeof fetch;
59
+ /**
60
+ * Bouncer attestation public-key set (`kid` → base64/PEM Ed25519 public
61
+ * key). Public keys are not secrets — apps commit this as code and every
62
+ * app in a deployment shares the same set. Injected (not imported from a
63
+ * central config) because this package is brand-free: the consumer
64
+ * decides where the keyset lives.
65
+ */
66
+ attestationKeys: Record<string, string>;
67
+ /**
68
+ * `createServerOnlyFn`-wrapped env-label thunk. Apps construct via:
69
+ *
70
+ * import { env } from "cloudflare:workers";
71
+ * import { createServerOnlyFn } from "@tanstack/react-start";
72
+ *
73
+ * getEnvironment: createServerOnlyFn(() => env.ENVIRONMENT)
74
+ *
75
+ * Kit invokes it only inside server-only paths — never at module load.
76
+ */
77
+ getEnvironment: () => string;
78
+ /**
79
+ * Optional. Host the bouncer envelope is verified against. Default reads
80
+ * `new URL(req.url).hostname` per request. Apps reached via a bouncer
81
+ * service-binding loopback (which rewrites `Host` to the miniflare port
82
+ * in dev) should pin to the public host — typically
83
+ * `new URL(env.APP_URL).hostname`. Accepts a string or `(req) => string`.
84
+ */
85
+ expectedHost?: string | ((req: Request) => string);
86
+ /**
87
+ * Optional. When supplied, kit constructs a `devEnvelopeStamper` that
88
+ * apps invoke at their worker entry to self-mint a bouncer envelope on
89
+ * dev-direct requests (dev-direct topology — public
90
+ * hosts hit the app without bouncer in front, so `x-platform-att` would
91
+ * otherwise be absent and every `getEnvelope` call would null out).
92
+ *
93
+ * Hard non-dev no-op: the returned stamper short-circuits unless
94
+ * `getEnvironment()` returns `"development"`. See `dev-envelope.ts`
95
+ * for the safety rationale.
96
+ *
97
+ * Apps wire it as:
98
+ *
99
+ * const stamped = await platform.devEnvelopeStamper?.(request) ?? request;
100
+ * return startEntry.fetch(stamped, { context });
101
+ */
102
+ devEnvelopeSigner?: () => { privPem: string; kid: string };
103
+ /**
104
+ * Optional. Per-request guestlist client builder for the dev envelope
105
+ * stamper. Apps mint a fresh client with cookies read directly from the
106
+ * incoming `Request` (not from TSS's H3 context — the stamper runs at
107
+ * the worker boundary, BEFORE TSS captures its H3 event, so the default
108
+ * `getGuestlist()` factory's cookie reader sees no cookies).
109
+ *
110
+ * When omitted, the stamper falls back to `getGuestlist()` and will mint
111
+ * `actor: null` envelopes in dev-direct topology — usable for public
112
+ * endpoints but not for `getEnvelope`-strict server fns.
113
+ */
114
+ devEnvelopeGuestlist?: (request: Request) => C;
115
+ }
116
+
117
+ export function createPlatformStartApp<C extends GuestlistClientShape>(
118
+ opts: PlatformStartAppOpts<C>,
119
+ ) {
120
+ const {
121
+ name,
122
+ attestationKeys,
123
+ getGuestlist,
124
+ guestlistFetcher,
125
+ getEnvironment,
126
+ expectedHost,
127
+ devEnvelopeSigner,
128
+ devEnvelopeGuestlist,
129
+ } = opts;
130
+
131
+ // Lazy verifier — first call inside a server-only path constructs and
132
+ // caches it. Env access (the ENVIRONMENT label) happens only inside the
133
+ // server-only chain.
134
+ const resolveHost: (req: Request) => string =
135
+ typeof expectedHost === "string"
136
+ ? () => expectedHost
137
+ : typeof expectedHost === "function"
138
+ ? expectedHost
139
+ : (req) => new URL(req.url).hostname;
140
+ let _verifier: BouncerEnvelopeVerifier | null = null;
141
+ function getVerifier(): BouncerEnvelopeVerifier {
142
+ if (_verifier) return _verifier;
143
+ _verifier = createBouncerEnvelopeVerifier({
144
+ keys: attestationKeys,
145
+ env: getEnvironment() as PlatformEnvironment,
146
+ expectedHost: (req) => resolveHost(req).toLowerCase(),
147
+ });
148
+ return _verifier;
149
+ }
150
+
151
+ // Per-Headers memoization for both lookups so N callers in one TSS
152
+ // request share at most one verify + at most one guestlist RPC.
153
+ const envelopeInflight = new WeakMap<Headers, Promise<EnvelopeResult>>();
154
+ const sessionInflight = new WeakMap<Headers, Promise<PlatformSession | null>>();
155
+ const activeOrgInflight = new WeakMap<Headers, Promise<string | null>>();
156
+
157
+ // Verify the envelope, feed request-context off the result, and surface
158
+ // a 403 Response in prod when the verifier rejected. Shared precondition
159
+ // for both `getEnvelope` and `getSession`.
160
+ //
161
+ // `createServerOnlyFn`-wrapped so TSS replaces the body with a throwing
162
+ // stub in the client bundle. Without the wrap, the closure's references
163
+ // to `getRequestContext` / `updateRequestContext` would keep
164
+ // `kit/request-context` reachable from any client module that imports
165
+ // `createPlatformStartApp` (every app reaches it via `lib/platform.ts` →
166
+ // `__root.tsx` → `loadSession`), pulling `new AsyncLocalStorage()` into
167
+ // the browser bundle.
168
+ const verifyAndContextualise = createServerOnlyFn(async function verifyAndContextualise(
169
+ headers: Headers,
170
+ ): Promise<EnvelopeResult> {
171
+ const cached = envelopeInflight.get(headers);
172
+ if (cached) return cached;
173
+ const promise = (async (): Promise<EnvelopeResult> => {
174
+ const host = headers.get("host") ?? "__platform__.invalid";
175
+ const realRequest = new Request(`https://${host}/`, { headers });
176
+ let result: EnvelopeResult;
177
+ try {
178
+ result = await getVerifier()(realRequest);
179
+ } catch (err) {
180
+ if (err instanceof EnvelopeRejection) {
181
+ throw new Response(`Forbidden: ${err.reason}`, {
182
+ status: 403,
183
+ headers: { "content-type": "text/plain; charset=utf-8" },
184
+ });
185
+ }
186
+ throw err;
187
+ }
188
+ if (result.kind === "valid" && result.actor && getRequestContext()) {
189
+ updateRequestContext({ actorKind: "user", actorId: result.actor.id });
190
+ }
191
+ return result;
192
+ })();
193
+ envelopeInflight.set(headers, promise);
194
+ return promise;
195
+ });
196
+
197
+ /**
198
+ * Fast path. Verifies the signed envelope and returns its narrow payload —
199
+ * or `null` for a public/optional request that has no actor. No guestlist
200
+ * I/O.
201
+ */
202
+ const getEnvelope = createServerOnlyFn(async function getEnvelope(
203
+ headers: Headers,
204
+ ): Promise<EnvelopeData | null> {
205
+ const result = await verifyAndContextualise(headers);
206
+ if (result.kind !== "valid") return null;
207
+ // Verifier enforces actor null ↔ session null.
208
+ if (!result.actor || !result.session) return null;
209
+ return { actor: result.actor, session: result.session };
210
+ });
211
+
212
+ /**
213
+ * Full BA-inferred `PlatformSession` by RPC to guestlist. Envelope is
214
+ * verified as a precondition (prod throws on missing/invalid). Returns
215
+ * `null` when the user is unauthenticated.
216
+ */
217
+ const getSession = createServerOnlyFn(function getSession(
218
+ headers: Headers,
219
+ ): Promise<PlatformSession | null> {
220
+ const cached = sessionInflight.get(headers);
221
+ if (cached) return cached;
222
+ const promise = (async () => {
223
+ const result = await verifyAndContextualise(headers);
224
+ // The envelope is the authoritative, locally-verified (no-I/O) answer to
225
+ // "is this request authenticated?"; the guestlist RPC only ENRICHES it to
226
+ // the full BA session. So a valid envelope with an actor means the caller
227
+ // IS signed in even if guestlist blips.
228
+ const envelopeAuthed = result.kind === "valid" && result.actor != null;
229
+ let session: PlatformSession | null = null;
230
+ try {
231
+ session = await getGuestlist().getSession();
232
+ } catch {
233
+ // A transient RPC failure must NOT be conflated with sign-out: returning
234
+ // null here bounces a valid signed-in user out of SSR-gated loaders
235
+ // (e.g. identity's admin `beforeLoad`, which redirects on
236
+ // `!context.session`) — a redirect the client's later
237
+ // `authClient.useSession()` reconcile (D4a) can't undo. When the
238
+ // envelope proves the caller is authenticated, retry once before giving
239
+ // up; only a sustained failure yields null. Unauthenticated / dev-direct
240
+ // requests keep the original single-attempt behavior.
241
+ if (envelopeAuthed) {
242
+ try {
243
+ session = await getGuestlist().getSession();
244
+ } catch {
245
+ session = null;
246
+ }
247
+ }
248
+ }
249
+ if (getRequestContext() && session) {
250
+ updateRequestContext({ actorKind: "user", actorId: session.user.id });
251
+ }
252
+ return session;
253
+ })();
254
+ sessionInflight.set(headers, promise);
255
+ return promise;
256
+ });
257
+
258
+ /**
259
+ * Active organization id for the current request (string) or `null`. Reads
260
+ * from the verified envelope's `activeOrgId` projection of
261
+ * `session.activeOrganizationId`. Role isn't denormalized — server fns
262
+ * that need it call `authClient.organization.getActiveMemberRole`.
263
+ *
264
+ * Dev-only fallback: when the envelope verifier returns `invalid`/`missing`
265
+ * (dev-direct topology with no bouncer), fall back to reading
266
+ * `session.activeOrganizationId` directly via the existing getSession
267
+ * RPC. No separate guestlist hop.
268
+ */
269
+ const getActiveOrgId = createServerOnlyFn(function getActiveOrgId(
270
+ headers: Headers,
271
+ ): Promise<string | null> {
272
+ const cached = activeOrgInflight.get(headers);
273
+ if (cached) return cached;
274
+ const promise = (async () => {
275
+ const result = await verifyAndContextualise(headers);
276
+ if (result.kind === "valid") return result.activeOrgId;
277
+ if (getEnvironment() !== "development") return null;
278
+ try {
279
+ const session = await getGuestlist().getSession();
280
+ return (
281
+ (session?.session as { activeOrganizationId?: string | null } | undefined)
282
+ ?.activeOrganizationId ?? null
283
+ );
284
+ } catch {
285
+ return null;
286
+ }
287
+ })();
288
+ activeOrgInflight.set(headers, promise);
289
+ return promise;
290
+ });
291
+
292
+ /**
293
+ * Envelope-driven global middleware (zero-hop). Apps install this in
294
+ * `start.ts`'s `requestMiddleware` to surface `ctx.principal` to every
295
+ * server fn / server route without an RPC. The verifier is the same lazy
296
+ * instance `getEnvelope` uses; TSS dedupes by reference when this
297
+ * middleware appears in the chain.
298
+ *
299
+ *`loadSession` remains available for
300
+ * client-side AuthProvider seeding.
301
+ */
302
+ const envelopeMiddleware = createEnvelopeMiddleware({ getVerifier });
303
+
304
+ // Dev-only envelope stamper. Built only when an app explicitly threads a
305
+ // signer through `devEnvelopeSigner`; the stamper itself is also gated on
306
+ // `getEnvironment() === "development"` (see SAFETY in `dev-envelope.ts`).
307
+ const devEnvelopeStamper: DevEnvelopeStamper | undefined = devEnvelopeSigner
308
+ ? createDevEnvelopeStamper({
309
+ getEnvironment,
310
+ getSigner: devEnvelopeSigner,
311
+ getGuestlist: devEnvelopeGuestlist ?? (() => getGuestlist()),
312
+ ...(expectedHost !== undefined && { expectedHost }),
313
+ })
314
+ : undefined;
315
+
316
+ /**
317
+ * Builds the `<AuthProvider>` + `useAuth` pair. Called from app code
318
+ * after defining `loadSession` (a top-level `createServerFn` per the
319
+ * TSS compiler constraint).
320
+ */
321
+ function makeAuthProvider(loadSession: () => Promise<PlatformSession | null>) {
322
+ const authContext = createAuthContext<PlatformSession>();
323
+ return {
324
+ AuthProvider: createReactStartAuthProvider<PlatformSession>({ authContext, loadSession }),
325
+ useAuth: authContext.useAuth,
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Reverse-proxy `/api/$` (the catch-all) to guestlist. Browser thinks it's
331
+ * same-origin: cookies attach automatically, no CORS preflight. Uses
332
+ * the same guestlist fetcher as `getGuestlist` so the binding-injection
333
+ * pattern is unified.
334
+ *
335
+ * export const Route = createFileRoute("/api/$")({
336
+ * server: { handlers: platform.apiProxyHandlers },
337
+ * });
338
+ */
339
+ const proxy = createServerOnlyFn(async function proxy(request: Request): Promise<Response> {
340
+ const url = new URL(request.url);
341
+ url.protocol = "http:";
342
+ url.host = "guestlist.internal";
343
+ const inner = new Request(url, request);
344
+
345
+ const ip = request.headers.get("cf-connecting-ip");
346
+ if (ip) {
347
+ const fwd = inner.headers.get("x-forwarded-for");
348
+ inner.headers.set("x-forwarded-for", fwd ? `${ip}, ${fwd}` : ip);
349
+ }
350
+
351
+ // Forward platform contract for log correlation only.
352
+ const ctx = getRequestContext();
353
+ if (ctx?.requestId) inner.headers.set(PLATFORM_HEADERS.rid, ctx.requestId);
354
+ inner.headers.set(PLATFORM_HEADERS.caller, name);
355
+ if (ctx?.actorKind) inner.headers.set(PLATFORM_HEADERS.actor.kind, ctx.actorKind);
356
+ if (ctx?.actorId) inner.headers.set(PLATFORM_HEADERS.actor.id, ctx.actorId);
357
+
358
+ return guestlistFetcher()(inner);
359
+ });
360
+
361
+ const apiProxyHandlers = {
362
+ GET: ({ request }: { request: Request }) => proxy(request),
363
+ POST: ({ request }: { request: Request }) => proxy(request),
364
+ PUT: ({ request }: { request: Request }) => proxy(request),
365
+ PATCH: ({ request }: { request: Request }) => proxy(request),
366
+ DELETE: ({ request }: { request: Request }) => proxy(request),
367
+ OPTIONS: ({ request }: { request: Request }) => proxy(request),
368
+ HEAD: ({ request }: { request: Request }) => proxy(request),
369
+ };
370
+
371
+ return {
372
+ name,
373
+ getEnvelope,
374
+ getSession,
375
+ getActiveOrgId,
376
+ getGuestlist,
377
+ envelopeMiddleware,
378
+ apiProxyHandlers,
379
+ makeAuthProvider,
380
+ devEnvelopeStamper,
381
+ };
382
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Build the TSS `Register.server.requestContext` payload from an inbound
3
+ * request's platform headers. Single source of truth for the shape app
4
+ * workers pass to `startEntry.fetch(req, { context })` — keeps the
5
+ * platform-header lookups out of app code.
6
+ *
7
+ * Both prod and dev-direct topologies populate the same headers at the edge
8
+ * (bouncer in prod; the dev stamper in portless), so this reader is
9
+ * topology-agnostic.
10
+ */
11
+ import { PLATFORM_HEADERS } from "@somewhatintelligent/auth";
12
+ import { extractPlatformRequestId } from "../request-context";
13
+
14
+ export interface PlatformStartContext {
15
+ requestId: string;
16
+ callerApp?: string;
17
+ }
18
+
19
+ export function extractPlatformStartContext(request: Request): PlatformStartContext {
20
+ const callerApp = request.headers.get(PLATFORM_HEADERS.caller);
21
+ return {
22
+ requestId: extractPlatformRequestId(request),
23
+ ...(callerApp != null && { callerApp }),
24
+ };
25
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Global request-middleware that opens the per-request canonical scope:
3
+ *
4
+ * - Seeds the request-context ALS from TSS's typed `requestContext`
5
+ * (apps' `worker.ts` populates it at the universal-fetch boundary
6
+ * by passing `{ context: { requestId, callerApp } }` to
7
+ * `startEntry.fetch`).
8
+ * - Opens a canonical `event: "http"` log scope; sets `outcome` from the
9
+ * final response status; emits one line per inbound request.
10
+ *
11
+ * Wire once in `start.ts`:
12
+ *
13
+ * // workers/<app>/src/start.ts
14
+ * import { createStart } from "@tanstack/react-start";
15
+ * import { createRequestLogger } from "@somewhatintelligent/kit/react-start";
16
+ *
17
+ * const requestLogger = createRequestLogger({ service: "<app>" });
18
+ *
19
+ * export const startInstance = createStart(() => ({
20
+ * requestMiddleware: [requestLogger],
21
+ * }));
22
+ *
23
+ * Static imports of kit/log + kit/request-context are fine — node:async_hooks
24
+ * is externalized by vite for the client bundle and the .server() body is
25
+ * stripped by TSS's import-protection plugin anyway.
26
+ */
27
+ import { createMiddleware } from "@tanstack/react-start";
28
+ import { withRequestLog } from "../log";
29
+ import { withRequestContext } from "../request-context";
30
+
31
+ /**
32
+ * Contract: consuming apps must augment `@tanstack/react-start`'s `Register`
33
+ * with `server: { requestContext: { requestId: string; callerApp?: string } }`
34
+ * (typically in `worker.ts`) AND seed those fields at the universal-fetch
35
+ * boundary. `callerApp` is intentionally optional: it carries the upstream
36
+ * caller's identity (read from the `x-caller-app` header) and is omitted
37
+ * from logs when the request didn't come from another in-platform worker.
38
+ * The kit factory compiles independently of those augmentations, so it
39
+ * casts `context` to this shape rather than relying on inferred types.
40
+ */
41
+ interface RequestContextShape {
42
+ requestId: string;
43
+ callerApp?: string;
44
+ }
45
+
46
+ export function createRequestLogger(opts: { service: string }) {
47
+ return createMiddleware({ type: "request" }).server(async ({ context, next, request }) => {
48
+ const ctx = context as unknown as RequestContextShape;
49
+ return withRequestContext({ requestId: ctx.requestId, callerApp: ctx.callerApp }, () =>
50
+ withRequestLog({ service: opts.service }, request, async (log) => {
51
+ const result = await next();
52
+ const status = result.response.status;
53
+ log.add({ status });
54
+ if (status >= 500) log.outcome("internal_error");
55
+ else if (status >= 400) log.outcome(`http_${status}`);
56
+ return result;
57
+ }),
58
+ );
59
+ });
60
+ }