@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,246 @@
1
+ /**
2
+ * Service-client factories for TanStack Start apps.
3
+ *
4
+ * Three DI-correct factories — kit owns no concrete guestlist/roadie client
5
+ * type. Apps inject the constructor (`createGuestlistClient`, `createRoadieClient`)
6
+ * so kit doesn't take a dependency on platform service packages. Cookie
7
+ * adapters, request-id and actor wiring are sourced from kit primitives
8
+ * (`@tanstack/react-start/server`, `@somewhatintelligent/kit/request-context`).
9
+ *
10
+ * All three return `createServerOnlyFn`-wrapped callables: importing the
11
+ * surface from a route file (which bundles for both client and server) is
12
+ * safe; calling it from the client throws.
13
+ */
14
+ import { createServerOnlyFn } from "@tanstack/react-start";
15
+ import { getCookies, getRequest, setCookie } from "@tanstack/react-start/server";
16
+ import {
17
+ extractRequestId,
18
+ getRequestContext,
19
+ updateRequestContext,
20
+ type Actor,
21
+ } from "../request-context";
22
+
23
+ // --- Guestlist ----------------------------------------------------------------
24
+
25
+ /**
26
+ * Caller-supplied factory for the guestlist client. Apps pass
27
+ * `createGuestlistClient` from `@somewhatintelligent/guestlist-service/client` (kit doesn't
28
+ * depend on it directly).
29
+ */
30
+ type GuestlistClientFactory<C> = (opts: {
31
+ baseURL: string;
32
+ callerApp: string;
33
+ fetchOptions: {
34
+ customFetchImpl: typeof fetch;
35
+ headers?: Record<string, string>;
36
+ };
37
+ cookies: {
38
+ getAll: () => ReadonlyArray<{ name: string; value: string }>;
39
+ setAll: (
40
+ cookies: ReadonlyArray<{
41
+ name: string;
42
+ value: string;
43
+ options?: Record<string, unknown>;
44
+ }>,
45
+ ) => void;
46
+ };
47
+ getRequestId: () => string;
48
+ getActor: () => Actor | null;
49
+ }) => C;
50
+
51
+ export interface GuestlistFactoryOpts<C> {
52
+ /** App identifier (`"identity"`, `"guestlist"`, etc.). Threaded into guestlist logs as `caller_app`. */
53
+ callerApp: string;
54
+ /** App-supplied guestlist client constructor. */
55
+ createClient: GuestlistClientFactory<C>;
56
+ /**
57
+ * Thunk returning a fetch implementation. Apps typically pass
58
+ * `() => env.GUESTLIST.fetch.bind(env.GUESTLIST)` so the binding lookup happens
59
+ * lazily inside the server-only path.
60
+ */
61
+ fetcher: () => typeof fetch;
62
+ /**
63
+ * Optional baseURL override. Defaults to `"http://guestlist.internal"` (the
64
+ * placeholder used for service-binding routing).
65
+ */
66
+ baseURL?: string;
67
+ }
68
+
69
+ /**
70
+ * Build an app's `getGuestlist` factory.
71
+ *
72
+ * Returns `(originRequest?: Request) => C` wrapped in `createServerOnlyFn`.
73
+ * Pass `originRequest` when calling Better Auth mutations from a server-route
74
+ * handler to forward the inbound `Origin` header (BA's CSRF check requires
75
+ * it on POST routes).
76
+ */
77
+ export function createGuestlistFactory<C>(opts: GuestlistFactoryOpts<C>) {
78
+ const { callerApp, createClient, fetcher, baseURL = "http://guestlist.internal" } = opts;
79
+
80
+ return createServerOnlyFn((originRequest?: Request): C => {
81
+ const origin = originRequest?.headers.get("origin") ?? undefined;
82
+ return createClient({
83
+ baseURL,
84
+ callerApp,
85
+ fetchOptions: {
86
+ customFetchImpl: fetcher(),
87
+ ...(origin && { headers: { origin } }),
88
+ },
89
+ cookies: {
90
+ getAll: () => Object.entries(getCookies()).map(([name, value]) => ({ name, value })),
91
+ setAll: (cookies) => {
92
+ for (const { name, value, options } of cookies) {
93
+ setCookie(name, value, options as Parameters<typeof setCookie>[2]);
94
+ }
95
+ },
96
+ },
97
+ getRequestId: () => extractRequestId(getRequest()),
98
+ getActor: () => {
99
+ const ctx = getRequestContext();
100
+ return ctx?.actorKind === "user" && ctx.actorId
101
+ ? { kind: "user", userId: ctx.actorId }
102
+ : null;
103
+ },
104
+ });
105
+ });
106
+ }
107
+
108
+ // --- Roadie -----------------------------------------------------------------
109
+
110
+ /**
111
+ * Caller-supplied factory for the roadie client. The exact `Binding` and
112
+ * `RoadieActor` types live in `@somewhatintelligent/roadie-service/client`; kit takes
113
+ * `Binding` as a generic so no import is needed here.
114
+ */
115
+ type RoadieClientFactory<C, B> = (
116
+ binding: B,
117
+ opts: {
118
+ callerApp: string;
119
+ getRequestId: () => string;
120
+ getActor: () => RoadieActorLike;
121
+ },
122
+ ) => C;
123
+
124
+ /**
125
+ * Mirror of `RoadieActor` from `@somewhatintelligent/roadie-service/client`. Kit doesn't
126
+ * import the platform package, so we restate the union here. Apps pass the
127
+ * real type via the generic `C`/`B`.
128
+ */
129
+ type RoadieActorLike =
130
+ | { kind: "user"; userId: string }
131
+ | { kind: "service"; serviceName: string }
132
+ | { kind: "anonymous"; label: string };
133
+
134
+ export interface RoadieFactoryOpts<C, B> {
135
+ /** App identifier — sets `meta.callerApp` and forms the anon-actor service prefix. */
136
+ callerApp: string;
137
+ /** App-supplied roadie client constructor. */
138
+ createClient: RoadieClientFactory<C, B>;
139
+ /** Thunk returning the roadie service binding (`() => env.ROADIE`). */
140
+ getBinding: () => B;
141
+ /**
142
+ * Optional anonymous-actor label deriver. When set and the request has no
143
+ * authenticated user, the returned label feeds `{ kind: "anonymous", label }`.
144
+ * Defaults to `"unauthenticated"`.
145
+ */
146
+ deriveAnonymousLabel?: (request: Request) => string;
147
+ }
148
+
149
+ /**
150
+ * Build an app's `getRoadie` factory.
151
+ *
152
+ * Returns `() => C` wrapped in `createServerOnlyFn`. Lazy: the binding
153
+ * thunk and any per-request state (active request, ALS context) are read
154
+ * each call, so the same factory works across requests in a single CF
155
+ * isolate.
156
+ */
157
+ export function createRoadieFactory<C, B>(opts: RoadieFactoryOpts<C, B>) {
158
+ const { callerApp, createClient, getBinding, deriveAnonymousLabel } = opts;
159
+
160
+ return createServerOnlyFn((): C => {
161
+ return createClient(getBinding(), {
162
+ callerApp,
163
+ getRequestId: () => extractRequestId(getRequest()),
164
+ getActor: (): RoadieActorLike => {
165
+ const ctx = getRequestContext();
166
+ if (ctx?.actorKind === "user" && ctx.actorId) {
167
+ return { kind: "user", userId: ctx.actorId };
168
+ }
169
+ const label = deriveAnonymousLabel?.(getRequest()) ?? "unauthenticated";
170
+ return { kind: "anonymous", label };
171
+ },
172
+ });
173
+ });
174
+ }
175
+
176
+ // --- Session ----------------------------------------------------------------
177
+
178
+ export interface SessionFactoryOpts<S extends { user: { id: string; role?: string | null } }> {
179
+ /**
180
+ * Guestlist fallback session reader. Apps wire
181
+ * `() => getGuestlist().getSession()`. Errors are swallowed and treated as
182
+ * unauthenticated.
183
+ */
184
+ getGuestlistFallback: () => Promise<S | null>;
185
+ }
186
+
187
+ /**
188
+ * Build an app's `getSession` server-only reader.
189
+ *
190
+ * `getSession(headers)` is WeakMap-memoised per Headers reference (so N
191
+ * callers in the same request share at most one guestlist service-binding RPC).
192
+ * On success, the resolved actor is patched into the active request-context
193
+ * ALS so canonical log lines emit with `actor_*` populated.
194
+ *
195
+ * In the target topology, the bouncer attestation envelope is the fast path
196
+ * — apps that wire `createPlatformStartApp` get envelope-first resolution
197
+ * for free. This raw factory exists for the non-Start path and for apps that
198
+ * want to compose `getSession` manually.
199
+ *
200
+ * **`loadSession` is intentionally NOT shipped from the kit**: TSS's compile
201
+ * plugin requires `createServerFn(...).handler(...)` to live at module
202
+ * top-level (see start-plugin-core's handleCreateServerFn). Wrapping it
203
+ * inside this factory body confuses the AST extraction. Apps build their
204
+ * own `loadSession` in a `lib/session.functions.ts` file instead:
205
+ *
206
+ * ```ts
207
+ * import { createServerFn } from "@tanstack/react-start";
208
+ * import { getRequestHeaders } from "@tanstack/react-start/server";
209
+ * import { platform } from "@/lib/platform";
210
+ *
211
+ * export const loadSession = createServerFn({ method: "GET" }).handler(
212
+ * async () => platform.getSession(getRequestHeaders()),
213
+ * );
214
+ * ```
215
+ */
216
+ export function createSessionFactory<S extends { user: { id: string; role?: string | null } }>(
217
+ opts: SessionFactoryOpts<S>,
218
+ ) {
219
+ const { getGuestlistFallback } = opts;
220
+
221
+ // Per-request memoization. The Headers reference is stable across all
222
+ // reads inside one TanStack request (single `getRequest()` instance), so
223
+ // caching the promise on the Headers means N callers share one guestlist
224
+ // service-binding RPC. Across requests the Headers object is GC'd and the
225
+ // entry vanishes.
226
+ const inflight = new WeakMap<Headers, Promise<S | null>>();
227
+
228
+ return createServerOnlyFn(function getSession(headers: Headers): Promise<S | null> {
229
+ const cached = inflight.get(headers);
230
+ if (cached) return cached;
231
+ const promise = (async () => {
232
+ let session: S | null = null;
233
+ try {
234
+ session = await getGuestlistFallback();
235
+ } catch {
236
+ session = null;
237
+ }
238
+ if (getRequestContext() && session) {
239
+ updateRequestContext({ actorKind: "user", actorId: session.user.id });
240
+ }
241
+ return session;
242
+ })();
243
+ inflight.set(headers, promise);
244
+ return promise;
245
+ });
246
+ }
@@ -0,0 +1,90 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ getActorId,
4
+ getActorKind,
5
+ getCallerApp,
6
+ getRequestContext,
7
+ getRequestId,
8
+ withRequestContext,
9
+ } from "../index";
10
+
11
+ describe("withRequestContext / getRequestContext", () => {
12
+ test("getRequestContext() returns the active context inside scope", async () => {
13
+ let captured: ReturnType<typeof getRequestContext> = null;
14
+ await withRequestContext({ requestId: "req_01" }, async () => {
15
+ captured = getRequestContext();
16
+ });
17
+ expect(captured).toEqual({ requestId: "req_01" });
18
+ });
19
+
20
+ test("getRequestContext() returns null outside any scope", () => {
21
+ expect(getRequestContext()).toBeNull();
22
+ });
23
+
24
+ test("getRequestId() reads request_id from active scope", async () => {
25
+ let id: string | null = null;
26
+ await withRequestContext({ requestId: "req_42" }, async () => {
27
+ id = getRequestId();
28
+ });
29
+ expect(id).toBe("req_42");
30
+ });
31
+
32
+ test("convenience readers return their respective fields", async () => {
33
+ let captured: { kind: string | null; id: string | null; caller: string | null } | null = null;
34
+ await withRequestContext(
35
+ {
36
+ requestId: "req_01",
37
+ actorKind: "user",
38
+ actorId: "user_123",
39
+ callerApp: "chat",
40
+ },
41
+ async () => {
42
+ captured = {
43
+ kind: getActorKind(),
44
+ id: getActorId(),
45
+ caller: getCallerApp(),
46
+ };
47
+ },
48
+ );
49
+ expect(captured).toEqual({ kind: "user", id: "user_123", caller: "chat" });
50
+ });
51
+
52
+ test("scope propagates through nested awaits", async () => {
53
+ async function deep() {
54
+ await new Promise((r) => setTimeout(r, 5));
55
+ return getRequestId();
56
+ }
57
+ async function middle() {
58
+ return deep();
59
+ }
60
+ let result: string | null = null;
61
+ await withRequestContext({ requestId: "req_deep" }, async () => {
62
+ result = await middle();
63
+ });
64
+ expect(result).toBe("req_deep");
65
+ });
66
+
67
+ test("nested withRequestContext shadows outer", async () => {
68
+ const ids: (string | null)[] = [];
69
+ await withRequestContext({ requestId: "outer" }, async () => {
70
+ ids.push(getRequestId());
71
+ await withRequestContext({ requestId: "inner" }, async () => {
72
+ ids.push(getRequestId());
73
+ });
74
+ ids.push(getRequestId());
75
+ });
76
+ expect(ids).toEqual(["outer", "inner", "outer"]);
77
+ });
78
+
79
+ test("readers return null for fields not set", async () => {
80
+ let captured: { kind: string | null; id: string | null; caller: string | null } | null = null;
81
+ await withRequestContext({ requestId: "req_01" }, async () => {
82
+ captured = {
83
+ kind: getActorKind(),
84
+ id: getActorId(),
85
+ caller: getCallerApp(),
86
+ };
87
+ });
88
+ expect(captured).toEqual({ kind: null, id: null, caller: null });
89
+ });
90
+ });
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Request-scoped context — request_id, actor, callerApp — propagated via
3
+ * AsyncLocalStorage. Distinct from `@somewhatintelligent/kit/log`'s `logStorage`
4
+ * (which holds the CanonicalLogBuilder for the active emit scope) — this
5
+ * holds the request-level identity that any number of canonical-log lines
6
+ * inside the same request might want to read.
7
+ *
8
+ * Why two ALSes:
9
+ * - logStorage scope opens/closes per `withCanonicalLog` call (often
10
+ * once per RPC method, server-fn, or HTTP handler).
11
+ * - requestContextStorage scope opens/closes per inbound request and
12
+ * persists across multiple log emissions inside that request.
13
+ *
14
+ * Consumers open the scope at the fetch boundary:
15
+ *
16
+ * import { withRequestContext, getRequestId } from "@somewhatintelligent/kit/request-context";
17
+ * import { ulid } from "@somewhatintelligent/kit/ids";
18
+ *
19
+ * export default {
20
+ * async fetch(request, _env, ctx) {
21
+ * const requestId = request.headers.get("cf-request-id") ?? ulid();
22
+ * return withRequestContext({ requestId }, () => app.fetch(request));
23
+ * },
24
+ * };
25
+ *
26
+ * Then anywhere in the request lifecycle:
27
+ *
28
+ * const requestId = getRequestId(); // reads from ALS, no parameter threading
29
+ *
30
+ * This is the minimal primitive. `withCanonicalLog` may grow to read
31
+ * request_id from this storage when its ctx arg doesn't supply one
32
+ * (cleaner boundary instrumentation), but that's a follow-up — for now
33
+ * boundary wrappers pass request_id explicitly.
34
+ */
35
+ /// <reference types="node" />
36
+ // Namespace import (not `{ AsyncLocalStorage }`) so Vite's client transform
37
+ // doesn't emit a top-level `stub["AsyncLocalStorage"]` property access. The
38
+ // kit/react-start barrel is reachable from app client bundles in dev via
39
+ // `.middleware([...])` runtime references that TSS can't strip; the named
40
+ // destructure form crashes the browser at module init because Vite
41
+ // externalizes `node:async_hooks` to a Proxy that throws on any get. The
42
+ // namespace binding stays inert until `storage()` is called server-side.
43
+ import * as nodeAsyncHooks from "node:async_hooks";
44
+ import type { AsyncLocalStorage as ALS } from "node:async_hooks";
45
+ import { ulid } from "../ids";
46
+
47
+ /**
48
+ * Adopt a request id from inbound headers, falling back to a fresh ULID.
49
+ * Prefers an already-open `withRequestContext` scope so calls made deep in
50
+ * the request lifecycle return the same id the boundary adopted; falls
51
+ * back to header probe (`cf-request-id`, then `x-request-id`) and finally
52
+ * a synthesized ulid.
53
+ *
54
+ * **Use this only at bouncer's public-edge boundary** — bouncer is the
55
+ * platform's sole translator of `cf-*` headers. Every other worker (apps
56
+ * + internal services) reaches request context via `extractPlatformRequestId`
57
+ * below, which speaks the internal `x-platform-rid` contract.
58
+ *
59
+ * import { extractRequestId } from "@somewhatintelligent/kit/request-context";
60
+ * const requestId = extractRequestId(request);
61
+ */
62
+ export function extractRequestId(req: Request): string {
63
+ return (
64
+ storage().getStore()?.requestId ??
65
+ req.headers.get("cf-request-id") ??
66
+ req.headers.get("x-request-id") ??
67
+ ulid()
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Adopt a request id from the platform's internal header contract.
73
+ *
74
+ * Reads `x-platform-rid` (set by bouncer, or by an upstream platform worker
75
+ * forwarding through service binding), otherwise mints a fresh ULID. The
76
+ * canonical reader for any worker that participates in the platform but is
77
+ * not the public-edge bouncer — apps, guestlist, roadie, promoter — should
78
+ * use this rather than `extractRequestId`.
79
+ *
80
+ * Apps run alone in development without bouncer in front; in that topology
81
+ * there is no `x-platform-rid`, the mint branch fires, and logs key off the
82
+ * minted id for the lifetime of that one request.
83
+ *
84
+ * import { extractPlatformRequestId } from "@somewhatintelligent/kit/request-context";
85
+ * const requestId = extractPlatformRequestId(request);
86
+ */
87
+ export function extractPlatformRequestId(req: Request): string {
88
+ return storage().getStore()?.requestId ?? req.headers.get("x-platform-rid") ?? ulid();
89
+ }
90
+
91
+ /**
92
+ * THE canonical "extract the routing host from a request" rule — the single
93
+ * definition shared by bouncer (the public-edge ingress), the dev-envelope
94
+ * stamper that emulates it, and the apps that resolve a host → brand. One place,
95
+ * so the envelope's attested host, the edge routing decision, and an app's
96
+ * host→brand lookup can never disagree.
97
+ *
98
+ * `x-forwarded-host` is the proxy's signal for the ORIGINAL client host (portless
99
+ * in dev rewrites `Host` to the proxy target and moves the real host here).
100
+ * Trusting it in production would let a client spoof the routing host, so it is
101
+ * honored ONLY when `trustForwardedHost` is set (development). Lowercased,
102
+ * port-stripped; null when nothing resolves.
103
+ */
104
+ export function routingHostFromHeaders(
105
+ headers: { get(name: string): string | null },
106
+ opts: { trustForwardedHost: boolean; fallbackHost?: string | null },
107
+ ): string | null {
108
+ const forwarded = opts.trustForwardedHost ? headers.get("x-forwarded-host") : null;
109
+ const raw = forwarded ?? headers.get("host") ?? opts.fallbackHost ?? null;
110
+ return raw ? raw.toLowerCase().split(":")[0]! : null;
111
+ }
112
+
113
+ /**
114
+ * Narrow Actor kind for service-binding RPC. Apps may have richer actor
115
+ * models at their own boundary (e.g. `anonymous` for unauthenticated transfer
116
+ * viewers); they fold those into `user` or `service` when calling RPC.
117
+ */
118
+ export type Actor = { kind: "user"; userId: string } | { kind: "service"; serviceName: string };
119
+
120
+ export interface RequestContext {
121
+ /** Request ID. Adopted from `cf-request-id` (or W3C traceparent) at the entry Worker, propagated through every layer. */
122
+ requestId: string;
123
+ /** Actor identity kind — typically `"user" | "service" | "anonymous"`. */
124
+ actorKind?: string;
125
+ /** Actor identifier — userId for user actors, serviceName for service actors, label for anonymous. Null when unresolved. */
126
+ actorId?: string | null;
127
+ /** For RPC events: which app/service called this method. */
128
+ callerApp?: string;
129
+ /** W3C Trace Context — paves the path to OTEL adoption. Free now (just propagated as-is). */
130
+ traceparent?: string;
131
+ }
132
+
133
+ let _storage: ALS<RequestContext> | undefined;
134
+ function storage(): ALS<RequestContext> {
135
+ return (_storage ??= new nodeAsyncHooks.AsyncLocalStorage<RequestContext>());
136
+ }
137
+
138
+ /**
139
+ * Open a request-scoped context. The fn (and everything in its async
140
+ * call stack) can read the context via `getRequestContext()`,
141
+ * `getRequestId()`, etc. The scope closes when fn resolves; nested
142
+ * `withRequestContext` calls shadow the outer (inner returns its own
143
+ * context until it closes).
144
+ */
145
+ export function withRequestContext<T>(ctx: RequestContext, fn: () => Promise<T>): Promise<T> {
146
+ return storage().run(ctx, fn);
147
+ }
148
+
149
+ /**
150
+ * Patch the active request context in place. Useful at the boundary when
151
+ * the request id is known immediately but actor info isn't resolved until
152
+ * after a session lookup — open the scope with `{ requestId }` first so
153
+ * any I/O during resolution sees the id, then `updateRequestContext({
154
+ * actorKind, actorId })` once the lookup returns. ALS stores a reference,
155
+ * so all readers downstream see the mutation. Throws if called outside
156
+ * a scope.
157
+ */
158
+ export function updateRequestContext(patch: Partial<RequestContext>): void {
159
+ const ctx = storage().getStore();
160
+ if (!ctx) {
161
+ throw new Error("updateRequestContext called outside any withRequestContext scope");
162
+ }
163
+ Object.assign(ctx, patch);
164
+ }
165
+
166
+ /** Read the active request context, or `null` if no scope is open. */
167
+ export function getRequestContext(): RequestContext | null {
168
+ return storage().getStore() ?? null;
169
+ }
170
+
171
+ /** Convenience reader. Returns the request_id from the active scope, or `null`. */
172
+ export function getRequestId(): string | null {
173
+ return storage().getStore()?.requestId ?? null;
174
+ }
175
+
176
+ /** Convenience reader. Returns the active actor kind, or `null`. */
177
+ export function getActorKind(): string | null {
178
+ return storage().getStore()?.actorKind ?? null;
179
+ }
180
+
181
+ /** Convenience reader. Returns the active actor id, or `null`. */
182
+ export function getActorId(): string | null {
183
+ return storage().getStore()?.actorId ?? null;
184
+ }
185
+
186
+ /** Convenience reader. Returns the active caller_app, or `null`. */
187
+ export function getCallerApp(): string | null {
188
+ return storage().getStore()?.callerApp ?? null;
189
+ }
package/src/roles.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Role helpers.
3
+ *
4
+ * `user.role` (Better Auth's admin plugin) is a COMMA-SEPARATED string
5
+ * (e.g. `"admin,user"`) or a `string[]` — the admin plugin splits on commas
6
+ * before matching `adminRoles`. Never compare the raw value with `=== "admin"`:
7
+ * a multi-role user (`"admin,user"`) silently loses access.
8
+ *
9
+ * Client-safe: no imports, no server-only references.
10
+ */
11
+
12
+ /** True when the role value (csv string or array) contains `role`. */
13
+ export function hasRole(
14
+ roles: string | readonly string[] | null | undefined,
15
+ role: string,
16
+ ): boolean {
17
+ if (!roles) return false;
18
+ const list = Array.isArray(roles) ? roles : String(roles).split(",");
19
+ return list.map((r) => r.trim()).includes(role);
20
+ }
21
+
22
+ /** Convenience: `hasRole(roles, "admin")`. */
23
+ export function isAdminRole(roles: string | readonly string[] | null | undefined): boolean {
24
+ return hasRole(roles, "admin");
25
+ }
@@ -0,0 +1,140 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { VERSION_PATH, handleVersionRequest, versionInfo, versionResponse } from "../index";
3
+
4
+ const req = (url: string, method = "GET") => new Request(url, { method });
5
+
6
+ describe("versionInfo", () => {
7
+ test("reads the package stamp + ENVIRONMENT from env", () => {
8
+ expect(
9
+ versionInfo({
10
+ worker: "guestlist",
11
+ env: { ENVIRONMENT: "staging" },
12
+ pkg: { version: "1.2.3", commit: "abc1234" },
13
+ }),
14
+ ).toEqual({
15
+ worker: "guestlist",
16
+ version: "1.2.3",
17
+ commit: "abc1234",
18
+ environment: "staging",
19
+ });
20
+ });
21
+
22
+ test("falls back safely when nothing is injected", () => {
23
+ expect(versionInfo({ worker: "bouncer" })).toEqual({
24
+ worker: "bouncer",
25
+ version: "0.0.0-dev",
26
+ commit: "unknown",
27
+ environment: "development",
28
+ });
29
+ });
30
+
31
+ test("ignores a non-string ENVIRONMENT var; deploy vars are NOT consulted", () => {
32
+ const info = versionInfo({
33
+ worker: "roadie",
34
+ // WORKER_VERSION/WORKER_COMMIT vars are dead — the package stamp is
35
+ // the source of truth; a stray var must not shadow it.
36
+ env: { WORKER_VERSION: "9.9.9", WORKER_COMMIT: "sneaky", ENVIRONMENT: null },
37
+ });
38
+ expect(info.version).toBe("0.0.0-dev");
39
+ expect(info.commit).toBe("unknown");
40
+ expect(info.environment).toBe("development");
41
+ });
42
+
43
+ test("tolerates a non-object env", () => {
44
+ expect(versionInfo({ worker: "promoter", env: "nope" }).version).toBe("0.0.0-dev");
45
+ });
46
+
47
+ test("overrides win over the pkg stamp (vite-define apps)", () => {
48
+ const info = versionInfo({
49
+ worker: "identity",
50
+ env: { ENVIRONMENT: "staging" },
51
+ pkg: { version: "9.9.9", commit: "zzz" },
52
+ overrides: { version: "1.0.0", commit: "def5678" },
53
+ });
54
+ expect(info.version).toBe("1.0.0");
55
+ expect(info.commit).toBe("def5678");
56
+ expect(info.environment).toBe("staging"); // un-overridden field still reads env
57
+ });
58
+ });
59
+
60
+ describe("versionResponse", () => {
61
+ test("serializes as uncacheable JSON", async () => {
62
+ const res = versionResponse({
63
+ worker: "bouncer",
64
+ version: "1.0.0",
65
+ commit: "abc",
66
+ environment: "production",
67
+ });
68
+ expect(res.status).toBe(200);
69
+ expect(res.headers.get("content-type")).toContain("application/json");
70
+ expect(res.headers.get("cache-control")).toBe("no-store");
71
+ expect(await res.json()).toEqual({
72
+ worker: "bouncer",
73
+ version: "1.0.0",
74
+ commit: "abc",
75
+ environment: "production",
76
+ });
77
+ });
78
+ });
79
+
80
+ describe("handleVersionRequest", () => {
81
+ test(`answers GET ${VERSION_PATH} on any host`, async () => {
82
+ const res = handleVersionRequest(req("https://anything.example/__version"), {
83
+ worker: "bouncer",
84
+ env: { ENVIRONMENT: "staging" },
85
+ });
86
+ expect(res).not.toBeNull();
87
+ expect(((await res!.json()) as { worker: string }).worker).toBe("bouncer");
88
+ });
89
+
90
+ test("returns null for other paths (routing proceeds)", () => {
91
+ expect(
92
+ handleVersionRequest(req("https://x.example/health"), { worker: "guestlist" }),
93
+ ).toBeNull();
94
+ // No prefix matching — only exact paths answer.
95
+ expect(
96
+ handleVersionRequest(req("https://x.example/__version/extra"), { worker: "guestlist" }),
97
+ ).toBeNull();
98
+ });
99
+
100
+ test("returns null for non-GET/HEAD methods", () => {
101
+ expect(
102
+ handleVersionRequest(req("https://x.example/__version", "POST"), { worker: "roadie" }),
103
+ ).toBeNull();
104
+ });
105
+
106
+ test("answers HEAD", () => {
107
+ expect(
108
+ handleVersionRequest(req("https://x.example/__version", "HEAD"), { worker: "roadie" }),
109
+ ).not.toBeNull();
110
+ });
111
+
112
+ test("extra mounted paths answer (guestlist behind bouncer's /api passthrough)", async () => {
113
+ const opts = {
114
+ worker: "guestlist",
115
+ paths: ["/__version", "/api/__version"],
116
+ } as const;
117
+ const direct = handleVersionRequest(req("https://x.example/__version"), opts);
118
+ const mounted = handleVersionRequest(req("https://x.example/api/__version"), opts);
119
+ expect(direct).not.toBeNull();
120
+ expect(mounted).not.toBeNull();
121
+ expect(((await mounted!.json()) as { worker: string }).worker).toBe("guestlist");
122
+ });
123
+ });
124
+
125
+ describe("versionInfo pkg stamp", () => {
126
+ test("chain: overrides > pkg stamp > dev placeholders", () => {
127
+ const pkg = { version: "1.2.3", commit: "abc1234" };
128
+ expect(versionInfo({ worker: "w", env: {}, pkg })).toMatchObject({
129
+ version: "1.2.3",
130
+ commit: "abc1234",
131
+ });
132
+ expect(
133
+ versionInfo({ worker: "w", env: {}, pkg, overrides: { version: "o" } }),
134
+ ).toMatchObject({ version: "o", commit: "abc1234" });
135
+ expect(versionInfo({ worker: "w", env: {} })).toMatchObject({
136
+ version: "0.0.0-dev",
137
+ commit: "unknown",
138
+ });
139
+ });
140
+ });