@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.
- package/package.json +58 -0
- package/src/execution-context/index.ts +16 -0
- package/src/ids/__tests__/index.test.ts +80 -0
- package/src/ids/index.ts +60 -0
- package/src/log/__tests__/http.test.ts +109 -0
- package/src/log/__tests__/index.test.ts +254 -0
- package/src/log/__tests__/instrumented.test.ts +319 -0
- package/src/log/__tests__/scheduled.test.ts +107 -0
- package/src/log/core.ts +230 -0
- package/src/log/http.ts +72 -0
- package/src/log/index.ts +22 -0
- package/src/log/instrumented.ts +191 -0
- package/src/log/scheduled.ts +84 -0
- package/src/react/auth.tsx +63 -0
- package/src/react/index.ts +2 -0
- package/src/react-start/__tests__/dev-envelope.test.ts +153 -0
- package/src/react-start/__tests__/smoke.test.ts +77 -0
- package/src/react-start/auth-provider.tsx +81 -0
- package/src/react-start/client.ts +11 -0
- package/src/react-start/dev-envelope.ts +136 -0
- package/src/react-start/envelope-middleware.ts +135 -0
- package/src/react-start/index.ts +29 -0
- package/src/react-start/logging.ts +70 -0
- package/src/react-start/platform-start-app.ts +382 -0
- package/src/react-start/platform-start-context.ts +25 -0
- package/src/react-start/request-logger.ts +60 -0
- package/src/react-start/service-clients.ts +246 -0
- package/src/request-context/__tests__/index.test.ts +90 -0
- package/src/request-context/index.ts +189 -0
- package/src/roles.ts +25 -0
- package/src/version/__tests__/index.test.ts +140 -0
- package/src/version/index.ts +85 -0
- package/src/version.gen.ts +7 -0
|
@@ -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
|
+
}
|