@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 ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@somewhatintelligent/bouncer",
3
+ "version": "0.0.1-beta.1783705886.297c5be",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/apostolos-geyer/platform.git",
12
+ "directory": "packages/bouncer"
13
+ },
14
+ "exports": {
15
+ ".": "./src/index.ts"
16
+ },
17
+ "files": [
18
+ "src"
19
+ ],
20
+ "scripts": {
21
+ "typecheck": "tsc --noEmit",
22
+ "test": "vitest run"
23
+ },
24
+ "dependencies": {
25
+ "@somewhatintelligent/auth": "^0.0.0",
26
+ "@somewhatintelligent/kit": "^0.0.0",
27
+ "arktype": "^2.2.0",
28
+ "cookie-es": "^3.1.1"
29
+ },
30
+ "devDependencies": {
31
+ "@cloudflare/workers-types": "^4",
32
+ "typescript": "5.9.2",
33
+ "vitest": "^4.1.10"
34
+ }
35
+ }
package/src/env.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The env contract `createBouncer` requires from the consumer's worker.
3
+ * Explicit interface (not the wrangler-generated ambient) so the shape
4
+ * travels with the source — consumers assert their generated
5
+ * `worker-configuration.d.ts` against this in their shim.
6
+ *
7
+ * Route bindings (IDENTITY, STORE, …) are additional `Fetcher` properties
8
+ * the consumer declares on its own env type; `ROUTES` references them by
9
+ * name and they are resolved dynamically per matched route.
10
+ */
11
+
12
+ /**
13
+ * The session-authority RPC surface bouncer needs from its GUESTLIST
14
+ * binding. Structural on purpose: bouncer does not depend on
15
+ * @somewhatintelligent/guestlist — any WorkerEntrypoint satisfying this shape works.
16
+ * @somewhatintelligent/guestlist's `Guestlist` entrypoint satisfies it.
17
+ */
18
+ export interface SessionAuthority {
19
+ getSession(input: {
20
+ cookie: string;
21
+ }): Promise<{ session: unknown; setCookies: string[] }>;
22
+ }
23
+
24
+ export interface BouncerEnv {
25
+ ENVIRONMENT: string;
26
+ /**
27
+ * Route table, JSON (string or object var). Schema + compilation in
28
+ * ./routes.ts — hosts, mounts, modes (passthrough | vmf | redirect).
29
+ */
30
+ ROUTES: string | object;
31
+ /** Guestlist service binding with `"entrypoint": "Guestlist"` (RPC + fetch). */
32
+ GUESTLIST: Fetcher & SessionAuthority;
33
+ /** Ed25519 PKCS8 private key (PEM) signing `x-platform-att` envelopes. Secret. */
34
+ BNC_ATT_PRIV: string;
35
+ /** Key id stamped into envelope JOSE headers (rotation support). */
36
+ BNC_ATT_KID: string;
37
+ /** Optional JSON array of extra asset prefixes for vmf rewriting. */
38
+ ASSET_PREFIXES?: string;
39
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Per-isolate cached envelope stamper. The CryptoKey import inside
3
+ * `createEnvelopeStamper` is async; we memoize the resolved stamper per
4
+ * `Env` reference so each isolate signs in <1ms after the first request.
5
+ */
6
+ import { createEnvelopeStamper, type EnvelopeStamper } from "@somewhatintelligent/auth";
7
+ import type { BouncerEnv } from "./env";
8
+ import { createBouncerSessionResolver, type BouncerSessionOptions } from "./session";
9
+
10
+ type StamperEnv = Pick<BouncerEnv, "GUESTLIST" | "BNC_ATT_PRIV" | "BNC_ATT_KID" | "ENVIRONMENT">;
11
+
12
+ const stamperCache = new WeakMap<StamperEnv, Promise<EnvelopeStamper>>();
13
+
14
+ export function getStamper(
15
+ env: StamperEnv,
16
+ resolveHost: (request: Request) => string,
17
+ session: BouncerSessionOptions,
18
+ ): Promise<EnvelopeStamper> {
19
+ let p = stamperCache.get(env);
20
+ if (!p) {
21
+ p = createEnvelopeStamper({
22
+ sessionResolver: createBouncerSessionResolver(env, session),
23
+ minter: { privPem: env.BNC_ATT_PRIV, kid: env.BNC_ATT_KID },
24
+ resolveHost,
25
+ });
26
+ stamperCache.set(env, p);
27
+ }
28
+ return p;
29
+ }
package/src/index.ts ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * @somewhatintelligent/bouncer — the public-edge ingress as a library.
3
+ *
4
+ * A consumer's bouncer worker is a thin shim:
5
+ *
6
+ * ```ts
7
+ * import { createBouncer } from "@somewhatintelligent/bouncer";
8
+ * export default createBouncer({ cookiePrefix: "acme" });
9
+ * ```
10
+ *
11
+ * Behavior (unchanged from the donor): resolve/refresh the session via the
12
+ * guestlist RPC binding, mint the Ed25519 attestation envelope, match the
13
+ * env-var `ROUTES` table, dispatch passthrough/vmf/redirect, strip + stamp
14
+ * `x-platform-*` headers both ways, and forward BA cookie refreshes.
15
+ */
16
+ import { withRequestLog } from "@somewhatintelligent/kit/log";
17
+ import {
18
+ extractRequestId,
19
+ routingHostFromHeaders,
20
+ updateRequestContext,
21
+ withRequestContext,
22
+ } from "@somewhatintelligent/kit/request-context";
23
+ import { handleVersionRequest } from "@somewhatintelligent/kit/version";
24
+ import { PKG } from "./version.gen";
25
+ import {
26
+ buildAssetPrefixes,
27
+ handleMountedApp,
28
+ handlePassthrough,
29
+ stampUpstreamHeaders,
30
+ stripPlatformResponseHeaders,
31
+ } from "./proxy";
32
+ import { compileRoutes, matchRoute, type CompiledRoute } from "./routes";
33
+ import { mergeCookiesIntoRequest } from "./session";
34
+ import { getStamper } from "./envelope";
35
+ import type { BouncerEnv } from "./env";
36
+
37
+ export interface BouncerConfig {
38
+ /** Cookie name prefix — must match guestlist's `cookiePrefix`. */
39
+ cookiePrefix: string;
40
+ /** Worker name for /__version + log lines. @default "bouncer" */
41
+ serviceName?: string;
42
+ }
43
+
44
+ interface CompiledConfig {
45
+ routes: CompiledRoute[];
46
+ smoothTransitions?: boolean;
47
+ assetPrefixes: string[];
48
+ }
49
+
50
+ export function createBouncer<TEnv extends BouncerEnv = BouncerEnv>(config: BouncerConfig) {
51
+ const serviceName = config.serviceName ?? "bouncer";
52
+ const configCache = new WeakMap<TEnv, CompiledConfig>();
53
+
54
+ // THE routing host for a request: the bouncer-visible hostname every
55
+ // host-keyed decision (route matching, envelope stamping) must agree on.
56
+ // x-forwarded-host is a dev-only proxy signal; trusting it in production
57
+ // would let a client spoof the routing host, so it's honored only when
58
+ // ENVIRONMENT is development. One definition, used for the outer host AND
59
+ // passed to getStamper (the stamper invokes it per request against the
60
+ // request it's stamping — never a closed-over host, or the per-isolate
61
+ // memoization would freeze the first request's host into every envelope).
62
+ function resolveRoutingHost(request: Request, env: TEnv): string {
63
+ return routingHostFromHeaders(request.headers, {
64
+ trustForwardedHost: env.ENVIRONMENT === "development",
65
+ fallbackHost: new URL(request.url).hostname,
66
+ })!;
67
+ }
68
+
69
+ function getFetcher(env: TEnv, bindingName: string): Fetcher {
70
+ // Route bindings (GUESTLIST, IDENTITY, …) are statically declared on
71
+ // the consumer's Env but resolved dynamically per-route.
72
+ const upstream: unknown = Reflect.get(env, bindingName);
73
+ if (
74
+ !upstream ||
75
+ typeof upstream !== "object" ||
76
+ typeof (upstream as { fetch?: unknown }).fetch !== "function"
77
+ ) {
78
+ throw new Error(`bouncer: route binding "${bindingName}" is not a bound Fetcher`);
79
+ }
80
+ return upstream as Fetcher;
81
+ }
82
+
83
+ function loadConfig(env: TEnv): CompiledConfig {
84
+ let cfg = configCache.get(env);
85
+ if (cfg) return cfg;
86
+ const raw: unknown = env.ROUTES;
87
+ let parsed: unknown;
88
+ if (typeof raw === "string") {
89
+ try {
90
+ parsed = JSON.parse(raw);
91
+ } catch (e) {
92
+ throw new Error(
93
+ `bouncer: ROUTES is not valid JSON — ${e instanceof Error ? e.message : String(e)}`,
94
+ );
95
+ }
96
+ } else {
97
+ parsed = raw;
98
+ }
99
+ const compiled = compileRoutes(parsed);
100
+ cfg = {
101
+ routes: compiled.routes,
102
+ smoothTransitions: compiled.smoothTransitions,
103
+ assetPrefixes: buildAssetPrefixes(env),
104
+ };
105
+ configCache.set(env, cfg);
106
+ return cfg;
107
+ }
108
+
109
+ return {
110
+ async fetch(request: Request, env: TEnv, _ctx: ExecutionContext): Promise<Response> {
111
+ // Version endpoint, answered BEFORE route matching so it works on
112
+ // every host bouncer fronts. Mounted apps' endpoints stay reachable
113
+ // through their mounts (/account/__version vmf-strips to identity's
114
+ // /__version; /api/__version passes through to guestlist untouched).
115
+ const version = handleVersionRequest(request, { worker: serviceName, env, pkg: PKG });
116
+ if (version) return version;
117
+
118
+ // Bouncer is public-edge ingress: no upstream caller, so the request
119
+ // context carries only the request id.
120
+ return withRequestContext({ requestId: extractRequestId(request) }, () =>
121
+ withRequestLog({ service: serviceName }, request, async (log) => {
122
+ const url = new URL(request.url);
123
+ const host = resolveRoutingHost(request, env);
124
+ log.add({ host });
125
+
126
+ const stamp = await getStamper(env, (req) => resolveRoutingHost(req, env), {
127
+ cookiePrefix: config.cookiePrefix,
128
+ });
129
+ const { envelope, setCookies, actor, activeOrgId } = await stamp(request);
130
+ if (actor) updateRequestContext({ actorKind: "user", actorId: actor.id });
131
+ log.add({
132
+ refreshed: setCookies.length > 0,
133
+ ...(activeOrgId && { active_org_id: activeOrgId }),
134
+ });
135
+
136
+ const cfg = loadConfig(env);
137
+ const match = matchRoute(cfg.routes, host, url.pathname);
138
+
139
+ const refreshedReq = mergeCookiesIntoRequest(request, setCookies);
140
+
141
+ let response: Response;
142
+ if (match && match.route.mode === "redirect") {
143
+ // Bouncer answers directly — no upstream binding, no stamping.
144
+ log.add({
145
+ dispatch: "redirect",
146
+ mode: "redirect",
147
+ redirect_to: match.route.redirectTo,
148
+ });
149
+ response = new Response(null, {
150
+ status: match.route.redirectStatus ?? 308,
151
+ headers: { location: match.route.redirectTo! },
152
+ });
153
+ } else if (match) {
154
+ const upstream = getFetcher(env, match.route.bindingName!);
155
+ const fwdReq = stampUpstreamHeaders(refreshedReq, envelope, actor);
156
+ if (match.route.mode === "passthrough") {
157
+ log.add({
158
+ dispatch: "passthrough",
159
+ mode: "passthrough",
160
+ route_binding: match.route.bindingName,
161
+ });
162
+ response = await handlePassthrough(fwdReq, upstream);
163
+ } else {
164
+ const preloadStaticMounts = cfg.routes
165
+ .filter(
166
+ (r) =>
167
+ r.preload &&
168
+ r.isStaticMount &&
169
+ r.staticMount &&
170
+ r.staticMount !== match.mountActual,
171
+ )
172
+ .map((r) => r.staticMount!);
173
+ log.add({
174
+ dispatch: "vmf",
175
+ mode: "vmf",
176
+ route_binding: match.route.bindingName,
177
+ ...(match.mountActual !== "/" && { mount: match.mountActual }),
178
+ });
179
+ response = await handleMountedApp(
180
+ fwdReq,
181
+ upstream,
182
+ match.mountActual,
183
+ cfg.assetPrefixes,
184
+ {
185
+ smoothTransitions: cfg.smoothTransitions,
186
+ preloadStaticMounts: preloadStaticMounts.length
187
+ ? preloadStaticMounts
188
+ : undefined,
189
+ },
190
+ );
191
+ }
192
+ } else {
193
+ // Unmatched site: every public host is an explicit bouncer-owned
194
+ // Custom Domain; there's no fall-through upstream. 404 here also
195
+ // kills the dev-mode fetch recursion the prior fallback exposed.
196
+ log.add({ dispatch: "not_found" });
197
+ response = new Response("Not Found", {
198
+ status: 404,
199
+ headers: { "content-type": "text/plain; charset=utf-8" },
200
+ });
201
+ }
202
+
203
+ // The envelope is internal-only and must not leak to the browser.
204
+ response = stripPlatformResponseHeaders(response);
205
+
206
+ if (setCookies.length > 0) {
207
+ const outHeaders = new Headers(response.headers);
208
+ for (const sc of setCookies) outHeaders.append("set-cookie", sc);
209
+ response = new Response(response.body, {
210
+ status: response.status,
211
+ statusText: response.statusText,
212
+ headers: outHeaders,
213
+ });
214
+ }
215
+
216
+ log.add({ status: response.status });
217
+ if (response.status >= 500) log.outcome("internal_error");
218
+ else if (response.status >= 400) log.outcome(`http_${response.status}`);
219
+ return response;
220
+ }),
221
+ );
222
+ },
223
+ };
224
+ }
225
+
226
+ export type { BouncerEnv, SessionAuthority } from "./env";
227
+ export { compileRoutes, matchRoute, type CompiledRoute } from "./routes";
228
+ export { createBouncerSessionResolver, mergeCookiesIntoRequest } from "./session";