@ggui-ai/mcp-server 0.6.3 → 0.8.0

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.
Files changed (42) hide show
  1. package/dist/api-renders-routes.d.ts.map +1 -1
  2. package/dist/api-renders-routes.js +9 -1
  3. package/dist/browser-cors.d.ts +29 -0
  4. package/dist/browser-cors.d.ts.map +1 -0
  5. package/dist/browser-cors.js +64 -0
  6. package/dist/build-mcp.d.ts.map +1 -1
  7. package/dist/build-mcp.js +5 -1
  8. package/dist/code-store-fs.d.ts +3 -0
  9. package/dist/code-store-fs.d.ts.map +1 -1
  10. package/dist/code-store-fs.js +27 -3
  11. package/dist/console-session-routes.d.ts +10 -5
  12. package/dist/console-session-routes.d.ts.map +1 -1
  13. package/dist/console-session-routes.js +10 -5
  14. package/dist/ggui-session-channel/outbound.d.ts.map +1 -1
  15. package/dist/ggui-session-channel/outbound.js +12 -0
  16. package/dist/ggui-session-channel/socket-router.d.ts +33 -0
  17. package/dist/ggui-session-channel/socket-router.d.ts.map +1 -1
  18. package/dist/ggui-session-channel/socket-router.js +155 -0
  19. package/dist/ggui-session-channel/subscribe.d.ts.map +1 -1
  20. package/dist/ggui-session-channel/subscribe.js +43 -2
  21. package/dist/ggui-session-channel.d.ts +114 -0
  22. package/dist/ggui-session-channel.d.ts.map +1 -1
  23. package/dist/ggui-session-channel.js +77 -2
  24. package/dist/health-routes.d.ts +3 -0
  25. package/dist/health-routes.d.ts.map +1 -1
  26. package/dist/health-routes.js +11 -1
  27. package/dist/mcp-apps-outbound.d.ts +236 -31
  28. package/dist/mcp-apps-outbound.d.ts.map +1 -1
  29. package/dist/mcp-apps-outbound.js +940 -261
  30. package/dist/origin-validation.d.ts +120 -0
  31. package/dist/origin-validation.d.ts.map +1 -0
  32. package/dist/origin-validation.js +199 -0
  33. package/dist/render-read-gate.d.ts +50 -0
  34. package/dist/render-read-gate.d.ts.map +1 -0
  35. package/dist/render-read-gate.js +36 -0
  36. package/dist/runtime-bundle-route.d.ts +15 -0
  37. package/dist/runtime-bundle-route.d.ts.map +1 -1
  38. package/dist/runtime-bundle-route.js +20 -4
  39. package/dist/server.d.ts +227 -9
  40. package/dist/server.d.ts.map +1 -1
  41. package/dist/server.js +292 -28
  42. package/package.json +12 -12
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Origin/Host validation for the MCP wire — the DNS-rebinding defense
3
+ * (ggui#438a).
4
+ *
5
+ * Spec (Streamable HTTP, 2025-11-25): "Servers MUST validate the Origin
6
+ * header on all incoming connections to prevent DNS rebinding attacks…
7
+ * If the Origin header is present and invalid, servers MUST respond
8
+ * with HTTP 403 Forbidden."
9
+ *
10
+ * WHY THIS IS NOT CORS: CORS governs whether a browser lets a page READ
11
+ * a cross-origin response; it never governs whether the request EXECUTES
12
+ * server-side. After a DNS rebind (attacker.com → 127.0.0.1) the page's
13
+ * requests are SAME-origin from the browser's view, so no preflight
14
+ * fires and no CORS header is consulted — only an inbound Host/Origin
15
+ * check catches it. See `./browser-cors.ts` for the (separate) CORS layer.
16
+ *
17
+ * WHY RAW HEADERS: `request-context.ts` honors `X-Forwarded-Host` when the
18
+ * TCP peer is loopback — and a rebinding attacker IS a loopback peer, so a
19
+ * check built on the derived host is bypassable with one attacker-supplied
20
+ * header. `Host` and `Origin` are forbidden header names for `fetch`, so
21
+ * page JS cannot forge them: the raw values are the trustworthy ones.
22
+ *
23
+ * WHY NOT THE SDK's enableDnsRebindingProtection: deprecated upstream in
24
+ * favor of external middleware, per-transport (never sees the WS upgrade
25
+ * ingress or non-transport routes), and runs after ggui's own pipeline.
26
+ * The 403 body + message wording here mirror the SDK's
27
+ * `createJsonErrorResponse` so clients see one rejection dialect.
28
+ */
29
+ import type { RequestHandler } from "express";
30
+ import type { Logger } from "./logger.js";
31
+ /** Hostnames that mean "this machine". Compared without port. */
32
+ export declare const LOOPBACK_HOSTNAMES: ReadonlyArray<string>;
33
+ export interface OriginHostPolicy {
34
+ /**
35
+ * Allowed `Host` hostnames, port-agnostic and lowercased. `null`
36
+ * disables the Host check entirely — used when the server is bound to
37
+ * a non-loopback address, where the operator has explicitly opted into
38
+ * wide exposure and rebinding is not the applicable threat model.
39
+ * (Combined with --dev-allow-all that posture leaves a documented
40
+ * residual — the CLI warns loudly; see serve-command.ts.)
41
+ */
42
+ readonly allowedHosts: ReadonlyArray<string> | null;
43
+ /**
44
+ * Allowed page origins, lowercased, exact (scheme + host + port):
45
+ * the operator's `browserOrigins` plus the publicBaseUrl origin
46
+ * (the server's own tunnel-served pages POST with it). Loopback
47
+ * origins and same-origin requests are allowed unconditionally on
48
+ * top of this list — see `validateOriginHost`.
49
+ */
50
+ readonly allowedOrigins: ReadonlyArray<string>;
51
+ }
52
+ export interface OriginHostRejection {
53
+ readonly header: "host" | "origin";
54
+ /** The offending header value; empty string when the header was absent. */
55
+ readonly value: string;
56
+ }
57
+ /**
58
+ * Build the effective policy from deployment inputs.
59
+ *
60
+ * The Host check is active only for loopback binds — that is exactly the
61
+ * configuration the spec's rebinding warning targets (a local server
62
+ * reachable because the attacker's domain resolves to 127.0.0.1). A
63
+ * server bound to 0.0.0.0 or a routable address was deliberately exposed
64
+ * by its operator; rejecting its own LAN hostname would break phone/LAN
65
+ * testing for no security gain — auth is the gate there.
66
+ *
67
+ * `browserOrigins` contribute ONLY to allowedOrigins, never to
68
+ * allowedHosts: a page at https://app.guuey.com talking to a local serve
69
+ * sends `Host: localhost:6781`, so widening the anti-rebinding Host
70
+ * allowlist with page hostnames would serve no flow.
71
+ */
72
+ export declare function buildOriginHostPolicy(input: {
73
+ readonly bindHost: string;
74
+ readonly publicBaseUrl?: string;
75
+ readonly browserOrigins?: ReadonlyArray<string>;
76
+ }): OriginHostPolicy;
77
+ /**
78
+ * The two validation postures this function serves. They share every
79
+ * rule except one: `"ws-upgrade"` additionally admits the opaque-origin
80
+ * ("null") Origin — see the `ingress` param below. This is a posture
81
+ * selector, not a literal "which wire did this arrive on" tag: the WS
82
+ * upgrade handler in server.ts passes `"ws-upgrade"` only for upgrades
83
+ * that declare bootstrap intent (`?wsToken=` present); every other
84
+ * upgrade is validated as `"http"`.
85
+ */
86
+ export type OriginValidationIngress = "http" | "ws-upgrade";
87
+ /**
88
+ * Validate one request's raw Host/Origin against the policy.
89
+ * Returns `null` when the request passes.
90
+ *
91
+ * Pure by design: the Express middleware and the WebSocket `upgrade`
92
+ * handler (a second ingress Express never sees) call the same function,
93
+ * so the two ingresses cannot drift. `ingress` is the one deliberate,
94
+ * documented point of difference between them — everything else is
95
+ * identical logic.
96
+ *
97
+ * @param ingress Defaults to `"http"`, so every existing HTTP call site
98
+ * is unaffected. The WS upgrade handler passes `"ws-upgrade"` only
99
+ * when the upgrade itself already declares bootstrap intent — see
100
+ * {@link OriginValidationIngress}.
101
+ */
102
+ export declare function validateOriginHost(rawHost: string | undefined, rawOrigin: string | undefined, policy: OriginHostPolicy, ingress?: OriginValidationIngress): OriginHostRejection | null;
103
+ /** Message text for a rejection — mirrors the SDK's own wording. */
104
+ export declare function rejectionMessage(rejection: OriginHostRejection): string;
105
+ export declare function createOriginHostValidationMiddleware(opts: {
106
+ readonly policy: OriginHostPolicy;
107
+ /**
108
+ * Path prefixes where a present-and-disallowed Origin is 403'd — the
109
+ * MCP wire (universal /mcp, per-app prefix, /control, isolated
110
+ * services). The HOST check runs on EVERY path regardless: it is the
111
+ * actual rebinding defense and legitimate cross-origin consumers
112
+ * still send the server's own Host. Origin enforcement is scoped
113
+ * because several non-MCP surfaces are deliberately consumed
114
+ * cross-origin — the runtime-bundle and /code routes are fetched by
115
+ * sandboxed iframes whose Origin is literally `null`.
116
+ */
117
+ readonly enforceOriginPathPrefixes: ReadonlyArray<string>;
118
+ readonly logger: Logger;
119
+ }): RequestHandler;
120
+ //# sourceMappingURL=origin-validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"origin-validation.d.ts","sourceRoot":"","sources":["../src/origin-validation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAC9C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,iEAAiE;AACjE,eAAO,MAAM,kBAAkB,EAAE,aAAa,CAAC,MAAM,CAA8C,CAAC;AAEpG,MAAM,WAAW,gBAAgB;IAC/B;;;;;;;OAOG;IACH,QAAQ,CAAC,YAAY,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;IACpD;;;;;;OAMG;IACH,QAAQ,CAAC,cAAc,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CAChD;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,CAAC;IACnC,2EAA2E;IAC3E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AA8CD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE;IAC3C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,cAAc,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CACjD,GAAG,gBAAgB,CA2BnB;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,uBAAuB,GAAG,MAAM,GAAG,YAAY,CAAC;AAE5D;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,EACxB,OAAO,GAAE,uBAAgC,GACxC,mBAAmB,GAAG,IAAI,CA8C5B;AAED,oEAAoE;AACpE,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,mBAAmB,GAAG,MAAM,CAGvE;AAED,wBAAgB,oCAAoC,CAAC,IAAI,EAAE;IACzD,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAClC;;;;;;;;;OASG;IACH,QAAQ,CAAC,yBAAyB,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC1D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB,GAAG,cAAc,CA+CjB"}
@@ -0,0 +1,199 @@
1
+ /** Hostnames that mean "this machine". Compared without port. */
2
+ export const LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "::1", "[::1]"];
3
+ /** Strip the port and surrounding brackets from a Host/authority value. */
4
+ function hostnameOf(authority) {
5
+ const lower = authority.trim().toLowerCase();
6
+ if (lower.startsWith("[")) {
7
+ const close = lower.indexOf("]");
8
+ return close === -1 ? lower : lower.slice(0, close + 1);
9
+ }
10
+ // A bracket-less value with more than one colon is a bare IPv6
11
+ // address (`::1`), not host:port — return it whole. Slicing at the
12
+ // first colon would return "" and silently disable the Host check
13
+ // for an IPv6 loopback bind.
14
+ if (lower.indexOf(":") !== lower.lastIndexOf(":"))
15
+ return lower;
16
+ const colon = lower.indexOf(":");
17
+ return colon === -1 ? lower : lower.slice(0, colon);
18
+ }
19
+ function isLoopbackHostname(hostname) {
20
+ return LOOPBACK_HOSTNAMES.includes(hostname);
21
+ }
22
+ function isLoopbackOrigin(origin) {
23
+ try {
24
+ return isLoopbackHostname(hostnameOf(new URL(origin).host));
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ /**
31
+ * Same-origin allowance: does the Origin's authority (host:port) match
32
+ * the request's own Host? A page this server itself served (LAN bind,
33
+ * tunnel) sends exactly that on every POST and WS handshake; rejecting
34
+ * it would 403 the server's own console. Safe on loopback binds because
35
+ * a rebound page fails the HOST check before Origin is consulted.
36
+ */
37
+ function originMatchesRequestHost(origin, rawHost) {
38
+ if (rawHost === undefined || rawHost.length === 0)
39
+ return false;
40
+ try {
41
+ return new URL(origin).host === rawHost.trim().toLowerCase();
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ /**
48
+ * Build the effective policy from deployment inputs.
49
+ *
50
+ * The Host check is active only for loopback binds — that is exactly the
51
+ * configuration the spec's rebinding warning targets (a local server
52
+ * reachable because the attacker's domain resolves to 127.0.0.1). A
53
+ * server bound to 0.0.0.0 or a routable address was deliberately exposed
54
+ * by its operator; rejecting its own LAN hostname would break phone/LAN
55
+ * testing for no security gain — auth is the gate there.
56
+ *
57
+ * `browserOrigins` contribute ONLY to allowedOrigins, never to
58
+ * allowedHosts: a page at https://app.guuey.com talking to a local serve
59
+ * sends `Host: localhost:6781`, so widening the anti-rebinding Host
60
+ * allowlist with page hostnames would serve no flow.
61
+ */
62
+ export function buildOriginHostPolicy(input) {
63
+ const boundToLoopback = isLoopbackHostname(hostnameOf(input.bindHost));
64
+ const hosts = [...LOOPBACK_HOSTNAMES];
65
+ const origins = (input.browserOrigins ?? [])
66
+ .map((o) => o.trim().toLowerCase().replace(/\/$/, ""))
67
+ .filter((o) => o.length > 0);
68
+ if (input.publicBaseUrl !== undefined) {
69
+ try {
70
+ const url = new URL(input.publicBaseUrl);
71
+ // The tunnel/proxy host arrives on forwarded requests' Host
72
+ // header, and the server's OWN pages served from that origin
73
+ // (console, /r/<code>) POST + open WS with it as Origin. Both
74
+ // must validate, or a tunnel deployment 403s itself.
75
+ hosts.push(hostnameOf(url.host));
76
+ origins.push(url.origin.toLowerCase());
77
+ }
78
+ catch {
79
+ // A malformed publicBaseUrl is the caller's problem to report;
80
+ // silently contributing nothing keeps validation fail-closed.
81
+ }
82
+ }
83
+ return {
84
+ allowedHosts: boundToLoopback ? hosts : null,
85
+ allowedOrigins: origins,
86
+ };
87
+ }
88
+ /**
89
+ * Validate one request's raw Host/Origin against the policy.
90
+ * Returns `null` when the request passes.
91
+ *
92
+ * Pure by design: the Express middleware and the WebSocket `upgrade`
93
+ * handler (a second ingress Express never sees) call the same function,
94
+ * so the two ingresses cannot drift. `ingress` is the one deliberate,
95
+ * documented point of difference between them — everything else is
96
+ * identical logic.
97
+ *
98
+ * @param ingress Defaults to `"http"`, so every existing HTTP call site
99
+ * is unaffected. The WS upgrade handler passes `"ws-upgrade"` only
100
+ * when the upgrade itself already declares bootstrap intent — see
101
+ * {@link OriginValidationIngress}.
102
+ */
103
+ export function validateOriginHost(rawHost, rawOrigin, policy, ingress = "http") {
104
+ if (policy.allowedHosts !== null) {
105
+ if (rawHost === undefined || rawHost.length === 0) {
106
+ return { header: "host", value: "" };
107
+ }
108
+ if (!policy.allowedHosts.includes(hostnameOf(rawHost))) {
109
+ return { header: "host", value: rawHost };
110
+ }
111
+ }
112
+ // Absent Origin passes: every non-browser client omits it.
113
+ if (rawOrigin === undefined || rawOrigin.length === 0)
114
+ return null;
115
+ // A document with an OPAQUE origin (a srcdoc-mounted document,
116
+ // an about:-scheme page, or an equivalent sandboxed embedding) has no
117
+ // per-origin identity to serialize — the Origin header spec requires
118
+ // browsers to send the literal value "null" in that case, not an
119
+ // absence of the header. Passing `ingress: "ws-upgrade"` admits that
120
+ // value — but the caller (the WS upgrade handler in server.ts) only
121
+ // passes it when the upgrade URL already declares bootstrap intent
122
+ // (`?wsToken=` present); a bare opaque-origin socket with no
123
+ // bootstrap intent is validated as `"http"` and gets the ordinary
124
+ // rejection below.
125
+ //
126
+ // Admission here is NOT authentication. A bootstrap-intent socket
127
+ // still carries only an unverified placeholder identity at this
128
+ // point — the subscribe handler
129
+ // (`ggui-session-channel/subscribe.ts`) rejects it outright unless
130
+ // the `subscribe` message itself presents a credential that verifies.
131
+ // Origin-allowlisting a socket that can still do nothing until it
132
+ // clears that later gate adds no protection; rejecting "null"
133
+ // outright would instead break a spec-legitimate embedding topology
134
+ // that has no other way to send an Origin at all. The HTTP ingress
135
+ // has no equivalent post-admission gate, so it keeps rejecting
136
+ // "null" unconditionally.
137
+ if (ingress === "ws-upgrade" && rawOrigin.trim().toLowerCase() === "null")
138
+ return null;
139
+ const origin = rawOrigin.trim().toLowerCase().replace(/\/$/, "");
140
+ // Loopback pages are allowed unconditionally so the zero-config
141
+ // quickstart (sample SPA on :6890 → serve on :6781) needs no flags.
142
+ // Safe because loopback origins can only be opened by software already
143
+ // running on this machine.
144
+ if (isLoopbackOrigin(origin))
145
+ return null;
146
+ if (originMatchesRequestHost(origin, rawHost))
147
+ return null;
148
+ if (policy.allowedOrigins.includes(origin))
149
+ return null;
150
+ return { header: "origin", value: rawOrigin };
151
+ }
152
+ /** Message text for a rejection — mirrors the SDK's own wording. */
153
+ export function rejectionMessage(rejection) {
154
+ const label = rejection.header === "host" ? "Host" : "Origin";
155
+ return `Invalid ${label} header: ${rejection.value}`;
156
+ }
157
+ export function createOriginHostValidationMiddleware(opts) {
158
+ const { policy, enforceOriginPathPrefixes, logger } = opts;
159
+ // Lowercased once at middleware-creation time so a caller passing a
160
+ // mixed-case service path (e.g. perAppRouting.pathPrefix) is still
161
+ // matched correctly below — see the req.path.toLowerCase() comment.
162
+ const lowerEnforcePrefixes = enforceOriginPathPrefixes.map((p) => p.toLowerCase());
163
+ // Dedupe log noise: a polling page would otherwise flood the log with
164
+ // one identical warning per request. Capped so attacker-minted unique
165
+ // header values cannot grow the set without bound; after the cap,
166
+ // rejections still 403 — they just stop logging.
167
+ const warned = new Set();
168
+ return (req, res, next) => {
169
+ // Express routes case-insensitively by default (no `case sensitive
170
+ // routing` setting here), so `POST /MCP` reaches the same handler as
171
+ // `POST /mcp`. Comparing req.path as-is would let that case variant
172
+ // skip Origin enforcement entirely — the Origin MUST must not be
173
+ // case-bypassable — so both sides are lowercased before comparison.
174
+ const path = req.path.toLowerCase();
175
+ const originEnforced = lowerEnforcePrefixes.some((p) => path === p || path.startsWith(`${p}/`));
176
+ const rejection = validateOriginHost(req.headers.host, originEnforced && typeof req.headers.origin === "string" ? req.headers.origin : undefined, policy);
177
+ if (rejection === null) {
178
+ next();
179
+ return;
180
+ }
181
+ const key = `${rejection.header}:${rejection.value}`;
182
+ if (!warned.has(key) && warned.size < 100) {
183
+ warned.add(key);
184
+ logger.warn("origin_host_rejected", {
185
+ header: rejection.header,
186
+ value: rejection.value,
187
+ path: req.path,
188
+ hint: rejection.header === "origin"
189
+ ? "Add the page origin with `ggui serve --browser-origin <origin>` (or GGUI_BROWSER_ORIGINS)."
190
+ : "Request arrived with an unexpected Host header — DNS-rebinding defense. Set --public-base-url if this is a legitimate proxy/tunnel host.",
191
+ });
192
+ }
193
+ res.status(403).json({
194
+ jsonrpc: "2.0",
195
+ error: { code: -32000, message: rejectionMessage(rejection) },
196
+ id: null,
197
+ });
198
+ };
199
+ }
@@ -0,0 +1,50 @@
1
+ import type { HandlerContext } from "@ggui-ai/mcp-server-handlers";
2
+ /** The two row fields the read gate consults. */
3
+ export interface RenderReadRowView {
4
+ readonly appId: string;
5
+ /**
6
+ * The row's SUBJECT — the end user a render belongs to, written at
7
+ * commit and nowhere else.
8
+ *
9
+ * `userId` rather than the richer `endUserIdentity` block, and that
10
+ * is the whole of #446. The gate used to read `endUserIdentity`,
11
+ * which no writer has populated since the repo split deleted the one
12
+ * that did (`f99b81c28`) — so the subject rung has been reading a
13
+ * field that is always absent, and passing every caller through as
14
+ * "row has no subject". Reading `userId` binds the rung for the
15
+ * first time.
16
+ *
17
+ * Absent stays a legitimate state: builder and anonymous
18
+ * single-tenant flows mint rows with no subject, and those still
19
+ * pass rung 4.
20
+ */
21
+ readonly userId?: string;
22
+ }
23
+ /**
24
+ * Per-session render-resource read gate (rehydration access control).
25
+ *
26
+ * Rungs, in order (spec §3):
27
+ * 1. Fail closed without a request context.
28
+ * 2. App boundary — the row's appId must equal the caller's.
29
+ * 3. Subject binding — a caller carrying an end-user identity (kind
30
+ * 'user', any auth source) reading a subject-bound row must BE
31
+ * that subject. The row's subject is its `userId`, written at
32
+ * commit; see {@link RenderReadRowView.userId} for why this rung
33
+ * only starts binding now.
34
+ * 4. Everything else same-app passes: app credentials (tenant trust —
35
+ * the app is obligated to enforce its own user-ownership before
36
+ * fetching on a user's behalf), builder/anonymous single-tenant
37
+ * flows, and rows with no subject.
38
+ *
39
+ * Deny is surfaced by the CALLER byte-identically to a missing row —
40
+ * reads must not oracle which sessionIds exist. This function only
41
+ * returns the boolean; making the two indistinguishable is the caller's
42
+ * obligation, and it is a real one, because "refused" and "never
43
+ * existed" travel completely different code paths to get there. The
44
+ * resource handler discharges it by collapsing a refusal to "absent"
45
+ * and letting every downstream branch run as it would for a locator
46
+ * that never existed, so both arrive at the same typed failure with the
47
+ * same bytes.
48
+ */
49
+ export declare function renderReadAllowed(row: RenderReadRowView, ctx: HandlerContext | undefined): boolean;
50
+ //# sourceMappingURL=render-read-gate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render-read-gate.d.ts","sourceRoot":"","sources":["../src/render-read-gate.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAEnE,iDAAiD;AACjD,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,iBAAiB,EACtB,GAAG,EAAE,cAAc,GAAG,SAAS,GAC9B,OAAO,CAOT"}
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Per-session render-resource read gate (rehydration access control).
3
+ *
4
+ * Rungs, in order (spec §3):
5
+ * 1. Fail closed without a request context.
6
+ * 2. App boundary — the row's appId must equal the caller's.
7
+ * 3. Subject binding — a caller carrying an end-user identity (kind
8
+ * 'user', any auth source) reading a subject-bound row must BE
9
+ * that subject. The row's subject is its `userId`, written at
10
+ * commit; see {@link RenderReadRowView.userId} for why this rung
11
+ * only starts binding now.
12
+ * 4. Everything else same-app passes: app credentials (tenant trust —
13
+ * the app is obligated to enforce its own user-ownership before
14
+ * fetching on a user's behalf), builder/anonymous single-tenant
15
+ * flows, and rows with no subject.
16
+ *
17
+ * Deny is surfaced by the CALLER byte-identically to a missing row —
18
+ * reads must not oracle which sessionIds exist. This function only
19
+ * returns the boolean; making the two indistinguishable is the caller's
20
+ * obligation, and it is a real one, because "refused" and "never
21
+ * existed" travel completely different code paths to get there. The
22
+ * resource handler discharges it by collapsing a refusal to "absent"
23
+ * and letting every downstream branch run as it would for a locator
24
+ * that never existed, so both arrive at the same typed failure with the
25
+ * same bytes.
26
+ */
27
+ export function renderReadAllowed(row, ctx) {
28
+ if (ctx === undefined)
29
+ return false;
30
+ if (ctx.appId !== row.appId)
31
+ return false;
32
+ if (row.userId !== undefined && ctx.userId !== undefined) {
33
+ return ctx.userId === row.userId;
34
+ }
35
+ return true;
36
+ }
@@ -32,6 +32,21 @@ interface MountOptions {
32
32
  readonly runtimeBundleFile: string;
33
33
  /** Structured logger for the missing-bundle boot warning. */
34
34
  readonly logger: Logger;
35
+ /**
36
+ * Content-hashed twin of the mount — the LONG-CACHE production
37
+ * route. When present, `GET <path>` serves `source` with
38
+ * `Cache-Control: public, max-age=31536000, immutable`: the path
39
+ * embeds a hash of these exact bytes, so the response can never go
40
+ * stale under its own name, and a rebuild rolls clients over by
41
+ * changing the STAMPED URL rather than the cached content. Serving
42
+ * the captured bytes (not the file) is load-bearing for that
43
+ * guarantee — an in-place rebuild must not change what the old
44
+ * hash-name returns.
45
+ */
46
+ readonly hashed?: {
47
+ readonly path: string;
48
+ readonly source: Buffer;
49
+ };
35
50
  }
36
51
  /**
37
52
  * Mount `GET <runtimePath>` onto the express app. Returns nothing —
@@ -1 +1 @@
1
- {"version":3,"file":"runtime-bundle-route.d.ts","sourceRoot":"","sources":["../src/runtime-bundle-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAEvC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,UAAU,YAAY;IACpB,iCAAiC;IACjC,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB,oDAAoD;IACpD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,sDAAsD;IACtD,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,YAAY,GAAG,IAAI,CAiDhE"}
1
+ {"version":3,"file":"runtime-bundle-route.d.ts","sourceRoot":"","sources":["../src/runtime-bundle-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAEvC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,UAAU,YAAY;IACpB,iCAAiC;IACjC,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB,oDAAoD;IACpD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,sDAAsD;IACtD,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE;QAChB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;KACzB,CAAC;CACH;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,YAAY,GAAG,IAAI,CAiEhE"}
@@ -29,13 +29,29 @@ import { existsSync } from "node:fs";
29
29
  */
30
30
  export function mountRuntimeBundleRoute(opts) {
31
31
  const { app, runtimePath, runtimeBundleFile, logger } = opts;
32
+ // Content-hashed long-cache route (#472) — registered ahead of the
33
+ // plain path so the two never shadow each other regardless of how a
34
+ // custom `runtimePath` glob might overlap.
35
+ if (opts.hashed !== undefined) {
36
+ const { path: hashedPath, source } = opts.hashed;
37
+ app.get(hashedPath, (_req, res) => {
38
+ res.setHeader("Content-Type", "application/javascript; charset=utf-8");
39
+ // Immutable: the path names these exact bytes (see MountOptions
40
+ // .hashed). One download per client, zero revalidations; deploys
41
+ // roll over via a new stamped URL.
42
+ res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
43
+ // Same CORS rationale as the plain route below.
44
+ res.setHeader("Access-Control-Allow-Origin", "*");
45
+ res.send(source);
46
+ });
47
+ }
32
48
  if (existsSync(runtimeBundleFile)) {
33
49
  app.get(runtimePath, (_req, res) => {
34
50
  res.setHeader("Content-Type", "application/javascript; charset=utf-8");
35
- // Short cache — operators iterating on the renderer want
36
- // fresh copies after rebuild. Production hardening (etag,
37
- // long-term caching with hashed filenames) is a follow-on
38
- // concern; same posture console takes.
51
+ // Short cache — operators iterating on the renderer want fresh
52
+ // copies after rebuild. Production long-term caching lives on
53
+ // the hashed twin above; this name's content changes in place,
54
+ // so it must stay revalidated.
39
55
  res.setHeader("Cache-Control", "no-cache");
40
56
  // CORS: the bundle MUST be loadable from `<script type="module"
41
57
  // src=...>` inside a sandboxed `srcdoc` iframe (the