@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/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
|
+
}
|
package/src/envelope.ts
ADDED
|
@@ -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";
|