@somewhatintelligent/bouncer 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/src/routes.ts ADDED
@@ -0,0 +1,328 @@
1
+ // Path-syntax and regex compilation lifted from the Cloudflare microfrontend
2
+ // template. Optional `host` is the bouncer extension: when present, restricts
3
+ // the route to a specific hostname (or `*.platform.example` single-label wildcard).
4
+ // `host` accepts a string or an array — array entries are expanded at compile
5
+ // time into one CompiledRoute per host so the matcher stays a single sweep.
6
+ // When absent, the route matches any host (template behavior).
7
+ // Specificity: exact host > wildcard host > no host.
8
+ //
9
+ // The wire schema is validated by arktype: misconfiguration is a boot failure
10
+ // surfaced with a precise field path (e.g. `routes[3].binding must be a
11
+ // string`) rather than the prior hand-rolled "Invalid route entry" error.
12
+ import { type } from "arktype";
13
+
14
+ // `passthrough` (default): one upstream owns this MOUNT fully — bouncer is a
15
+ // transparent reverse proxy. No URL/asset/cookie/redirect rewriting.
16
+ // `vmf`: one or more mount-naive upstreams under this MOUNT — bouncer runs
17
+ // the full Cloudflare-microfrontend transformation pipeline (path strip,
18
+ // asset-prefix rewriting, Location/cookie path scoping, preload injection).
19
+ // The mounted app serves at its OWN root (bouncer strips the mount inbound)
20
+ // and stays entirely prefix-free server-side; bouncer rewrites the outbound
21
+ // artifacts (asset paths, Location, Set-Cookie paths) back under the mount.
22
+ // The identity (`/account`) and store (`/shop`) SPAs are mounted this way.
23
+ // The one thing vmf cannot rewrite is a hydrated client router's history/link
24
+ // state — so each SPA closes that gap itself with a CLIENT-ONLY router
25
+ // `basepath` fed by a single `PUBLIC_BASE` config value (see decision #14 in
26
+ // the donor's decision log).
27
+ // `redirect`: bouncer answers directly with a Location redirect — no
28
+ // upstream `binding` involved at all.
29
+ // Mode is fixed per (host, mount) — e.g. one host can freely run `/api` in
30
+ // passthrough and `/account` in vmf, since dispatch (index.ts) already picks
31
+ // mode per matched route and `handleMountedApp` only ever rewrites the
32
+ // response for the mount it matched. What's rejected is the SAME mount on
33
+ // the SAME host declared in two different modes — see compileRoutes' mode-
34
+ // consistency check. `redirect` is exempt even from that: it never touches
35
+ // the passthrough/vmf asset-handling contract the rule protects, so it's
36
+ // free to coexist with either mode on the same mount too.
37
+ const RouteModeSchema = type("'passthrough' | 'vmf'");
38
+ const RedirectStatusSchema = type("301 | 302 | 307 | 308");
39
+
40
+ // Passthrough/vmf routes proxy to an upstream `binding`; redirect routes
41
+ // answer directly with a Location header and carry no binding at all.
42
+ const ProxyRouteConfigSchema = type({
43
+ binding: "string > 0",
44
+ "host?": "string | string[]",
45
+ path: "string > 0",
46
+ "preload?": "boolean",
47
+ "mode?": RouteModeSchema,
48
+ });
49
+
50
+ const RedirectRouteConfigSchema = type({
51
+ "host?": "string | string[]",
52
+ path: "string > 0",
53
+ mode: "'redirect'",
54
+ to: "string > 0",
55
+ "status?": RedirectStatusSchema,
56
+ });
57
+
58
+ const RouteConfigSchema = ProxyRouteConfigSchema.or(RedirectRouteConfigSchema);
59
+
60
+ const RoutesConfigSchema = type({
61
+ "smoothTransitions?": "boolean",
62
+ routes: RouteConfigSchema.array(),
63
+ });
64
+
65
+ type RouteMode = typeof RouteModeSchema.infer | "redirect";
66
+ type RedirectStatus = typeof RedirectStatusSchema.infer;
67
+
68
+ export type CompiledRoute = {
69
+ expr: string;
70
+ bindingName?: string;
71
+ host?: string;
72
+ hostIsWildcard: boolean;
73
+ hostWildcardTail?: string;
74
+ preload?: boolean;
75
+ mode: RouteMode;
76
+ redirectTo?: string;
77
+ redirectStatus?: RedirectStatus;
78
+ re: RegExp;
79
+ isStaticMount: boolean;
80
+ staticMount?: string;
81
+ baseSpecificity: number;
82
+ };
83
+
84
+ function normalizePath(p: string): string {
85
+ if (!p.startsWith("/")) p = "/" + p;
86
+ if (p !== "/" && p.endsWith("/")) p = p.slice(0, -1);
87
+ return p;
88
+ }
89
+
90
+ function escapeRegexLiteral(s: string): string {
91
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
92
+ }
93
+
94
+ function unescapePathLiterals(s: string): string {
95
+ return s.replace(/\\(.)/g, "$1");
96
+ }
97
+
98
+ /**
99
+ * Convert a single path-segment expression to a regex pattern.
100
+ * Supports literals, `:name`, `:name(pattern)`, embedded params, and
101
+ * backslash-escaped literals. Throws on unclosed `(...)`.
102
+ */
103
+ function segmentToRegex(segmentExpr: string): string {
104
+ let out = "";
105
+ let i = 0;
106
+ while (i < segmentExpr.length) {
107
+ const ch = segmentExpr[i];
108
+ if (ch === "\\") {
109
+ if (i + 1 < segmentExpr.length) {
110
+ out += escapeRegexLiteral(segmentExpr[i + 1]!);
111
+ i += 2;
112
+ } else {
113
+ i += 1;
114
+ }
115
+ continue;
116
+ }
117
+ if (ch === ":") {
118
+ const nameMatch = segmentExpr.slice(i).match(/^:([A-Za-z0-9_]+)/);
119
+ if (!nameMatch) throw new Error(`Invalid param in segment: "${segmentExpr}"`);
120
+ const name = nameMatch[1]!;
121
+ i += 1 + name.length;
122
+ if (segmentExpr[i] === "(") {
123
+ let depth = 0;
124
+ let j = i;
125
+ for (; j < segmentExpr.length; j++) {
126
+ const c = segmentExpr[j]!;
127
+ if (c === "\\" && j + 1 < segmentExpr.length) {
128
+ j++;
129
+ continue;
130
+ }
131
+ if (c === "(") depth++;
132
+ if (c === ")") {
133
+ depth--;
134
+ if (depth === 0) break;
135
+ }
136
+ }
137
+ if (j >= segmentExpr.length) {
138
+ throw new Error(`Unclosed (...) in segment: "${segmentExpr}"`);
139
+ }
140
+ const inner = segmentExpr.slice(i + 1, j);
141
+ out += `(${unescapePathLiterals(inner)})`;
142
+ i = j + 1;
143
+ } else {
144
+ out += "([^/]+)";
145
+ }
146
+ continue;
147
+ }
148
+ out += escapeRegexLiteral(ch!);
149
+ i++;
150
+ }
151
+ return out;
152
+ }
153
+
154
+ function computeBaseSpecificity(expr: string): number {
155
+ const idx = expr.indexOf(":");
156
+ const prefix = idx === -1 ? expr : expr.slice(0, idx);
157
+ return prefix.length;
158
+ }
159
+
160
+ function compilePathExpr(exprRaw: string): {
161
+ re: RegExp;
162
+ isStaticMount: boolean;
163
+ staticMount?: string;
164
+ } {
165
+ const expr = normalizePath(exprRaw.trim());
166
+ const isStaticMount =
167
+ !expr.includes(":") && !expr.includes("(") && !expr.includes(")") && !expr.includes("\\");
168
+ if (isStaticMount) {
169
+ const re = new RegExp(`^(${escapeRegexLiteral(expr)})(?:/.*)?$`);
170
+ return { re, isStaticMount: true, staticMount: expr };
171
+ }
172
+ const parts = expr.split("/").filter(Boolean);
173
+ const last = parts[parts.length - 1] ?? "";
174
+ const mStarPlus = last.match(/^:([A-Za-z0-9_]+)([*+])$/);
175
+ let mountPattern = "^/";
176
+ for (let i = 0; i < parts.length; i++) {
177
+ const part = parts[i]!;
178
+ if (mStarPlus && i === parts.length - 1) break;
179
+ mountPattern += segmentToRegex(part);
180
+ if (i < parts.length - 1 && !(mStarPlus && i === parts.length - 2)) {
181
+ mountPattern += "/";
182
+ }
183
+ }
184
+ mountPattern = mountPattern.replace(/\/$/, "");
185
+ if (mStarPlus) {
186
+ const op = mStarPlus[2];
187
+ if (op === "*") return { re: new RegExp(`^(${mountPattern})(?:/.*)?$`), isStaticMount: false };
188
+ return { re: new RegExp(`^(${mountPattern})/.+$`), isStaticMount: false };
189
+ }
190
+ return { re: new RegExp(`^(${mountPattern})(?:/.*)?$`), isStaticMount: false };
191
+ }
192
+
193
+ // Throws on malformed config — misconfiguration is a boot failure, not per-request.
194
+ export function compileRoutes(input: unknown): {
195
+ routes: CompiledRoute[];
196
+ smoothTransitions?: boolean;
197
+ } {
198
+ const parsed = RoutesConfigSchema(input);
199
+ if (parsed instanceof type.errors) {
200
+ throw new Error(`ROUTES validation failed: ${parsed.summary}`);
201
+ }
202
+ const compiled: CompiledRoute[] = [];
203
+ for (const rec of parsed.routes) {
204
+ const expr = normalizePath(rec.path);
205
+ const { re, isStaticMount, staticMount } = compilePathExpr(expr);
206
+ const hosts: Array<string | undefined> = Array.isArray(rec.host)
207
+ ? rec.host.length === 0
208
+ ? [undefined]
209
+ : rec.host
210
+ : [rec.host];
211
+ for (const host of hosts) {
212
+ const hostIsWildcard = !!host?.startsWith("*.");
213
+ const shared = {
214
+ expr,
215
+ host,
216
+ hostIsWildcard,
217
+ hostWildcardTail: hostIsWildcard ? host!.slice(1) : undefined,
218
+ re,
219
+ isStaticMount,
220
+ staticMount,
221
+ baseSpecificity: computeBaseSpecificity(expr),
222
+ };
223
+ if (rec.mode === "redirect") {
224
+ compiled.push({
225
+ ...shared,
226
+ mode: "redirect",
227
+ redirectTo: rec.to,
228
+ redirectStatus: rec.status ?? 308,
229
+ });
230
+ } else {
231
+ compiled.push({
232
+ ...shared,
233
+ bindingName: rec.binding,
234
+ preload: rec.preload,
235
+ mode: rec.mode ?? "passthrough",
236
+ });
237
+ }
238
+ }
239
+ }
240
+ // Per-(host, mount) mode consistency: the SAME mount on the SAME host can't
241
+ // be declared in two different modes — which one would actually dispatch is
242
+ // ambiguous and almost certainly a config bug, so reject at boot. This is
243
+ // narrower than the original per-HOST rule: passthrough and vmf now legally
244
+ // coexist on one host as long as they own DIFFERENT mounts (e.g. `/api`
245
+ // passthrough + `/account` vmf on the same apex) — dispatch in index.ts
246
+ // already picks per-route mode at match time, so there's no cross-mount
247
+ // asset/cookie/redirect-rewriting conflict for `handleMountedApp` to worry
248
+ // about; it only ever touches the response for the mount it matched.
249
+ // `redirect` is exempt entirely — it never touches the passthrough/vmf
250
+ // asset-handling contract this rule protects, so it's free to coexist with
251
+ // either mode on the same mount too. Wildcard hosts are checked separately
252
+ // from exact hosts since they're distinct match tiers (same as before).
253
+ const mountModes = new Map<string, RouteMode>();
254
+ for (const r of compiled) {
255
+ if (r.host === undefined || r.mode === "redirect") continue;
256
+ const key = `${r.host}${r.expr}`;
257
+ const prev = mountModes.get(key);
258
+ if (prev === undefined) mountModes.set(key, r.mode);
259
+ else if (prev !== r.mode) {
260
+ throw new Error(
261
+ `ROUTES validation failed: host "${r.host}" mount "${r.expr}" has routes in both "${prev}" and "${r.mode}" mode; one mount must use a single mode.`,
262
+ );
263
+ }
264
+ }
265
+ compiled.sort((a, b) => {
266
+ const aTier = a.host ? (a.hostIsWildcard ? 1 : 0) : 2;
267
+ const bTier = b.host ? (b.hostIsWildcard ? 1 : 0) : 2;
268
+ if (aTier !== bTier) return aTier - bTier;
269
+ if (b.baseSpecificity !== a.baseSpecificity) return b.baseSpecificity - a.baseSpecificity;
270
+ return b.expr.length - a.expr.length;
271
+ });
272
+ return {
273
+ routes: compiled,
274
+ smoothTransitions: parsed.smoothTransitions,
275
+ };
276
+ }
277
+
278
+ export type RouteMatch = {
279
+ route: CompiledRoute;
280
+ mountActual: string;
281
+ };
282
+
283
+ export function matchRoute(
284
+ routes: CompiledRoute[],
285
+ host: string,
286
+ pathname: string,
287
+ ): RouteMatch | null {
288
+ let best: { match: RouteMatch; score: number; hostTier: number } | null = null;
289
+ let rootRoute: { route: CompiledRoute; hostTier: number } | null = null;
290
+ for (const route of routes) {
291
+ let hostMatch = false;
292
+ let hostTier = 2; // any-host
293
+ if (route.host) {
294
+ if (route.hostIsWildcard) {
295
+ const tail = route.hostWildcardTail!;
296
+ if (host.endsWith(tail) && host.length > tail.length) {
297
+ const head = host.slice(0, -tail.length);
298
+ if (head.length > 0 && !head.includes(".")) {
299
+ hostMatch = true;
300
+ hostTier = 1;
301
+ }
302
+ }
303
+ } else if (route.host === host) {
304
+ hostMatch = true;
305
+ hostTier = 0;
306
+ }
307
+ } else {
308
+ hostMatch = true;
309
+ }
310
+ if (!hostMatch) continue;
311
+
312
+ if (route.staticMount === "/" && (!rootRoute || hostTier < rootRoute.hostTier)) {
313
+ rootRoute = { route, hostTier };
314
+ }
315
+
316
+ const m = route.re.exec(pathname);
317
+ if (!m) continue;
318
+ const mountActual = normalizePath(m[1]!);
319
+ const score =
320
+ mountActual.length * 1_000_000 + route.baseSpecificity * 1_000 + route.expr.length;
321
+ if (!best || hostTier < best.hostTier || (hostTier === best.hostTier && score > best.score)) {
322
+ best = { match: { route, mountActual }, score, hostTier };
323
+ }
324
+ }
325
+ if (best) return best.match;
326
+ if (rootRoute) return { route: rootRoute.route, mountActual: "/" };
327
+ return null;
328
+ }
package/src/session.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Bouncer-side session resolution over the guestlist WorkerEntrypoint RPC.
3
+ * `getSession` returns the refreshed session AND any Set-Cookie strings BA
4
+ * wrote (cache rotation, session refresh) so the edge can propagate them
5
+ * on the response.
6
+ */
7
+ import { parse as parseCookieHeader } from "cookie-es";
8
+ import type { SessionResolver, SessionResolverResult, StampableSession } from "@somewhatintelligent/auth";
9
+ import type { BouncerEnv } from "./env";
10
+
11
+ export interface BouncerSessionOptions {
12
+ /**
13
+ * Cookie name prefix (must match guestlist's `cookiePrefix`). Used only
14
+ * as a fast-path skip: requests without `{prefix}.session_token` in the
15
+ * Cookie header resolve to anonymous without an RPC.
16
+ */
17
+ cookiePrefix: string;
18
+ }
19
+
20
+ export function createBouncerSessionResolver(
21
+ env: Pick<BouncerEnv, "GUESTLIST">,
22
+ opts: BouncerSessionOptions,
23
+ ): SessionResolver {
24
+ const sessionTokenCookie = `${opts.cookiePrefix}.session_token`;
25
+ return async function resolve(request: Request): Promise<SessionResolverResult> {
26
+ const cookieHeader = request.headers.get("cookie");
27
+ if (!cookieHeader || !cookieHeader.includes(sessionTokenCookie)) {
28
+ return { session: null, setCookies: [] };
29
+ }
30
+ try {
31
+ const { session, setCookies } = await env.GUESTLIST.getSession({ cookie: cookieHeader });
32
+ return { session: session as StampableSession | null, setCookies };
33
+ } catch (err) {
34
+ // Fail open: an unreachable guestlist must not take the whole edge
35
+ // down — the request proceeds anonymous and apps' own gates apply.
36
+ console.warn("bouncer:resolveSession failed open", {
37
+ message: err instanceof Error ? err.message : String(err),
38
+ });
39
+ return { session: null, setCookies: [] };
40
+ }
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Merge BA-emitted Set-Cookie values into the forwarded request's Cookie
46
+ * header so the downstream app sees the rotated session_data cookie on its
47
+ * own subsequent guestlist calls (e.g. `loadSession`). Without this, the
48
+ * app would re-issue with the pre-rotation cookie and BA would refresh
49
+ * again.
50
+ */
51
+ export function mergeCookiesIntoRequest(request: Request, setCookies: string[]): Request {
52
+ if (setCookies.length === 0) return request;
53
+ const incoming = request.headers.get("cookie") ?? "";
54
+ const parsed = parseCookieHeader(incoming);
55
+ const merged: Record<string, string> = { ...parsed } as Record<string, string>;
56
+ for (const sc of setCookies) {
57
+ const eq = sc.indexOf("=");
58
+ if (eq <= 0) continue;
59
+ const name = sc.slice(0, eq);
60
+ const semi = sc.indexOf(";", eq);
61
+ const value = sc.slice(eq + 1, semi === -1 ? undefined : semi);
62
+ merged[name] = value;
63
+ }
64
+ const newCookieHeader = Object.entries(merged)
65
+ .map(([n, v]) => `${n}=${v}`)
66
+ .join("; ");
67
+ const headers = new Headers(request.headers);
68
+ headers.set("cookie", newCookieHeader);
69
+ return new Request(request, { headers });
70
+ }
@@ -0,0 +1,7 @@
1
+ // AUTO-STAMPED by scripts/stamp-versions.ts at release — the checked-in
2
+ // values are dev placeholders, identical to the runtime fallbacks.
3
+ export const PKG = {
4
+ name: "@somewhatintelligent/bouncer",
5
+ version: "0.0.1-beta.1783705886.297c5be",
6
+ commit: "297c5be7d3af822a7f5da906acf27a076332609c",
7
+ } as const;