@ggui-ai/mcp-server 0.6.3 → 0.7.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 (36) 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/ggui-session-channel/outbound.d.ts.map +1 -1
  12. package/dist/ggui-session-channel/outbound.js +12 -0
  13. package/dist/ggui-session-channel/socket-router.d.ts +33 -0
  14. package/dist/ggui-session-channel/socket-router.d.ts.map +1 -1
  15. package/dist/ggui-session-channel/socket-router.js +155 -0
  16. package/dist/ggui-session-channel/subscribe.d.ts.map +1 -1
  17. package/dist/ggui-session-channel/subscribe.js +43 -2
  18. package/dist/ggui-session-channel.d.ts +114 -0
  19. package/dist/ggui-session-channel.d.ts.map +1 -1
  20. package/dist/ggui-session-channel.js +77 -2
  21. package/dist/health-routes.d.ts +3 -0
  22. package/dist/health-routes.d.ts.map +1 -1
  23. package/dist/health-routes.js +11 -1
  24. package/dist/mcp-apps-outbound.d.ts +191 -30
  25. package/dist/mcp-apps-outbound.d.ts.map +1 -1
  26. package/dist/mcp-apps-outbound.js +843 -223
  27. package/dist/origin-validation.d.ts +120 -0
  28. package/dist/origin-validation.d.ts.map +1 -0
  29. package/dist/origin-validation.js +199 -0
  30. package/dist/render-read-gate.d.ts +50 -0
  31. package/dist/render-read-gate.d.ts.map +1 -0
  32. package/dist/render-read-gate.js +36 -0
  33. package/dist/server.d.ts +164 -4
  34. package/dist/server.d.ts.map +1 -1
  35. package/dist/server.js +206 -20
  36. 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
+ }
package/dist/server.d.ts CHANGED
@@ -37,9 +37,10 @@
37
37
  * handlers wired up. The `Logger.warn('dev_mode_auth_enabled')` fires
38
38
  * once at boot so operators see the shape they're running.
39
39
  */
40
- import type { AppMetadataStore, AuditSink, AuthAdapter, AuthResult, BlueprintIndex, BlueprintProvider, BlueprintSearch, BlueprintSelector, BlueprintStore, CodeStore, EmbeddingProvider, GeneratorRegistry, KeyValueStore, PairingService, PendingEventConsumer, ProviderKeyStore, RateLimiter, GguiSessionStore, GguiSessionStreamBuffer, ShortCodeIndex, TelemetrySink, ThreadStore, VectorStore } from "@ggui-ai/mcp-server-core";
40
+ import type { AppMetadataStore, AuditSink, AuthAdapter, AuthResult, BlueprintIndex, BlueprintProvider, BlueprintSearch, BlueprintSelector, BlueprintStore, CodeStore, EmbeddingProvider, GeneratorRegistry, KeyValueStore, PairingService, PendingEventConsumer, ProviderKeyStore, RateLimiter, RenderIdentityStore, GguiSessionStore, GguiSessionStreamBuffer, ShortCodeIndex, TelemetrySink, ThreadStore, VectorStore } from "@ggui-ai/mcp-server-core";
41
41
  import type { SharedHandler } from "@ggui-ai/mcp-server-handlers";
42
42
  import { type ThemeCatalogEntry } from "@ggui-ai/mcp-server-handlers/app-discovery";
43
+ import type { BlueprintDurabilityDeps } from "@ggui-ai/mcp-server-handlers/renders";
43
44
  import type { OperatorConfig, ThemeConfig } from "@ggui-ai/project-config";
44
45
  import type { DiscoveredPrimitiveCatalog, LoadedTheme } from "@ggui-ai/project-config/node";
45
46
  import type { Blueprint } from "@ggui-ai/protocol";
