@ggui-ai/mcp-server 0.6.2 → 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.
- package/dist/api-renders-routes.d.ts.map +1 -1
- package/dist/api-renders-routes.js +9 -1
- package/dist/browser-cors.d.ts +29 -0
- package/dist/browser-cors.d.ts.map +1 -0
- package/dist/browser-cors.js +64 -0
- package/dist/build-mcp.d.ts.map +1 -1
- package/dist/build-mcp.js +5 -1
- package/dist/code-store-fs.d.ts +3 -0
- package/dist/code-store-fs.d.ts.map +1 -1
- package/dist/code-store-fs.js +27 -3
- package/dist/ggui-session-channel/outbound.d.ts.map +1 -1
- package/dist/ggui-session-channel/outbound.js +12 -0
- package/dist/ggui-session-channel/socket-router.d.ts +33 -0
- package/dist/ggui-session-channel/socket-router.d.ts.map +1 -1
- package/dist/ggui-session-channel/socket-router.js +155 -0
- package/dist/ggui-session-channel/subscribe.d.ts.map +1 -1
- package/dist/ggui-session-channel/subscribe.js +43 -2
- package/dist/ggui-session-channel.d.ts +114 -0
- package/dist/ggui-session-channel.d.ts.map +1 -1
- package/dist/ggui-session-channel.js +77 -2
- package/dist/health-routes.d.ts +3 -0
- package/dist/health-routes.d.ts.map +1 -1
- package/dist/health-routes.js +11 -1
- package/dist/mcp-apps-outbound.d.ts +191 -30
- package/dist/mcp-apps-outbound.d.ts.map +1 -1
- package/dist/mcp-apps-outbound.js +843 -223
- package/dist/origin-validation.d.ts +120 -0
- package/dist/origin-validation.d.ts.map +1 -0
- package/dist/origin-validation.js +199 -0
- package/dist/render-read-gate.d.ts +50 -0
- package/dist/render-read-gate.d.ts.map +1 -0
- package/dist/render-read-gate.js +36 -0
- package/dist/server.d.ts +164 -4
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +206 -20
- 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
|
|
1665
|
-
*
|
|
1666
|
-
*
|
|
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
|