@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/package.json +35 -0
- package/src/env.ts +39 -0
- package/src/envelope.ts +29 -0
- package/src/index.ts +228 -0
- package/src/proxy.ts +804 -0
- package/src/routes.ts +328 -0
- package/src/session.ts +70 -0
- package/src/version.gen.ts +7 -0
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;
|