@@ -148,6 +149,12 @@ export declare function defaultHandlers(deps: {
148
149
  };
149
150
  readonly render?: {
150
151
  readonly renderStore: GguiSessionStore;
152
+ /**
153
+ * Render-row retention window in ms. Forwarded as-is to
154
+ * `createGguiRenderHandler`'s `renderTtlMs` dep. Absent = the
155
+ * handler's own `DEFAULT_RENDER_TTL_MS` (1h) fallback.
156
+ */
157
+ readonly renderTtlMs?: number;
151
158
  /**
152
159
  * Optional bootstrap-credential minter. When present, `ggui_render`
153
160
  * (the renamed render-commit tool) results carry the
@@ -215,6 +222,15 @@ export declare function defaultHandlers(deps: {
215
222
  * enabled.
216
223
  */
217
224
  readonly shortCodeIndex?: ShortCodeIndex;
225
+ /**
226
+ * Optional durable render-identity side store. When present, every
227
+ * `ggui_render` commit also records the identity a
228
+ * `ui://ggui/render/{sessionId}/{blueprintKey}` locator needs to
229
+ * re-create the render after the render row is gone. Writes are
230
+ * best-effort — they never block or fail a render. Absent = the
231
+ * deployment's render rows are themselves durable enough.
232
+ */
233
+ readonly renderIdentityStore?: RenderIdentityStore;
218
234
  /**
219
235
  * Optional provisional-preview wiring. When present, `ggui_render`
220
236
  * kicks off the configured emitter on every qualifying render (the
@@ -343,6 +359,12 @@ export declare function defaultHandlers(deps: {
343
359
  */
344
360
  readonly update?: {
345
361
  readonly renderStore: GguiSessionStore;
362
+ /**
363
+ * Durable render-identity side store — the same instance `render`
364
+ * writes to. When present, a successful patch refreshes the
365
+ * existing record off the re-committed row. Absent = no refresh.
366
+ */
367
+ readonly renderIdentityStore?: RenderIdentityStore;
346
368
  /**
347
369
  * Optional live-subscriber `props_update` notifier — typically a
348
370
  * thin closure over `GguiSessionChannelServer.sendPropsUpdate`.
@@ -1003,6 +1025,32 @@ export interface CreateGguiServerOptions {
1003
1025
  * when they land.
1004
1026
  */
1005
1027
  readonly renderStore?: GguiSessionStore;
1028
+ /**
1029
+ * Render-row retention window in ms. Operators align this with
1030
+ * chat-history lifetime so rehydration-by-refetch (re-reading the
1031
+ * `ui://ggui/render/*` resource after the agent's original render
1032
+ * call) finds the row instead of a reaped one.
1033
+ *
1034
+ * One knob, three consumers, all of them lifecycle decisions about a
1035
+ * render row: `ggui_render` stamps new renders with it, a re-mint
1036
+ * commits its reconstructed row with it, and a read of a row that is
1037
+ * present but past its expiry gives the row this lifetime again so
1038
+ * the live-channel token minted in the same read cannot outlive it.
1039
+ *
1040
+ * Defaults to 1h when unset — the pre-existing memory-hygiene
1041
+ * default for in-process stores, and the same value each consumer
1042
+ * falls back to independently.
1043
+ */
1044
+ readonly renderTtlMs?: number;
1045
+ /**
1046
+ * #457 — total-lifetime bound on render-row RESURRECTION: the
1047
+ * expired-read extension and the re-mint commit never advance
1048
+ * `expiresAt` past `createdAt + maxRenderLifetimeMs`. Unset =
1049
+ * unbounded (today's behavior). Live-row heartbeats are not capped —
1050
+ * active use is legitimate lease renewal; this bounds how long an
1051
+ * evicted-but-in-grace row can be kept warm by polling alone.
1052
+ */
1053
+ readonly maxRenderLifetimeMs?: number;
1006
1054
  /**
1007
1055
  * Outbound stream replay buffer for the live-channel endpoint. Defaults
1008
1056
  * to a fresh `InMemoryGguiSessionStreamBuffer` — fine for OSS zero-config
@@ -1097,6 +1145,43 @@ export interface CreateGguiServerOptions {
1097
1145
  * Only consulted when `renderChannel` is enabled.
1098
1146
  */
1099
1147
  readonly versionPolicy?: "advisory" | "reject";
1148
+ /**
1149
+ * Live-channel WS caps (ggui#444). Three of the four bound only what
1150
+ * a NOT-YET-SUBSCRIBED (unauthenticated / unregistered) WebSocket can
1151
+ * consume; a socket that has completed a valid subscribe is a
1152
+ * legitimate long-lived subscriber and is exempt from those three.
1153
+ * The fourth, `wsMaxPayloadBytes`, is the exception: a global backstop
1154
+ * applied to EVERY frame on EVERY socket, subscribed or not. Every
1155
+ * cap is disable-able with `0`; the three pre-subscribe caps ship
1156
+ * generous defaults so the zero-config quickstart + e2e journeys
1157
+ * never trip. Forwarded verbatim to `createGguiSessionChannelServer`;
1158
+ * only consulted when `renderChannel` is enabled.
1159
+ *
1160
+ * - `wsMaxPayloadBytes` — coarse ws-level `maxPayload` memory
1161
+ * backstop for EVERY frame on EVERY socket, INCLUDING subscribed
1162
+ * ones (default 1 MiB — a 100x reduction from the `ws` library's
1163
+ * own ~100 MiB default). `ActionEnvelope` has no protocol-level
1164
+ * max size, so this is a deliberately generous-but-finite
1165
+ * backstop, not a guarantee it never clips a legitimate frame;
1166
+ * raise it if legitimate `action` frames exceed the default. See
1167
+ * `GguiSessionChannelOptions.maxPayloadBytes`.
1168
+ * - `wsMaxPreSubscribePayloadBytes` — tight per-frame ceiling for
1169
+ * pre-subscribe frames only (default 64 KiB). See
1170
+ * `GguiSessionChannelOptions.maxPreSubscribePayloadBytes`.
1171
+ * - `wsPreSubscribeIdleMs` — grace window to complete a valid
1172
+ * subscribe before the socket is closed (default 30 s). See
1173
+ * `GguiSessionChannelOptions.preSubscribeIdleMs`.
1174
+ * - `wsMaxPreSubscribeConnections` — concurrent pending-socket
1175
+ * ceiling (default 1024). See
1176
+ * `GguiSessionChannelOptions.maxPreSubscribeConnections`.
1177
+ *
1178
+ * Cap-driven closures/refusals are counted on `/ggui/health` under
1179
+ * `channel.caps.preSubscribeRejections`.
1180
+ */
1181
+ readonly wsMaxPayloadBytes?: number;
1182
+ readonly wsMaxPreSubscribePayloadBytes?: number;
1183
+ readonly wsPreSubscribeIdleMs?: number;
1184
+ readonly wsMaxPreSubscribeConnections?: number;
1100
1185
  /**
1101
1186
  * Policy for the schema compat check. Checks that every
1102
1187
  * `actionSpec[name]` tool ref points at a tool whose
@@ -1244,6 +1329,36 @@ export interface CreateGguiServerOptions {
1244
1329
  * Ignored entirely when MCP Apps is disabled.
1245
1330
  */
1246
1331
  readonly wsTokenSecret?: string;
1332
+ /**
1333
+ * Bind host `listen()` will use, declared up front so boot-time
1334
+ * wiring — the Origin/Host validation policy (ggui#438a) — sees the
1335
+ * address the server will actually bind. `listen(port, host)`
1336
+ * defaults its host argument to this value; passing a DIFFERENT
1337
+ * host to `listen()` is a misuse (the validation policy would
1338
+ * describe the wrong bind). Embedders who mount `.app` under their
1339
+ * own HTTP server and never call `listen()` should set this to the
1340
+ * host they bind. Default: `"127.0.0.1"`.
1341
+ */
1342
+ readonly host?: string;
1343
+ /**
1344
+ * Page origins permitted to reach the MCP wire from a browser.
1345
+ *
1346
+ * ONE list drives BOTH layers, deliberately: the Origin/Host
1347
+ * validation gate (`./origin-validation.ts`) and the CORS response
1348
+ * headers (`./browser-cors.ts`). Two lists would let an operator
1349
+ * CORS-allow an origin that validation still 403s — a debugging trap
1350
+ * with no upside.
1351
+ *
1352
+ * Loopback origins (`http://localhost:*`, `http://127.0.0.1:*`,
1353
+ * `http://[::1]:*`), same-origin requests, and the `publicBaseUrl`
1354
+ * origin are always allowed and need no entry, so the zero-config
1355
+ * quickstart and tunnel deployments work untouched.
1356
+ *
1357
+ * This is NOT authentication: an allowlisted origin still presents a
1358
+ * bearer. It controls which PAGES a browser lets talk to this
1359
+ * server; non-browser clients ignore it entirely.
1360
+ */
1361
+ readonly browserOrigins?: ReadonlyArray<string>;
1247
1362
  /**
1248
1363
  * Enable the pairing transport. Adds `POST /pair` (public, completes a
1249
1364
  * pairing handshake) and by default `POST /admin/pair/init` (builder-
@@ -1323,6 +1438,13 @@ export interface CreateGguiServerOptions {
1323
1438
  * Embedded hosts that supply a custom `store` also supply the
1324
1439
  * right durability claim; the server doesn't inspect the store
1325
1440
  * instance to guess.
1441
+ *
1442
+ * Deliberately BINDING-SITE-level, unlike the substrate stores'
1443
+ * port-level `durability` member (#457): this one is a
1444
+ * health-report fact the operator asserts for display, while the
1445
+ * substrate declaration backs a WIRE promise (NOT_FOUND ⇒
1446
+ * restorable) — the higher bar lives on the implementation, where
1447
+ * the knowledge is.
1326
1448
  */
1327
1449
  readonly durability?: "durable" | "ephemeral";
1328
1450
  };
@@ -1654,6 +1776,24 @@ export interface CreateGguiServerOptions {
1654
1776
  * See `defaultHandlers` for the wiring seam.
1655
1777
  */
1656
1778
  readonly shortCodeIndex?: ShortCodeIndex;
1779
+ /**
1780
+ * Durable side record of what each render WAS — its `blueprintId`,
1781
+ * `contractKey`, `variantKey`, props, and event sequence at the last
1782
+ * commit. `ggui_render` writes it at every commit; the record is what
1783
+ * lets a `ui://ggui/render/{sessionId}/{blueprintKey}` locator
1784
+ * re-create the render once the render row itself has aged out. The
1785
+ * record's field is `contractKey` and the locator's segment is
1786
+ * `blueprintKey`: one value, named for the record on one side and for
1787
+ * its domain on the other.
1788
+ *
1789
+ * Optional, and inert until something reads it: writes are
1790
+ * best-effort (a rejecting store logs and the render proceeds), and
1791
+ * with no store bound nothing is written and every read path behaves
1792
+ * exactly as before. Deployments whose GguiSessionStore is already
1793
+ * durable enough to outlive the renders it holds do not need one —
1794
+ * the row still carries everything.
1795
+ */
1796
+ readonly renderIdentityStore?: RenderIdentityStore;
1657
1797
  /**
1658
1798
  * Content-addressable code blob storage. When wired, this server
1659
1799
  * mounts `GET /code/<hash>.js` for the iframe runtime to fetch
@@ -1661,9 +1801,11 @@ export interface CreateGguiServerOptions {
1661
1801
  * to the store before emitting `codeUrl` on the `ai.ggui/render`
1662
1802
  * slice.
1663
1803
  *
1664
- * Defaults: when omitted the route is NOT mounted; the render
1665
- * handler falls back to inline base64 `componentCode` on the
1666
- * `ai.ggui/render` slice (legacy delivery channel).
1804
+ * Defaults: when omitted the route is NOT mounted and the
1805
+ * `ai.ggui/render` slice carries no `codeUrl` — there is no second
1806
+ * static channel behind it. Such a deployment delivers renders
1807
+ * through the live trio (`wsUrl` + `wsToken`); one that wires
1808
+ * neither cannot mount a static component at all.
1667
1809
  *
1668
1810
  * OSS dev wires `FileSystemCodeStore` (rooted at `~/.ggui/code-cache/`)
1669
1811
  * via `ggui-cli/buildMcpServerBackend`. Tests wire
@@ -1673,6 +1815,24 @@ export interface CreateGguiServerOptions {
1673
1815
  * across deployments — only the storage adapter changes.
1674
1816
  */
1675
1817
  readonly codeStore?: CodeStore;
1818
+ /**
1819
+ * Durable write-through for freshly registered blueprints (#430
1820
+ * slice 2). When wired, a registration that mints a new row also
1821
+ * persists it — metadata to the `BlueprintStore`, the compiled body
1822
+ * to the `CodeStore` — so the blueprint stays resolvable by id after
1823
+ * the capped in-process registry has evicted it or the process has
1824
+ * restarted.
1825
+ *
1826
+ * Separate from {@link codeStore} on purpose: that one backs the
1827
+ * `GET /code/<hash>.js` delivery route and binding it changes what
1828
+ * the render slice emits. This one is retention, and a deployment
1829
+ * may reasonably want one without the other.
1830
+ *
1831
+ * Defaults: omitted ⇒ no write-through, no events, and the registry
1832
+ * remains a blueprint's only home. Best-effort when present — a
1833
+ * failing store logs a named event and registration still succeeds.
1834
+ */
1835
+ readonly durableBlueprints?: BlueprintDurabilityDeps;
1676
1836
  /**
1677
1837
  * Provisional A2UI preview wiring for `ggui_render`. When the config
1678
1838
  * flag is on, every qualifying component render kicks off the