@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,81 @@
1
+ /**
2
+ * TanStack Start-aware auth provider factory.
3
+ *
4
+ * Glue between kit/react's framework-independent `createAuthContext` and
5
+ * TSS's `useServerFn`. Apps mount the returned `<AuthProvider>` once in
6
+ * `__root.tsx`, seeding `initialSession` from the SSR'd
7
+ * `Route.useRouteContext().session` so first paint requires zero round-trip.
8
+ *
9
+ * `refetch` calls the supplied `loadSession` server-fn through `useServerFn`
10
+ * so loading state and TSS's request-cancel/dedup affordances flow through.
11
+ *
12
+ * @example
13
+ * // app/src/lib/auth-context.ts
14
+ * import { createAuthContext } from "@somewhatintelligent/kit/react";
15
+ * import { createReactStartAuthProvider } from "@somewhatintelligent/kit/react-start/client";
16
+ * import { loadSession } from "@/lib/session.server";
17
+ *
18
+ * const authContext = createAuthContext<PlatformSession>();
19
+ * export const { useAuth } = authContext;
20
+ * export const AuthProvider = createReactStartAuthProvider({ authContext, loadSession });
21
+ */
22
+ import { useServerFn } from "@tanstack/react-start";
23
+ import { useCallback, useMemo, useState, type ReactNode } from "react";
24
+ import type { AuthContextValue, createAuthContext } from "../react/auth";
25
+
26
+ export interface ReactStartAuthProviderOpts<S extends { user: { role?: string | null } }> {
27
+ /** Output of `createAuthContext<S>()` from `@somewhatintelligent/kit/react`. */
28
+ authContext: ReturnType<typeof createAuthContext<S>>;
29
+ /**
30
+ * The `createServerFn` returned by `createSessionFactory` (or any
31
+ * equivalent server-fn that resolves the current session). Wired through
32
+ * `useServerFn` so refetch loading state lives in React.
33
+ */
34
+ loadSession: () => Promise<S | null>;
35
+ }
36
+
37
+ export function createReactStartAuthProvider<S extends { user: { role?: string | null } }>(
38
+ opts: ReactStartAuthProviderOpts<S>,
39
+ ) {
40
+ const { authContext, loadSession } = opts;
41
+ const { BaseAuthProvider } = authContext;
42
+
43
+ return function AuthProvider({
44
+ initialSession,
45
+ children,
46
+ }: {
47
+ initialSession: S | null;
48
+ children: ReactNode;
49
+ }) {
50
+ const fn = useServerFn(loadSession);
51
+ const [session, setSession] = useState<S | null>(initialSession);
52
+ const [isLoading, setIsLoading] = useState(false);
53
+
54
+ const refetch = useCallback(async () => {
55
+ setIsLoading(true);
56
+ try {
57
+ const next = await fn();
58
+ setSession(next);
59
+ } finally {
60
+ setIsLoading(false);
61
+ }
62
+ }, [fn]);
63
+
64
+ const value = useMemo<AuthContextValue<S>>(
65
+ () =>
66
+ ({
67
+ session,
68
+ user: session?.user ?? null,
69
+ isAuthenticated: session != null,
70
+ isLoading,
71
+ refetch,
72
+ hasRole: (role: string) => session?.user?.role === role,
73
+ hasAnyRole: (roles: ReadonlyArray<string>) =>
74
+ session ? roles.includes(session.user?.role ?? "") : false,
75
+ }) as AuthContextValue<S>,
76
+ [session, isLoading, refetch],
77
+ );
78
+
79
+ return <BaseAuthProvider value={value}>{children}</BaseAuthProvider>;
80
+ };
81
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Client-safe subset of `@somewhatintelligent/kit/react-start`.
3
+ *
4
+ * Importing from `@somewhatintelligent/kit/react-start` (the server barrel) drags in
5
+ * modules that top-level-instantiate `AsyncLocalStorage` from
6
+ * `node:async_hooks`, which Vite externalizes for the browser. Client code
7
+ * imports from this entry instead. Only TSS bindings that are safe to evaluate
8
+ * in the browser belong here.
9
+ */
10
+ export { createReactStartAuthProvider } from "./auth-provider";
11
+ export type { ReactStartAuthProviderOpts } from "./auth-provider";
@@ -0,0 +1,136 @@
1
+ /**
2
+ * SAFETY: this module mints bouncer-issued envelopes from inside the app.
3
+ * It is a forgery surface unless gated to `ENVIRONMENT === "development"`.
4
+ * In dev-direct topology (no bouncer in front), bouncer is
5
+ * not in the path on public-host requests to apps, so `x-platform-att` is
6
+ * absent and every server fn that calls `getEnvelope` breaks. This stamper
7
+ * lets dev apps self-mint with the well-known dev key (LOCAL_BNC_ATT_PRIV /
8
+ * kid="dev") so the prod codepath runs verbatim. In staging/production the
9
+ * stamper is a hard no-op: only bouncer holds the live signing key, and a
10
+ * locally-minted envelope under a real kid would bypass the platform's only
11
+ * authoritative actor-resolution step.
12
+ *
13
+ * Dev-direct shares bouncer's mint code via
14
+ * `@somewhatintelligent/auth`'s `createEnvelopeStamper`.
15
+ */
16
+ import { createServerOnlyFn } from "@tanstack/react-start";
17
+ import {
18
+ createEnvelopeStamper,
19
+ PLATFORM_HEADERS,
20
+ stampPlatformHeaders,
21
+ type EnvelopeStamper,
22
+ type SessionResolver,
23
+ type SessionResolverResult,
24
+ type StampableSession,
25
+ } from "@somewhatintelligent/auth";
26
+ import { extractPlatformRequestId } from "../request-context";
27
+
28
+ export type { StampableSession };
29
+
30
+ export interface DevEnvelopeStamperOpts<C extends GuestlistClientShape> {
31
+ /**
32
+ * Env-label thunk. Stamper is active only when this returns
33
+ * `"development"`. Any other value (including `undefined`, `"staging"`,
34
+ * `"production"`) makes the stamper a passthrough — see SAFETY note above.
35
+ */
36
+ getEnvironment: () => string | undefined;
37
+ /**
38
+ * Returns the bouncer signing key + kid. Only invoked when the env is
39
+ * `"development"` AND no envelope is present on the request — apps that
40
+ * don't ship `BNC_ATT_PRIV` in their dev env never call it.
41
+ */
42
+ getSigner: () => { privPem: string; kid: string };
43
+ /**
44
+ * Returns the guestlist client to use for resolving the BA session.
45
+ * Receives the dev request so apps can build a cookie-adapter scoped to
46
+ * the per-request cookie jar (the same pattern `createGuestlistFactory`
47
+ * uses, but without `getRequest()`'s H3 context — we're outside TSS).
48
+ */
49
+ getGuestlist: (request: Request) => C;
50
+ /**
51
+ * Resolves the public host the envelope is bound to. Defaults to
52
+ * `new URL(request.url).hostname.toLowerCase()`. Apps reached via a
53
+ * service-binding loopback (which rewrites Host) should pin it.
54
+ */
55
+ expectedHost?: string | ((request: Request) => string);
56
+ /** Envelope TTL in seconds. Default 30 (matches bouncer). */
57
+ ttlSeconds?: number;
58
+ }
59
+
60
+ export interface GuestlistClientShape {
61
+ getSession: () => Promise<StampableSession | null>;
62
+ }
63
+
64
+ /**
65
+ * Result of running the dev stamper: the stamped request to forward into
66
+ * TSS, plus any `Set-Cookie` headers BA wrote during session resolution
67
+ * that the caller must propagate on the response back to the browser.
68
+ */
69
+ export interface DevEnvelopeStampOutcome {
70
+ request: Request;
71
+ setCookies: string[];
72
+ }
73
+
74
+ export type DevEnvelopeStamper = (request: Request) => Promise<DevEnvelopeStampOutcome>;
75
+
76
+ export function createDevEnvelopeStamper<C extends GuestlistClientShape>(
77
+ opts: DevEnvelopeStamperOpts<C>,
78
+ ): DevEnvelopeStamper {
79
+ const resolveHost: (req: Request) => string =
80
+ typeof opts.expectedHost === "string"
81
+ ? () => opts.expectedHost as string
82
+ : typeof opts.expectedHost === "function"
83
+ ? opts.expectedHost
84
+ : (req) => new URL(req.url).hostname.toLowerCase();
85
+
86
+ let stamperPromise: Promise<EnvelopeStamper> | null = null;
87
+ function getStamper(): Promise<EnvelopeStamper> {
88
+ if (stamperPromise) return stamperPromise;
89
+ const signer = opts.getSigner();
90
+ const sessionResolver: SessionResolver = async (request) => {
91
+ try {
92
+ const guestlist = opts.getGuestlist(request);
93
+ const session = await guestlist.getSession();
94
+ return { session, setCookies: [] } satisfies SessionResolverResult;
95
+ } catch {
96
+ return { session: null, setCookies: [] } satisfies SessionResolverResult;
97
+ }
98
+ };
99
+ stamperPromise = createEnvelopeStamper({
100
+ sessionResolver,
101
+ minter: { privPem: signer.privPem, kid: signer.kid, ttlSeconds: opts.ttlSeconds ?? 30 },
102
+ resolveHost,
103
+ });
104
+ return stamperPromise;
105
+ }
106
+
107
+ // Wrapped in `createServerOnlyFn` so the body — and its static reference
108
+ // to `extractPlatformRequestId` from `../request-context` — is stripped
109
+ // from the client bundle. Apps reach this factory's RETURN value via
110
+ // `lib/platform.ts`'s `createPlatformStartApp` call at module-top, which
111
+ // is reachable from `__root.tsx`'s `session.functions` chain in the
112
+ // client environment. Without the wrap, the closure keeps
113
+ // `kit/request-context`'s `new AsyncLocalStorage()` alive in the browser.
114
+ return createServerOnlyFn(async function stamp(
115
+ request: Request,
116
+ ): Promise<DevEnvelopeStampOutcome> {
117
+ if (opts.getEnvironment() !== "development") return { request, setCookies: [] };
118
+ if (request.headers.get(PLATFORM_HEADERS.att)) return { request, setCookies: [] };
119
+
120
+ try {
121
+ const stamper = await getStamper();
122
+ const { envelope, setCookies, actor } = await stamper(request);
123
+ const stamped = stampPlatformHeaders(request, {
124
+ envelope,
125
+ actor,
126
+ requestId: extractPlatformRequestId(request),
127
+ });
128
+ return { request: stamped, setCookies };
129
+ } catch (err) {
130
+ console.warn("kit:dev-envelope stamp failed; passthrough", {
131
+ message: err instanceof Error ? err.message : String(err),
132
+ });
133
+ return { request, setCookies: [] };
134
+ }
135
+ });
136
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Envelope-driven request middleware. Verifies the bouncer attestation
3
+ * locally (Ed25519, no I/O, no guestlist RPC) and projects the result onto
4
+ * the TSS request context as `ctx.principal`. The single trust source for
5
+ * downstream handlers — verified once per request.
6
+ *
7
+ * Verification is zero-hop: no per-request guestlist RPC. Mutations that
8
+ * need plugin-extended BA fields still call `getSession` explicitly at the
9
+ * callsite.
10
+ */
11
+ import { createMiddleware } from "@tanstack/react-start";
12
+ import {
13
+ EnvelopeRejection,
14
+ type BouncerEnvelopeVerifier,
15
+ type EnvelopeActorUser,
16
+ type EnvelopeResult,
17
+ type EnvelopeSessionData,
18
+ } from "@somewhatintelligent/auth";
19
+ import { updateRequestContext } from "../request-context";
20
+
21
+ /**
22
+ * Discriminated principal projection of the verified envelope. Today's active
23
+ * variants are `user` and `anonymous`; future kinds (service, webhook) slot
24
+ * in without breaking exhaustive consumers — they branch on `kind`.
25
+ */
26
+ export type Principal =
27
+ | {
28
+ kind: "user";
29
+ actor: EnvelopeActorUser;
30
+ session: EnvelopeSessionData;
31
+ activeOrgId: string | null;
32
+ }
33
+ | { kind: "anonymous" };
34
+
35
+ export interface EnvelopeMiddlewareOpts {
36
+ /**
37
+ * Returns the bouncer envelope verifier. Called lazily on first request so
38
+ * apps can defer env-access (cloudflare's `env` global is server-only).
39
+ * The returned verifier is cached internally for the isolate's lifetime.
40
+ */
41
+ getVerifier: () => BouncerEnvelopeVerifier;
42
+ }
43
+
44
+ /**
45
+ * Build the global envelope middleware. Apps install this in
46
+ * `start.ts`'s `requestMiddleware` chain and pass the same singleton
47
+ * reference into `createPrincipalGate`s so TSS dedupes the verify step.
48
+ */
49
+ export function createEnvelopeMiddleware(opts: EnvelopeMiddlewareOpts) {
50
+ let cached: BouncerEnvelopeVerifier | null = null;
51
+ function verifier(): BouncerEnvelopeVerifier {
52
+ if (!cached) cached = opts.getVerifier();
53
+ return cached;
54
+ }
55
+ return createMiddleware({ type: "request" }).server(async ({ next, request }) => {
56
+ let result: EnvelopeResult;
57
+ try {
58
+ result = await verifier()(request);
59
+ } catch (err) {
60
+ if (err instanceof EnvelopeRejection) {
61
+ throw new Response(`Forbidden: ${err.reason}`, {
62
+ status: 403,
63
+ headers: { "content-type": "text/plain; charset=utf-8" },
64
+ });
65
+ }
66
+ throw err;
67
+ }
68
+ const principal = projectPrincipal(result);
69
+ if (principal.kind === "user") {
70
+ updateRequestContext({ actorKind: "user", actorId: principal.actor.id });
71
+ }
72
+ return next({ context: { principal } });
73
+ });
74
+ }
75
+
76
+ function projectPrincipal(result: EnvelopeResult): Principal {
77
+ if (result.kind !== "valid") return { kind: "anonymous" };
78
+ if (!result.actor || !result.session) return { kind: "anonymous" };
79
+ if (result.actor.kind === "user") {
80
+ return {
81
+ kind: "user",
82
+ actor: result.actor,
83
+ session: result.session,
84
+ activeOrgId: result.activeOrgId,
85
+ };
86
+ }
87
+ // Forward-compat: unknown actor kinds collapse to anonymous until we add
88
+ // a Principal variant for them. Verifier already rejected service actors
89
+ // since bouncer doesn't mint them yet.
90
+ return { kind: "anonymous" };
91
+ }
92
+
93
+ export interface PrincipalGateOpts<P extends Principal> {
94
+ /**
95
+ * The singleton envelope middleware instance the app installed globally.
96
+ * Used as a `.middleware([...])` dependency so TSS's `flattenMiddlewares`
97
+ * dedupes the verify step — see `@tanstack/start-client-core`'s
98
+ * `createServerFn.js:147` and `createStartHandler.js:358`.
99
+ */
100
+ envelope: ReturnType<typeof createEnvelopeMiddleware>;
101
+ /**
102
+ * Type-guard predicate. Narrows `context.principal` to `P` for downstream
103
+ * handlers so they can read `principal.actor.id` etc. without re-checking
104
+ * `kind`. Example for "must be a user":
105
+ *
106
+ * predicate: (p): p is Extract<Principal, { kind: "user" }> => p.kind === "user"
107
+ */
108
+ predicate: (principal: Principal) => principal is P;
109
+ /**
110
+ * Called on predicate failure. Default throws `Error("forbidden")`. Apps
111
+ * typically throw a redirect or a custom Response for cloaking.
112
+ */
113
+ onReject?: () => never;
114
+ }
115
+
116
+ /**
117
+ * Build a gate middleware on top of `envelopeMiddleware`. Composing by
118
+ * reference means TSS only runs envelope-verify once per request even when
119
+ * multiple gates are attached. The narrowed `principal` flows down the
120
+ * chain so handlers attached via `.middleware([gate])` see the gated
121
+ * variant directly.
122
+ */
123
+ export function createPrincipalGate<P extends Principal>(opts: PrincipalGateOpts<P>) {
124
+ const reject =
125
+ opts.onReject ??
126
+ (() => {
127
+ throw new Error("forbidden");
128
+ });
129
+ return createMiddleware({ type: "request" })
130
+ .middleware([opts.envelope])
131
+ .server(async ({ next, context }) => {
132
+ if (!opts.predicate(context.principal)) reject();
133
+ return next({ context: { principal: context.principal as P } });
134
+ });
135
+ }
@@ -0,0 +1,29 @@
1
+ // Server-side-only factories. The marker lives on the leaf modules with
2
+ // eager top-level side effects (`../log/core.ts`, `../request-context/index.ts`)
3
+ // so legitimate isomorphic config files (e.g. `workers/*/src/start.ts`, which
4
+ // TSS imports from both sides) can still pull factory references without
5
+ // firing import-protection on the barrel itself. The factories themselves
6
+ // return middleware definition objects whose `.server()` callbacks fire
7
+ // only server-side; the closures inside capture mock proxies in the client
8
+ // bundle but never invoke them.
9
+ export { createEnvelopeMiddleware, createPrincipalGate } from "./envelope-middleware";
10
+ export type { Principal, EnvelopeMiddlewareOpts, PrincipalGateOpts } from "./envelope-middleware";
11
+ export { createLoggingFunctionMiddleware } from "./logging";
12
+ export { createRequestLogger } from "./request-logger";
13
+ export { extractPlatformStartContext } from "./platform-start-context";
14
+ export type { PlatformStartContext } from "./platform-start-context";
15
+ export {
16
+ createGuestlistFactory,
17
+ createRoadieFactory,
18
+ createSessionFactory,
19
+ } from "./service-clients";
20
+ export { createPlatformStartApp } from "./platform-start-app";
21
+ export type { PlatformStartAppOpts } from "./platform-start-app";
22
+ export { createDevEnvelopeStamper } from "./dev-envelope";
23
+ export type {
24
+ DevEnvelopeStamper,
25
+ DevEnvelopeStamperOpts,
26
+ DevEnvelopeStampOutcome,
27
+ GuestlistClientShape as DevEnvelopeGuestlistShape,
28
+ StampableSession as DevEnvelopeStampableSession,
29
+ } from "./dev-envelope";
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Global server-fn function-middleware that PATCHES the active request log
3
+ * scope (opened by `createRequestLogger`) with `event: "server_fn"` and an
4
+ * `operation` derived from the compile-time `serverFnMeta.name`. Does NOT
5
+ * open its own canonical-log scope — server-fns run inside an HTTP request
6
+ * that already has one open via the request middleware, and opening a
7
+ * nested scope would double-emit (one server_fn line + one http line per
8
+ * server-fn invocation).
9
+ *
10
+ * Override mechanic: `withCanonicalLog` emits with `{...ctx, ...fields}` —
11
+ * `...fields` spread last, so `log.add({ event, operation })` overrides
12
+ * the request middleware's `event: "http"` / `operation: "http.<method>"`
13
+ * defaults for this one line. The HTTP-specific fields (`method`, `path`,
14
+ * `status`) the request middleware added still ride along, so a server-fn
15
+ * line carries both function-level identity (event/operation) and
16
+ * transport metadata.
17
+ *
18
+ * Wire once in `start.ts`:
19
+ *
20
+ * // workers/<app>/src/start.ts
21
+ * import { createStart } from "@tanstack/react-start";
22
+ * import {
23
+ * createLoggingFunctionMiddleware,
24
+ * createRequestLogger,
25
+ * } from "@somewhatintelligent/kit/react-start";
26
+ *
27
+ * const requestLogger = createRequestLogger({ service: "<app>" });
28
+ * const functionLogger = createLoggingFunctionMiddleware({ service: "<app>" });
29
+ *
30
+ * export const startInstance = createStart(() => ({
31
+ * requestMiddleware: [requestLogger],
32
+ * functionMiddleware: [functionLogger],
33
+ * }));
34
+ */
35
+ import { createMiddleware } from "@tanstack/react-start";
36
+ import { describeThrown, getRequestLog } from "../log";
37
+
38
+ export function createLoggingFunctionMiddleware(opts: { service: string }) {
39
+ return createMiddleware({ type: "function" }).server(async ({ next, serverFnMeta }) => {
40
+ const log = getRequestLog();
41
+ if (log) {
42
+ log.add({
43
+ event: "server_fn",
44
+ operation: `${opts.service}.${serverFnMeta.name}`,
45
+ });
46
+ }
47
+ // A server-fn throw is masked into a 500 by the framework before it reaches
48
+ // the fetch-boundary logger, so record the failure (with a traceback) onto
49
+ // the active request line here, then rethrow so behaviour is unchanged.
50
+ try {
51
+ return await next();
52
+ } catch (err) {
53
+ if (log) {
54
+ if (err instanceof Response) {
55
+ // TanStack `redirect()` (and any deliberate `throw new Response`)
56
+ // is control flow, not a failure: mirror the request logger's
57
+ // status→outcome mapping and never fabricate error fields from it.
58
+ if (err.status >= 500) log.outcome("internal_error");
59
+ else if (err.status >= 400) log.outcome(`http_${err.status}`);
60
+ else log.outcome("redirect");
61
+ } else {
62
+ const { message, stack } = describeThrown(err);
63
+ log.add({ error_message: message, error_stack: stack ?? message });
64
+ log.outcome("internal_error");
65
+ }
66
+ }
67
+ throw err;
68
+ }
69
+ });
70
+ }