@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
|
@@ -31,12 +31,14 @@
|
|
|
31
31
|
* build target — per the design lock, the shell is served by the
|
|
32
32
|
* same `@ggui-ai/mcp-server` instance that mints the bootstrap.
|
|
33
33
|
*/
|
|
34
|
-
import { deriveBundleOrigins, deriveContractBundle, derivePublicEnvProjection, deriveRenderMeta, findBlueprintExact, } from "@ggui-ai/mcp-server-handlers/renders";
|
|
35
|
-
import { deriveContextDefault, isRecord } from "@ggui-ai/protocol";
|
|
34
|
+
import { deriveBundleOrigins, deriveContractBundle, derivePublicEnvProjection, deriveRenderMeta, filterDescriptorsToContract, findBlueprintExact, } from "@ggui-ai/mcp-server-handlers/renders";
|
|
35
|
+
import { RESOURCE_NOT_FOUND_MESSAGE, deriveContextDefault, isRecord, resolveAppGadgets, resourceReadErrorToJsonRpc, } from "@ggui-ai/protocol";
|
|
36
36
|
import { GGUI_RENDER_RESOURCE_MIME, GGUI_RENDER_RESOURCE_URI, GGUI_RENDER_SHELL_SURFACE, MCP_APPS_UI_CAPABILITY, MCP_APP_BOOTSTRAP_FAILED_TYPE, asGguiRenderBootstrap, deriveContextName, gguiShellHtml, toMcpAppEnvelope, } from "@ggui-ai/protocol/integrations/mcp-apps";
|
|
37
37
|
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
38
38
|
import { registerAppResource } from "@modelcontextprotocol/ext-apps/server";
|
|
39
39
|
import { createHash } from "node:crypto";
|
|
40
|
+
import { renderReadAllowed } from "./render-read-gate.js";
|
|
41
|
+
import { DEFAULT_BUILDER_APP_ID } from "./auth.js";
|
|
40
42
|
/**
|
|
41
43
|
* Thin-shell body served from `ui://ggui/render` (C8 pivot).
|
|
42
44
|
*
|
|
@@ -314,7 +316,7 @@ postRpc('ui/initialize',{
|
|
|
314
316
|
// Value-resolution only — no `--ggui-*` token added or renamed. The
|
|
315
317
|
// constant itself lives with the protocol host-helper (the shared
|
|
316
318
|
// self-contained-shell assembler paints the same surface); imported
|
|
317
|
-
// above and reused here for the thin postMessage shell
|
|
319
|
+
// above and reused here for the thin postMessage shell.
|
|
318
320
|
// `#ggui-root` here is LOAD-BEARING for the shell script (NOT a React
|
|
319
321
|
// mount target): the inline script grabs it as `rootEl` for the
|
|
320
322
|
// pre-mount overlays ("Initializing…", "Waiting for tool result…",
|
|
@@ -647,32 +649,104 @@ export function buildSelfContainedShell(opts) {
|
|
|
647
649
|
return gguiShellHtml(bootstrap, { background: "surface" });
|
|
648
650
|
}
|
|
649
651
|
/**
|
|
650
|
-
*
|
|
651
|
-
* for a render whose visible-bits surface has no componentCode yet
|
|
652
|
-
* (placeholder, generation in flight). Renders a tiny status surface
|
|
653
|
-
* so hosts that pin lifecycle selectors don't see a blank document.
|
|
652
|
+
* A `resources/read` on a render locator that cannot return a mount.
|
|
654
653
|
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
654
|
+
* Thrown rather than returned: the MCP transport turns a thrown error
|
|
655
|
+
* carrying a numeric `code` into a JSON-RPC error response, and a
|
|
656
|
+
* JSON-RPC error is the only exit from the handler that is not a
|
|
657
|
+
* successful result. That is what makes "any successful `contents`
|
|
658
|
+
* result IS a mountable shell" checkable by a host instead of a claim
|
|
659
|
+
* in a docstring.
|
|
659
660
|
*
|
|
660
|
-
* @
|
|
661
|
+
* The wire body comes from {@link resourceReadErrorToJsonRpc} and
|
|
662
|
+
* nowhere else. Routing every branch through the protocol's projection
|
|
663
|
+
* is what keeps a refused read and a read of a locator that never
|
|
664
|
+
* existed byte-identical: the projection substitutes a constant message
|
|
665
|
+
* and drops `detail` on `NOT_FOUND`, so no call site can leak a
|
|
666
|
+
* diagnostic by writing a more helpful message.
|
|
661
667
|
*/
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
.
|
|
671
|
-
.
|
|
672
|
-
.
|
|
673
|
-
|
|
674
|
-
</body></html>`;
|
|
668
|
+
class ResourceReadFailure extends Error {
|
|
669
|
+
/** JSON-RPC error number — read off this instance by the transport. */
|
|
670
|
+
code;
|
|
671
|
+
/** JSON-RPC `error.data` — the closed classification plus optional detail. */
|
|
672
|
+
data;
|
|
673
|
+
constructor(failure) {
|
|
674
|
+
const wire = resourceReadErrorToJsonRpc(failure);
|
|
675
|
+
super(wire.message);
|
|
676
|
+
this.name = "ResourceReadFailure";
|
|
677
|
+
this.code = wire.code;
|
|
678
|
+
this.data = wire.data;
|
|
679
|
+
}
|
|
675
680
|
}
|
|
681
|
+
/**
|
|
682
|
+
* The terminal failure for a read that resolved nothing, on a server
|
|
683
|
+
* that DOES keep durable records. One constant, so the branches that
|
|
684
|
+
* must stay indistinguishable — a locator that never existed, one whose
|
|
685
|
+
* record is gone, and one the caller may not read — have no shape in
|
|
686
|
+
* which to differ. The message is stated rather than left blank because
|
|
687
|
+
* the projection substitutes it anyway; writing it makes the call sites
|
|
688
|
+
* honest about what goes on the wire.
|
|
689
|
+
*/
|
|
690
|
+
const NOT_FOUND_FAILURE = {
|
|
691
|
+
code: "NOT_FOUND",
|
|
692
|
+
message: RESOURCE_NOT_FOUND_MESSAGE,
|
|
693
|
+
};
|
|
694
|
+
/**
|
|
695
|
+
* The terminal failure for the same reads on a server that keeps no
|
|
696
|
+
* durable record. It replaces {@link NOT_FOUND_FAILURE} for EVERY
|
|
697
|
+
* locator on such a server, never for some of them: it describes the
|
|
698
|
+
* server, so a read that answered it for a missing locator and
|
|
699
|
+
* `NOT_FOUND` for an existing-but-refused one would tell a caller which
|
|
700
|
+
* locators exist — the disclosure the access check exists to prevent.
|
|
701
|
+
*/
|
|
702
|
+
const NOT_SUPPORTED_FAILURE = {
|
|
703
|
+
code: "NOT_SUPPORTED",
|
|
704
|
+
message: "This server keeps no durable record of a render, so a locator whose render is gone cannot be restored.",
|
|
705
|
+
};
|
|
706
|
+
/**
|
|
707
|
+
* The three ways a resolved locator still yields nothing to mount. One
|
|
708
|
+
* caller-facing message per code, with `detail` carrying the
|
|
709
|
+
* discrimination — a host's decision is the same for all three, while
|
|
710
|
+
* an operator needs to know which link is missing.
|
|
711
|
+
*/
|
|
712
|
+
const notMountable = (detail) => ({
|
|
713
|
+
code: "NOT_MOUNTABLE",
|
|
714
|
+
message: "This locator resolved, but nothing mountable can be produced for it.",
|
|
715
|
+
detail,
|
|
716
|
+
});
|
|
717
|
+
/** Neither delivery channel is wired, so a component cannot reach an iframe. */
|
|
718
|
+
const NO_DELIVERY_CHANNEL_FAILURE = notMountable("no static component URL and no live channel is wired");
|
|
719
|
+
/** The row exists and is the caller's, but its generation has not landed. */
|
|
720
|
+
const NOT_YET_COMMITTED_FAILURE = notMountable("the render has not committed a component yet");
|
|
721
|
+
/** A blueprint matched the locator's key, but its code cannot be delivered. */
|
|
722
|
+
const BLUEPRINT_UNDELIVERABLE_FAILURE = notMountable("the matched blueprint has no delivery channel");
|
|
723
|
+
/**
|
|
724
|
+
* The four ways a durable record stops short of a component. Same
|
|
725
|
+
* shape and the same reasoning as {@link notMountable} above. All four
|
|
726
|
+
* are reachable only after the access check has passed, so naming the
|
|
727
|
+
* step discloses nothing about a locator the caller cannot read.
|
|
728
|
+
*/
|
|
729
|
+
const blueprintUnresolvable = (detail) => ({
|
|
730
|
+
code: "BLUEPRINT_UNRESOLVABLE",
|
|
731
|
+
message: "The record for this locator no longer resolves to a component.",
|
|
732
|
+
detail,
|
|
733
|
+
});
|
|
734
|
+
/**
|
|
735
|
+
* The single value returned for BOTH "no record was ever written" and
|
|
736
|
+
* "the caller is not entitled to this record". One shared constant
|
|
737
|
+
* rather than two equal literals: the two branches cannot drift apart
|
|
738
|
+
* later, because there is only one of them to change.
|
|
739
|
+
*/
|
|
740
|
+
const RECORD_UNAVAILABLE = { ok: false, failure: NOT_FOUND_FAILURE };
|
|
741
|
+
/**
|
|
742
|
+
* Retention this read path assumes when the operator names none —
|
|
743
|
+
* deliberately the same hour `ggui_render` falls back to, so a
|
|
744
|
+
* deployment that has never set the knob gets one answer rather than
|
|
745
|
+
* two. Every use of it is behind
|
|
746
|
+
* {@link GguiRenderResourceTemplateOptions.renderTtlMs}, which is where
|
|
747
|
+
* a real deployment's retention comes from.
|
|
748
|
+
*/
|
|
749
|
+
const FALLBACK_RENDER_TTL_MS = 60 * 60 * 1000;
|
|
676
750
|
function pickComponentFromGguiSession(render) {
|
|
677
751
|
if (!render)
|
|
678
752
|
return null;
|
|
@@ -714,11 +788,79 @@ function pickComponentFromGguiSession(render) {
|
|
|
714
788
|
* legacy postMessage shell at the static URI, self-contained shell at
|
|
715
789
|
* the templated URI.
|
|
716
790
|
*
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
*
|
|
791
|
+
* # The obligation
|
|
792
|
+
*
|
|
793
|
+
* A read returns EITHER a shell carrying mount material — a static
|
|
794
|
+
* component URL, a live channel, or a system card — OR exactly one
|
|
795
|
+
* typed JSON-RPC error. There is no third outcome, and in particular no
|
|
796
|
+
* successful result wrapping a shell that can never paint anything. A
|
|
797
|
+
* host can check this without trusting the server: if it got
|
|
798
|
+
* `contents`, it got something mountable.
|
|
799
|
+
*
|
|
800
|
+
* The obligation covers outcomes this server can DECIDE. A malfunction
|
|
801
|
+
* still reaches the caller as an internal error (`-32603`) carrying
|
|
802
|
+
* none of the four codes: a template wired with an empty `runtimeUrl`,
|
|
803
|
+
* or a delivery channel that faults **and leaves the read with no other
|
|
804
|
+
* channel to mount through**. A fault the read survives — the usual
|
|
805
|
+
* case on a deployment wiring both channels — is not an outcome at all;
|
|
806
|
+
* the render mounts through whatever is left. That split is deliberate:
|
|
807
|
+
* `-32603` says "something is broken here", which the four codes must
|
|
808
|
+
* never be diluted into claiming, and equally must not be raised over a
|
|
809
|
+
* blip the server routed around.
|
|
810
|
+
*
|
|
811
|
+
* Three resolutions are tried, in this order, and any of them can
|
|
812
|
+
* produce the mount:
|
|
813
|
+
*
|
|
814
|
+
* 1. The render row itself.
|
|
815
|
+
* 2. A re-mint from the durable identity record, when the row is gone
|
|
816
|
+
* and this server keeps one ({@link GguiRenderResourceTemplateOptions.renderIdentityStore}
|
|
817
|
+
* + {@link GguiRenderResourceTemplateOptions.durableBlueprints}).
|
|
818
|
+
* 3. The blueprint registry, keyed by the locator's own `blueprintKey`
|
|
819
|
+
* — the original component with its authoring-time defaults rather
|
|
820
|
+
* than the state the render last held.
|
|
821
|
+
*
|
|
822
|
+
* # Failure modes
|
|
823
|
+
*
|
|
824
|
+
* Which code a given read produces is fully predictable from two facts:
|
|
825
|
+
* whether this server binds a durable substrate (both
|
|
826
|
+
* {@link GguiRenderResourceTemplateOptions.renderIdentityStore} and
|
|
827
|
+
* {@link GguiRenderResourceTemplateOptions.durableBlueprints} — either
|
|
828
|
+
* one alone is as good as neither), and how far the read got.
|
|
829
|
+
*
|
|
830
|
+
* - `NOT_FOUND` (`-32002`) — nothing resolved the locator, **and** the
|
|
831
|
+
* response for a caller who may not read a locator that DOES
|
|
832
|
+
* resolve. Those two are byte-identical by construction: both route
|
|
833
|
+
* through the protocol's projection, which substitutes a constant
|
|
834
|
+
* message and drops `detail`. A distinguishable refusal would turn
|
|
835
|
+
* this read into an oracle for the existence of other callers'
|
|
836
|
+
* renders, so the equality is a security property, not a courtesy.
|
|
837
|
+
* - `NOT_SUPPORTED` (`-32006`) — the same two cases, on a server with
|
|
838
|
+
* no durable substrate: an evicted locator can never be restored
|
|
839
|
+
* here, so saying "not found" would understate it. It takes
|
|
840
|
+
* `NOT_FOUND`'s place WHOLESALE on such a server rather than
|
|
841
|
+
* answering for some locators and not others — a server that said
|
|
842
|
+
* `NOT_FOUND` for a row it refused and `NOT_SUPPORTED` for one that
|
|
843
|
+
* never existed would have rebuilt the same oracle out of the two
|
|
844
|
+
* codes. Correspondingly, a server that DOES bind the substrate
|
|
845
|
+
* never emits it.
|
|
846
|
+
* - `BLUEPRINT_UNRESOLVABLE` (`-32006`) — a record named the render
|
|
847
|
+
* but its component is gone; `detail` names which link broke.
|
|
848
|
+
* Reachable only after the access check.
|
|
849
|
+
* - `NOT_MOUNTABLE` (`-32006`) — something resolved, but nothing
|
|
850
|
+
* mountable can be produced from it: no delivery channel is wired
|
|
851
|
+
* (neither {@link GguiRenderResourceTemplateOptions.codeStore} nor
|
|
852
|
+
* {@link GguiRenderResourceTemplateOptions.mintWsToken}), the row's
|
|
853
|
+
* generation has not committed a component yet, or a registry
|
|
854
|
+
* blueprint matched but cannot be delivered. Unlike the pair above,
|
|
855
|
+
* this one does NOT vary with the substrate: it is what the
|
|
856
|
+
* caller's OWN row gets on every server, because "this server
|
|
857
|
+
* cannot rehydrate" is not the useful truth for a row that is
|
|
858
|
+
* sitting right there. Reachable only after the access check, or —
|
|
859
|
+
* for the registry case — off the caller's own supplied key, so it
|
|
860
|
+
* discloses nothing either way.
|
|
861
|
+
*
|
|
862
|
+
* A URI matching neither template never reaches any of this: the
|
|
863
|
+
* transport rejects it as invalid params, outside these four codes.
|
|
722
864
|
*
|
|
723
865
|
* Returns nothing; mutates the server in place.
|
|
724
866
|
*
|
|
@@ -730,16 +872,20 @@ export function registerGguiRenderResourceTemplate(server, opts) {
|
|
|
730
872
|
// 1. Single-segment legacy URI — `ui://ggui/render/{sessionId}`.
|
|
731
873
|
// Pre-resume-contract chats in claude.ai's history persisted
|
|
732
874
|
// this shape; we keep the registration so historical messages
|
|
733
|
-
// still rehydrate
|
|
875
|
+
// still rehydrate.
|
|
734
876
|
//
|
|
735
877
|
// 2. Two-segment resume URI — `ui://ggui/render/{sessionId}/
|
|
736
878
|
// {blueprintKey}`. Stamped by every render since the resume
|
|
737
|
-
// contract landed. Carries enough state for the handler to
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
//
|
|
879
|
+
// contract landed. Carries enough state for the handler to
|
|
880
|
+
// fall back to a registry-only render when the render is gone
|
|
881
|
+
// but the blueprint is still cached (the original card with
|
|
882
|
+
// default props/context instead of a typed failure).
|
|
883
|
+
//
|
|
884
|
+
// Both shapes ALSO re-mint from a durable identity record when
|
|
885
|
+
// the deployment keeps one — that path is keyed by sessionId
|
|
886
|
+
// alone and needs no blueprintKey, so it serves the legacy
|
|
887
|
+
// shape too. Every lookup either shape triggers happens after
|
|
888
|
+
// the access check, never in parallel with it.
|
|
743
889
|
const legacyTemplate = new ResourceTemplate(`${GGUI_RENDER_RESOURCE_URI}/{sessionId}`, {
|
|
744
890
|
// No list-callback — the resource set is unbounded per render
|
|
745
891
|
// count, and `resources/list` would leak render ids across
|
|
@@ -795,7 +941,575 @@ export function registerGguiRenderResourceTemplate(server, opts) {
|
|
|
795
941
|
},
|
|
796
942
|
],
|
|
797
943
|
});
|
|
798
|
-
|
|
944
|
+
/**
|
|
945
|
+
* The three stores a re-mint needs, or `null` when this server keeps
|
|
946
|
+
* no durable record of a render.
|
|
947
|
+
*
|
|
948
|
+
* Read through ONE accessor because two callers depend on the same
|
|
949
|
+
* answer: the re-mint itself, and the handler's choice of terminal
|
|
950
|
+
* failure. Deriving that choice separately is how a server could end
|
|
951
|
+
* up answering `NOT_SUPPORTED` for some locators and `NOT_FOUND` for
|
|
952
|
+
* others, which is a disclosure rather than a cosmetic difference.
|
|
953
|
+
*/
|
|
954
|
+
function durableSubstrate() {
|
|
955
|
+
const identityStore = opts.renderIdentityStore;
|
|
956
|
+
const blueprintStore = opts.durableBlueprints?.blueprintStore;
|
|
957
|
+
const bodyStore = opts.durableBlueprints?.codeStore;
|
|
958
|
+
if (!identityStore || !blueprintStore || !bodyStore)
|
|
959
|
+
return null;
|
|
960
|
+
// #457 — bound is not enough: every member must DECLARE durability.
|
|
961
|
+
// An operator binding three in-memory stores must get NOT_SUPPORTED
|
|
962
|
+
// (an honest "keeps no durable record"), not a NOT_FOUND that
|
|
963
|
+
// promises restorability a restart erases.
|
|
964
|
+
if (identityStore.durability !== "durable" ||
|
|
965
|
+
blueprintStore.durability !== "durable" ||
|
|
966
|
+
bodyStore.durability !== "durable") {
|
|
967
|
+
return null;
|
|
968
|
+
}
|
|
969
|
+
return { identityStore, blueprintStore, bodyStore };
|
|
970
|
+
}
|
|
971
|
+
/** Operator retention, or the shared fallback. Read in both places. */
|
|
972
|
+
const renderTtlMs = opts.renderTtlMs ?? FALLBACK_RENDER_TTL_MS;
|
|
973
|
+
/**
|
|
974
|
+
* Give a row that has outlived its `expiresAt` a full lifetime again.
|
|
975
|
+
*
|
|
976
|
+
* A store may keep a row readable past its expiry — the reaper runs
|
|
977
|
+
* on its own schedule, and some stamp the deletion deadline with a
|
|
978
|
+
* grace window on top. A read landing in that window is a caller
|
|
979
|
+
* entitled to the row proving they are actively using it, which is
|
|
980
|
+
* the same signal every other touch-and-extend path acts on, so the
|
|
981
|
+
* row gets its lifetime back.
|
|
982
|
+
*
|
|
983
|
+
* The live-channel token is the SHARPEST case, not the only one: a
|
|
984
|
+
* read that mints one turns "the row dies soon" into "the thing I
|
|
985
|
+
* was just handed outlives what it points at". But a deployment with
|
|
986
|
+
* no minter wired reaches this too, and its row earns the same
|
|
987
|
+
* extension — the read was just as real. Widening this to any active
|
|
988
|
+
* use is why the call site sits ahead of the mint rather than inside
|
|
989
|
+
* it.
|
|
990
|
+
*
|
|
991
|
+
* CONDITIONAL, and that is the load-bearing half: a resource read is
|
|
992
|
+
* the hottest path this handler has, so extending a row that is
|
|
993
|
+
* already live would put a store write on every read of every render
|
|
994
|
+
* for nothing.
|
|
995
|
+
*
|
|
996
|
+
* A failure does not fail the read. The caller owns this render and
|
|
997
|
+
* the handler can mount it; refusing over an extension would turn a
|
|
998
|
+
* store's bad moment into a dead card, and the pre-extension
|
|
999
|
+
* behavior — a token that may outlive its row — is what the read
|
|
1000
|
+
* would have done anyway. It is logged rather than swallowed,
|
|
1001
|
+
* because a row that could not be extended is now living on borrowed
|
|
1002
|
+
* time and nothing else will say so.
|
|
1003
|
+
*
|
|
1004
|
+
* One asymmetry worth knowing about: this moves the row's OWN
|
|
1005
|
+
* `expiresAt`, and the render payload stored inside it keeps the
|
|
1006
|
+
* value stamped at the last commit. Two spellings of one fact,
|
|
1007
|
+
* briefly disagreeing. Which one a reader sees is the store's
|
|
1008
|
+
* business — a store that re-projects lifecycle columns onto the
|
|
1009
|
+
* payload heals it on the very next read, one that stores the payload
|
|
1010
|
+
* verbatim carries the stale copy until the next commit. The
|
|
1011
|
+
* authoritative field is the row's, which is what the lifecycle
|
|
1012
|
+
* gates and the reaper both read; the payload's copy is a projection
|
|
1013
|
+
* for the wire. Extending both here would mean rewriting the payload
|
|
1014
|
+
* on a read, which is a much larger write for a field nothing gates
|
|
1015
|
+
* on.
|
|
1016
|
+
*/
|
|
1017
|
+
/**
|
|
1018
|
+
* #457 — the row's total-lifetime ceiling, or undefined when the
|
|
1019
|
+
* operator set none / the row predates the createdAt column (the
|
|
1020
|
+
* legacy projection is 0, and a cap keyed on that sentinel would
|
|
1021
|
+
* mass-kill pre-column rows).
|
|
1022
|
+
*/
|
|
1023
|
+
function lifetimeCapFor(subject) {
|
|
1024
|
+
if (opts.maxRenderLifetimeMs === undefined)
|
|
1025
|
+
return undefined;
|
|
1026
|
+
if (subject.createdAt === 0)
|
|
1027
|
+
return undefined;
|
|
1028
|
+
return subject.createdAt + opts.maxRenderLifetimeMs;
|
|
1029
|
+
}
|
|
1030
|
+
async function extendExpiredRow(sessionId, stored) {
|
|
1031
|
+
const now = Date.now();
|
|
1032
|
+
if (stored.expiresAt > now)
|
|
1033
|
+
return;
|
|
1034
|
+
// #457 — resurrection is bounded. Past the cap: serve the read
|
|
1035
|
+
// (a mount is not retention) but extend nothing — the row reaps
|
|
1036
|
+
// at its standing deadline. Under the cap: extend, clamped to it.
|
|
1037
|
+
// Either clamp branch logs; successful UNCAPPED extension stays
|
|
1038
|
+
// silent as before, so the one new line is the bounded-retention
|
|
1039
|
+
// signal, not a metrics build-out.
|
|
1040
|
+
const cap = lifetimeCapFor(stored);
|
|
1041
|
+
if (cap !== undefined && now >= cap) {
|
|
1042
|
+
opts.logger?.warn("render_resource_ttl_extend_capped", {
|
|
1043
|
+
sessionId,
|
|
1044
|
+
createdAt: stored.createdAt,
|
|
1045
|
+
cap,
|
|
1046
|
+
});
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
const target = cap !== undefined ? Math.min(now + renderTtlMs, cap) : now + renderTtlMs;
|
|
1050
|
+
try {
|
|
1051
|
+
await opts.renderStore.update(sessionId, { expiresAt: target });
|
|
1052
|
+
if (cap !== undefined && target < now + renderTtlMs) {
|
|
1053
|
+
opts.logger?.warn("render_resource_ttl_extend_capped", {
|
|
1054
|
+
sessionId,
|
|
1055
|
+
createdAt: stored.createdAt,
|
|
1056
|
+
cap,
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
catch (cause) {
|
|
1061
|
+
opts.logger?.warn("render_resource_ttl_extend_failed", {
|
|
1062
|
+
sessionId,
|
|
1063
|
+
expiredAt: stored.expiresAt,
|
|
1064
|
+
error: cause instanceof Error ? cause.message : String(cause),
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* Rehydration access control (spec §3), applied to ONE read
|
|
1070
|
+
* candidate — a render row, or the durable identity record that
|
|
1071
|
+
* stands in for an evicted one. Both carry the same two gated
|
|
1072
|
+
* fields, so both go through here.
|
|
1073
|
+
*
|
|
1074
|
+
* The anonymous-context synthesis is why this is shared rather than
|
|
1075
|
+
* inlined twice: a compose path that cannot thread a request context
|
|
1076
|
+
* still reads renders owned by the default builder identity, because
|
|
1077
|
+
* a candidate owned by THAT identity is attributed to an anonymous
|
|
1078
|
+
* caller. Anything else fails closed — `renderReadAllowed` denies a
|
|
1079
|
+
* missing context outright. Losing the synthesis on the record path
|
|
1080
|
+
* would break exactly the deployments the re-mint exists to help.
|
|
1081
|
+
*
|
|
1082
|
+
* Refusal is never an early return at the call sites: it collapses
|
|
1083
|
+
* the candidate to "absent" so every downstream branch runs as it
|
|
1084
|
+
* would for a locator that never existed. The refusal is observable
|
|
1085
|
+
* only server-side, on the audit line below — whose `rowAppId` names
|
|
1086
|
+
* the render's owner whichever store answered for it.
|
|
1087
|
+
*/
|
|
1088
|
+
function readAllowed(sessionId, candidate) {
|
|
1089
|
+
const callerCtx = opts.getContext?.();
|
|
1090
|
+
const fallbackCtx = callerCtx === undefined && candidate.appId === DEFAULT_BUILDER_APP_ID
|
|
1091
|
+
? {
|
|
1092
|
+
appId: DEFAULT_BUILDER_APP_ID,
|
|
1093
|
+
authSource: "anonymous",
|
|
1094
|
+
requestId: "resource-read",
|
|
1095
|
+
}
|
|
1096
|
+
: callerCtx;
|
|
1097
|
+
if (renderReadAllowed(candidate, fallbackCtx))
|
|
1098
|
+
return true;
|
|
1099
|
+
opts.logger?.warn("render_resource_read_denied", {
|
|
1100
|
+
sessionId,
|
|
1101
|
+
rowAppId: candidate.appId,
|
|
1102
|
+
callerAppId: callerCtx?.appId,
|
|
1103
|
+
});
|
|
1104
|
+
return false;
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Re-mint an evicted locator from its durable identity record: the
|
|
1108
|
+
* record names the blueprint, the blueprint names the body, and the
|
|
1109
|
+
* body is committed back onto a FRESH row under the same sessionId.
|
|
1110
|
+
* Returns the committed row, or the failure that stopped it.
|
|
1111
|
+
*
|
|
1112
|
+
* The two branches that must stay indistinguishable — no record was
|
|
1113
|
+
* ever written, and the caller is not entitled to the one that was —
|
|
1114
|
+
* return the SAME constant, so there is no shape in which they could
|
|
1115
|
+
* differ. Every other failure is reachable only after the access
|
|
1116
|
+
* check has passed, and so may say what went wrong.
|
|
1117
|
+
*
|
|
1118
|
+
* Ordering is load-bearing. The record's own owner gates the read
|
|
1119
|
+
* before ANY blueprint or body lookup runs, and every lookup is
|
|
1120
|
+
* keyed by the record's `blueprintId` under the record's own owner —
|
|
1121
|
+
* never by a caller-supplied key. A resolution step reachable before
|
|
1122
|
+
* the check would let a caller distinguish a resolvable locator from
|
|
1123
|
+
* one that never existed, which is the same disclosure the gate
|
|
1124
|
+
* exists to prevent.
|
|
1125
|
+
*
|
|
1126
|
+
* The body is INLINED onto the committed row rather than delivered
|
|
1127
|
+
* by URL: a row carrying its own component code is self-sufficient,
|
|
1128
|
+
* so the existing mount path serves it with no extra wiring, and
|
|
1129
|
+
* deployments with no content-addressed delivery channel re-mint
|
|
1130
|
+
* just as well as those with one.
|
|
1131
|
+
*
|
|
1132
|
+
* Store faults are not caught. A rejecting store is a malfunction,
|
|
1133
|
+
* and swallowing it here would report a working render as
|
|
1134
|
+
* unresolvable — indistinguishable from one that was genuinely
|
|
1135
|
+
* purged, and it would stay that way on every retry.
|
|
1136
|
+
*/
|
|
1137
|
+
async function remintFromRecord(sessionId) {
|
|
1138
|
+
const substrate = durableSubstrate();
|
|
1139
|
+
if (substrate === null)
|
|
1140
|
+
return { ok: false, failure: NOT_SUPPORTED_FAILURE };
|
|
1141
|
+
const { identityStore, blueprintStore, bodyStore } = substrate;
|
|
1142
|
+
const record = await identityStore.get(sessionId);
|
|
1143
|
+
if (record === null)
|
|
1144
|
+
return RECORD_UNAVAILABLE;
|
|
1145
|
+
if (!readAllowed(sessionId, {
|
|
1146
|
+
appId: record.appId,
|
|
1147
|
+
// The record's SUBJECT — the same field the row's gate binds
|
|
1148
|
+
// on, written by the same commit.
|
|
1149
|
+
...(record.userId !== undefined ? { userId: record.userId } : {}),
|
|
1150
|
+
})) {
|
|
1151
|
+
return RECORD_UNAVAILABLE;
|
|
1152
|
+
}
|
|
1153
|
+
// Everything below can only be reached by a caller entitled to
|
|
1154
|
+
// this locator. A null `blueprintId` is terminal (#460): the id is
|
|
1155
|
+
// resolved before the success commit, so null means registration
|
|
1156
|
+
// failed or was unavailable at commit — the record names nothing
|
|
1157
|
+
// to resolve, by design (#445).
|
|
1158
|
+
if (record.blueprintId === null) {
|
|
1159
|
+
return { ok: false, failure: blueprintUnresolvable("the record names no blueprint") };
|
|
1160
|
+
}
|
|
1161
|
+
const blueprint = await blueprintStore.get(record.blueprintId);
|
|
1162
|
+
if (blueprint === null) {
|
|
1163
|
+
return {
|
|
1164
|
+
ok: false,
|
|
1165
|
+
failure: blueprintUnresolvable("the blueprint the record names is gone"),
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
const codeHash = blueprint.codeHash;
|
|
1169
|
+
if (codeHash === undefined) {
|
|
1170
|
+
return {
|
|
1171
|
+
ok: false,
|
|
1172
|
+
failure: blueprintUnresolvable("the blueprint stores no component reference"),
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
const componentCode = await bodyStore.get(codeHash);
|
|
1176
|
+
if (componentCode === null || componentCode.length === 0) {
|
|
1177
|
+
return {
|
|
1178
|
+
ok: false,
|
|
1179
|
+
failure: blueprintUnresolvable("the component body behind the blueprint is gone"),
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
// Sidecars are re-resolved from the live app record rather than
|
|
1183
|
+
// carried on the record: gadget descriptors and the theme overlay
|
|
1184
|
+
// are the operator's current configuration, and a re-minted render
|
|
1185
|
+
// should mount under it, not under a snapshot of what it was.
|
|
1186
|
+
//
|
|
1187
|
+
// Degrades rather than fails, matching the same lookup on the mount
|
|
1188
|
+
// path below: these are presentation sidecars, so a metadata store
|
|
1189
|
+
// having a bad moment costs the render its theme and its wrapper
|
|
1190
|
+
// catalog — it must not cost the render its rehydrate. The body and
|
|
1191
|
+
// the props, which a mount cannot do without, were already resolved
|
|
1192
|
+
// above and are NOT part of this tolerance.
|
|
1193
|
+
let gadgetDescriptors;
|
|
1194
|
+
let theme;
|
|
1195
|
+
if (opts.appMetadataStore) {
|
|
1196
|
+
try {
|
|
1197
|
+
const appRecord = await opts.appMetadataStore.get(record.appId);
|
|
1198
|
+
const resolved = filterDescriptorsToContract(blueprint.contract, resolveAppGadgets(appRecord?.gadgets));
|
|
1199
|
+
if (resolved.length > 0)
|
|
1200
|
+
gadgetDescriptors = resolved;
|
|
1201
|
+
theme = appRecord?.theme;
|
|
1202
|
+
}
|
|
1203
|
+
catch {
|
|
1204
|
+
// Silent — the render mounts with the renderer's default theme
|
|
1205
|
+
// and a STDLIB-only wrapper catalog.
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
const now = Date.now();
|
|
1209
|
+
const contract = blueprint.contract;
|
|
1210
|
+
const render = {
|
|
1211
|
+
type: "component",
|
|
1212
|
+
id: sessionId,
|
|
1213
|
+
appId: record.appId,
|
|
1214
|
+
componentCode,
|
|
1215
|
+
contentType: "application/javascript+react",
|
|
1216
|
+
// The props the render last carried — the whole point of the
|
|
1217
|
+
// record — restored verbatim, including their ABSENCE. A record
|
|
1218
|
+
// with no props describes a render that had none, so the re-mint
|
|
1219
|
+
// gives it none: `props` is optional on the wire shape and the
|
|
1220
|
+
// record round-trips the distinction.
|
|
1221
|
+
//
|
|
1222
|
+
// The one thing this must never do is substitute authoring-time
|
|
1223
|
+
// defaults for missing props. Nothing repopulates a
|
|
1224
|
+
// defaults-booted card afterwards — props travel the session
|
|
1225
|
+
// channel, and no agent turn runs at rehydration — so it would
|
|
1226
|
+
// show plausible-looking wrong state indefinitely, which is
|
|
1227
|
+
// worse than showing none.
|
|
1228
|
+
...(record.props !== undefined ? { props: record.props } : {}),
|
|
1229
|
+
...(contract.propsSpec ? { propsSpec: contract.propsSpec } : {}),
|
|
1230
|
+
...(contract.actionSpec ? { actionSpec: contract.actionSpec } : {}),
|
|
1231
|
+
...(contract.streamSpec ? { streamSpec: contract.streamSpec } : {}),
|
|
1232
|
+
...(contract.contextSpec ? { contextSpec: contract.contextSpec } : {}),
|
|
1233
|
+
...(contract.clientCapabilities
|
|
1234
|
+
? { clientCapabilities: contract.clientCapabilities }
|
|
1235
|
+
: {}),
|
|
1236
|
+
...(gadgetDescriptors !== undefined ? { gadgetDescriptors } : {}),
|
|
1237
|
+
...(theme !== undefined ? { theme } : {}),
|
|
1238
|
+
// Carried from the record so a re-minted render does not present
|
|
1239
|
+
// itself as newly created.
|
|
1240
|
+
createdAt: record.createdAt,
|
|
1241
|
+
lastActivityAt: now,
|
|
1242
|
+
// #457 — the re-mint is the other resurrection surface: capped
|
|
1243
|
+
// the same way the expired-read extension is, keyed on the
|
|
1244
|
+
// ORIGINAL createdAt the record carries. Past the cap the mount
|
|
1245
|
+
// still serves (a mount is not retention) but the row lands
|
|
1246
|
+
// already in-grace and un-extendable.
|
|
1247
|
+
expiresAt: Math.min(now + renderTtlMs, lifetimeCapFor(record) ?? Number.POSITIVE_INFINITY),
|
|
1248
|
+
// States the same fact as the `seqFloor` below, which is what
|
|
1249
|
+
// the store actually seeds the row's ledger from.
|
|
1250
|
+
eventSequence: record.seqAtLastCommit,
|
|
1251
|
+
};
|
|
1252
|
+
const row = await opts.renderStore.commit({
|
|
1253
|
+
render,
|
|
1254
|
+
appId: record.appId,
|
|
1255
|
+
...(record.userId !== undefined ? { userId: record.userId } : {}),
|
|
1256
|
+
// The render resumes rather than restarts: its ledger continues
|
|
1257
|
+
// above where the record says it last was, so a reader still
|
|
1258
|
+
// holding a cursor from before the eviction sees the new events
|
|
1259
|
+
// instead of filtering them out as already-seen.
|
|
1260
|
+
//
|
|
1261
|
+
// "Where the record says it last was" is the honest bound. The
|
|
1262
|
+
// record samples the sequence at COMMIT, so events appended
|
|
1263
|
+
// between the last commit and the eviction are not reflected and
|
|
1264
|
+
// their numbers do get reissued. This narrows sequence reuse to
|
|
1265
|
+
// that window rather than eliminating it — which is the best any
|
|
1266
|
+
// record-based resume can do, and still strictly better than
|
|
1267
|
+
// restarting the ledger at zero.
|
|
1268
|
+
seqFloor: record.seqAtLastCommit,
|
|
1269
|
+
});
|
|
1270
|
+
return { ok: true, row };
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Serve the self-contained shell for a render row — the mount path,
|
|
1274
|
+
* shared by rows read from the store and rows a re-mint just
|
|
1275
|
+
* committed.
|
|
1276
|
+
*
|
|
1277
|
+
* Three exits, and the caller has to handle all three:
|
|
1278
|
+
*
|
|
1279
|
+
* - a response, when the row resolved to something mountable;
|
|
1280
|
+
* - `null`, when the row carries no renderable visible-bits surface
|
|
1281
|
+
* (a placeholder whose generation has not committed yet), so the
|
|
1282
|
+
* caller can go on looking;
|
|
1283
|
+
* - a thrown {@link ResourceReadFailure}, when a render DID resolve
|
|
1284
|
+
* but no channel can deliver it. That one is terminal by design —
|
|
1285
|
+
* it is the deepest the read gets, so there is nothing left for
|
|
1286
|
+
* the caller to try.
|
|
1287
|
+
*/
|
|
1288
|
+
async function serveMount(uri, sessionId, accessibleStored) {
|
|
1289
|
+
const picked = pickComponentFromGguiSession(accessibleStored.render);
|
|
1290
|
+
if (!picked)
|
|
1291
|
+
return null;
|
|
1292
|
+
// Put an expired-but-still-readable row back on a full lifetime.
|
|
1293
|
+
//
|
|
1294
|
+
// The reason is that a caller entitled to this row has just proved
|
|
1295
|
+
// they are using it, which is the same signal every other
|
|
1296
|
+
// touch-and-extend path acts on. The live-channel token is the
|
|
1297
|
+
// sharpest case rather than the only one — it turns "the row dies
|
|
1298
|
+
// soon" into "the thing I just handed you outlives what it points
|
|
1299
|
+
// at" — which is why this sits ahead of the mint below. But a
|
|
1300
|
+
// deployment with no minter wired reaches here too, and its row
|
|
1301
|
+
// deserves the same extension: the read was just as real.
|
|
1302
|
+
//
|
|
1303
|
+
// After the `picked` check, because a row with nothing to mount is
|
|
1304
|
+
// not a row anyone is using yet.
|
|
1305
|
+
await extendExpiredRow(sessionId, accessibleStored);
|
|
1306
|
+
// Project the active render to the transport-agnostic bootstrap
|
|
1307
|
+
// view — same source of truth the render-mutation handler and
|
|
1308
|
+
// `/r/<shortCode>` consume. Carries permissionsPolicy when
|
|
1309
|
+
// clientCapabilities declares permissions. The MCP Apps
|
|
1310
|
+
// resource path emits this only into the inline bootstrap
|
|
1311
|
+
// (the browser-enforced gate ultimately comes from the host's
|
|
1312
|
+
// `allow=""` attribute when the host translates
|
|
1313
|
+
// `_meta.ui.permissions` — set by McpAppIframe consumers).
|
|
1314
|
+
const view = deriveRenderMeta(picked.source);
|
|
1315
|
+
const isSystem = picked.kind !== undefined;
|
|
1316
|
+
// A fault on EITHER delivery channel, held rather than acted on.
|
|
1317
|
+
//
|
|
1318
|
+
// Two things have to stay true at once. A channel that faulted must
|
|
1319
|
+
// never be reported as a channel that was never wired — that is
|
|
1320
|
+
// NOT_MOUNTABLE, which rides -32006 and tells the host the outcome
|
|
1321
|
+
// is deterministic and a retry cannot succeed, when a store having
|
|
1322
|
+
// a bad moment is the one thing that is not. But most deployments
|
|
1323
|
+
// wire BOTH channels, and there a fault on one is survivable: the
|
|
1324
|
+
// other still carries the mount, and failing the read would throw
|
|
1325
|
+
// away a perfectly good delivery path.
|
|
1326
|
+
//
|
|
1327
|
+
// So the fault is remembered here and consulted at the mount-mode
|
|
1328
|
+
// gate, which is the only place that knows whether anything
|
|
1329
|
+
// survived. If a channel did, the render mounts through it and the
|
|
1330
|
+
// fault costs the read nothing. If none did, the fault is thrown in
|
|
1331
|
+
// place of NOT_MOUNTABLE and reaches the caller as an internal
|
|
1332
|
+
// error — the honest answer for a blip, and the same policy the
|
|
1333
|
+
// re-mint path applies to its own stores.
|
|
1334
|
+
//
|
|
1335
|
+
// Wrapped in an object so a thrown `undefined` is still recorded as
|
|
1336
|
+
// a fault, and `??=` keeps the FIRST one when both channels break.
|
|
1337
|
+
let channelFault;
|
|
1338
|
+
// Static-component delivery via codeUrl. The compiled-component
|
|
1339
|
+
// path mints a content-addressable URL the iframe-runtime fetches
|
|
1340
|
+
// at boot. When codeStore + codeBaseUrl aren't wired this channel
|
|
1341
|
+
// simply does not exist, and the live channel below has to carry
|
|
1342
|
+
// the mount.
|
|
1343
|
+
let codeUrl;
|
|
1344
|
+
let codeHash;
|
|
1345
|
+
let contractHash;
|
|
1346
|
+
let validatorsUrl;
|
|
1347
|
+
if (!isSystem && opts.codeStore && opts.codeBaseUrl) {
|
|
1348
|
+
try {
|
|
1349
|
+
const hash = opts.codeStore.hashOf(picked.componentCode);
|
|
1350
|
+
await opts.codeStore.put(hash, picked.componentCode);
|
|
1351
|
+
codeHash = hash;
|
|
1352
|
+
const base = opts.codeBaseUrl.replace(/\/$/, "");
|
|
1353
|
+
codeUrl = `${base}/code/${hash}.js`;
|
|
1354
|
+
}
|
|
1355
|
+
catch (cause) {
|
|
1356
|
+
channelFault ??= { cause };
|
|
1357
|
+
}
|
|
1358
|
+
// Content-addressable contract-validator bundle (#109).
|
|
1359
|
+
try {
|
|
1360
|
+
const bundle = await deriveContractBundle(picked.source);
|
|
1361
|
+
if (bundle) {
|
|
1362
|
+
await opts.codeStore.put(bundle.contractHash, bundle.bundleSource);
|
|
1363
|
+
contractHash = bundle.contractHash;
|
|
1364
|
+
const base = opts.codeBaseUrl.replace(/\/$/, "");
|
|
1365
|
+
validatorsUrl = `${base}/contract/${bundle.contractHash}.js`;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
catch {
|
|
1369
|
+
// Silent, and unlike the two channel faults this one stays
|
|
1370
|
+
// that way: validators are an optional client-side courtesy,
|
|
1371
|
+
// the server-side gate is authoritative, and losing them costs
|
|
1372
|
+
// the read nothing it needs to mount. It cannot be mistaken for
|
|
1373
|
+
// an absent channel, which is what makes swallowing it safe
|
|
1374
|
+
// here and not above.
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
// The codeUrl gate is applied AFTER the live-channel mint below, so
|
|
1378
|
+
// a render with no static codeUrl still mounts via live-mode
|
|
1379
|
+
// (wsUrl + wsToken) instead of failing as undeliverable —
|
|
1380
|
+
// parity with the `/r/<shortCode>` path. (See the gate after the
|
|
1381
|
+
// mint.) This matters for deployments that wire `mintWsToken` but no
|
|
1382
|
+
// `codeStore`/`codeBaseUrl` (e.g. the cloud pod): the agent-server
|
|
1383
|
+
// inlines THIS resource, so without live-mode every render on such a
|
|
1384
|
+
// deployment would resolve fine and then be reported as having no
|
|
1385
|
+
// way to be delivered.
|
|
1386
|
+
// Project the wrapper catalog AND the union-filtered
|
|
1387
|
+
// publicEnv onto the inline bootstrap so the resource-served
|
|
1388
|
+
// iframe matches the MCP-Apps postMessage path. Without this,
|
|
1389
|
+
// wrapper-using contracts rendered through `resources/read`
|
|
1390
|
+
// mount as STDLIB-only.
|
|
1391
|
+
let resourcePublicEnv;
|
|
1392
|
+
if (opts.appMetadataStore) {
|
|
1393
|
+
try {
|
|
1394
|
+
const appRecord = await opts.appMetadataStore.get(accessibleStored.appId);
|
|
1395
|
+
resourcePublicEnv = derivePublicEnvProjection(picked.source, appRecord?.publicEnv);
|
|
1396
|
+
}
|
|
1397
|
+
catch {
|
|
1398
|
+
// Silent — wrappers calling getPublicEnv throw clearly.
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
// Live-channel bootstrap — when the operator wired
|
|
1402
|
+
// {@link GguiRenderResourceTemplateOptions.mintWsToken}, mint a
|
|
1403
|
+
// wsToken for this render so the iframe-runtime opens a
|
|
1404
|
+
// WebSocket on mount and receives `props_update` frames.
|
|
1405
|
+
// Without this, the resource shell renders in static-component
|
|
1406
|
+
// mode only — `ggui_update` server-side mutations never
|
|
1407
|
+
// visibly reach the live iframe (hosts must re-fetch
|
|
1408
|
+
// `resources/read` after every update tool result to see new
|
|
1409
|
+
// state).
|
|
1410
|
+
//
|
|
1411
|
+
// A mint FAULT is held the same way the code-store write above is,
|
|
1412
|
+
// for the same reason: `wsToken` left undefined is indistinguishable
|
|
1413
|
+
// from "no live channel is wired" by the time the gate reads it.
|
|
1414
|
+
let wsUrl;
|
|
1415
|
+
let wsToken;
|
|
1416
|
+
let wsExpiresAt;
|
|
1417
|
+
if (opts.mintWsToken) {
|
|
1418
|
+
try {
|
|
1419
|
+
const minted = opts.mintWsToken(sessionId, accessibleStored.appId);
|
|
1420
|
+
wsUrl = minted.wsUrl;
|
|
1421
|
+
wsToken = minted.token;
|
|
1422
|
+
// Forward the token TTL so the iframe-runtime can degrade to
|
|
1423
|
+
// static-only mode once it lapses (parity with the render-tool
|
|
1424
|
+
// slice projection, render.ts). Dropping it left the live-mode
|
|
1425
|
+
// resource shell unable to know when its WS token expired.
|
|
1426
|
+
wsExpiresAt = minted.expiresAt;
|
|
1427
|
+
}
|
|
1428
|
+
catch (cause) {
|
|
1429
|
+
channelFault ??= { cause };
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
// Mount-mode gate (below the live-channel mint): a compiled
|
|
1433
|
+
// component needs ONE of the two channels. A deployment that wires
|
|
1434
|
+
// no codeStore (codeUrl === undefined) but DOES wire mintWsToken
|
|
1435
|
+
// mounts via live-mode; one that wires neither has resolved a
|
|
1436
|
+
// render it cannot deliver, and says so.
|
|
1437
|
+
//
|
|
1438
|
+
// Terminal, deliberately: this is the deepest the read gets, so
|
|
1439
|
+
// there is nothing left to try. It also has to stay AHEAD of
|
|
1440
|
+
// `buildSelfContainedShell`, which throws a plain Error on the same
|
|
1441
|
+
// condition — that would reach the caller as an untyped internal
|
|
1442
|
+
// error announcing a malfunction where the server is behaving
|
|
1443
|
+
// exactly as configured.
|
|
1444
|
+
//
|
|
1445
|
+
// Reaching here having FAULTED is the one case that is not the
|
|
1446
|
+
// server behaving as configured, and it is the only place with
|
|
1447
|
+
// enough information to tell: a fault matters exactly when nothing
|
|
1448
|
+
// else produced a channel. Anywhere above this line the same fault
|
|
1449
|
+
// may have been survivable, and on a deployment wiring both
|
|
1450
|
+
// channels it usually is.
|
|
1451
|
+
if (!isSystem && codeUrl === undefined && (wsUrl === undefined || wsToken === undefined)) {
|
|
1452
|
+
if (channelFault !== undefined)
|
|
1453
|
+
throw channelFault.cause;
|
|
1454
|
+
throw new ResourceReadFailure(NO_DELIVERY_CHANNEL_FAILURE);
|
|
1455
|
+
}
|
|
1456
|
+
const html = buildSelfContainedShell({
|
|
1457
|
+
sessionId,
|
|
1458
|
+
appId: accessibleStored.appId,
|
|
1459
|
+
...(isSystem
|
|
1460
|
+
? { systemKind: picked.kind }
|
|
1461
|
+
: codeUrl !== undefined
|
|
1462
|
+
? {
|
|
1463
|
+
codeUrl,
|
|
1464
|
+
...(codeHash !== undefined ? { codeHash } : {}),
|
|
1465
|
+
}
|
|
1466
|
+
: // No static codeUrl → live-mode (wsUrl + token spread below)
|
|
1467
|
+
// carries the render; buildSelfContainedShell accepts
|
|
1468
|
+
// live-mode without codeUrl.
|
|
1469
|
+
{}),
|
|
1470
|
+
runtimeUrl: opts.runtimeUrl,
|
|
1471
|
+
...(wsUrl !== undefined && wsToken !== undefined
|
|
1472
|
+
? {
|
|
1473
|
+
wsUrl,
|
|
1474
|
+
token: wsToken,
|
|
1475
|
+
...(wsExpiresAt !== undefined ? { expiresAt: wsExpiresAt } : {}),
|
|
1476
|
+
}
|
|
1477
|
+
: {}),
|
|
1478
|
+
...(opts.themeId !== undefined ? { themeId: opts.themeId } : {}),
|
|
1479
|
+
...(opts.themeMode !== undefined ? { themeMode: opts.themeMode } : {}),
|
|
1480
|
+
// Per-app theme overlay projected by `deriveRenderMeta` from
|
|
1481
|
+
// the render's `theme` sidecar — forwarded so the
|
|
1482
|
+
// resource-served iframe matches the postMessage path.
|
|
1483
|
+
...(view.theme !== undefined ? { theme: view.theme } : {}),
|
|
1484
|
+
...(view.propsJson !== undefined ? { propsJson: view.propsJson } : {}),
|
|
1485
|
+
...(view.contextSlots !== undefined ? { contextSlots: view.contextSlots } : {}),
|
|
1486
|
+
...(view.permissionsPolicy !== undefined
|
|
1487
|
+
? { permissionsPolicy: view.permissionsPolicy }
|
|
1488
|
+
: {}),
|
|
1489
|
+
...(view.gadgets !== undefined && view.gadgets.length > 0
|
|
1490
|
+
? { gadgets: view.gadgets }
|
|
1491
|
+
: {}),
|
|
1492
|
+
...(contractHash !== undefined && validatorsUrl !== undefined
|
|
1493
|
+
? { contractHash, validatorsUrl }
|
|
1494
|
+
: {}),
|
|
1495
|
+
...(resourcePublicEnv !== undefined && Object.keys(resourcePublicEnv).length > 0
|
|
1496
|
+
? { publicEnv: resourcePublicEnv }
|
|
1497
|
+
: {}),
|
|
1498
|
+
// R6 — ledger cursor stamp for polling-cursor alignment.
|
|
1499
|
+
lastSequence: accessibleStored.eventSequence,
|
|
1500
|
+
});
|
|
1501
|
+
// Augment per-call CSP with gadget-declared bundle / style /
|
|
1502
|
+
// API origins. Without this, claude.ai's iframe CSP only allows
|
|
1503
|
+
// the publicBaseUrl origin, so Leaflet wrapper bundles fetched
|
|
1504
|
+
// from registry.ggui.ai, leaflet.css fetched from same, and
|
|
1505
|
+
// OSM tile requests to tile.openstreetmap.org all get blocked
|
|
1506
|
+
// → the component throws and the React error boundary renders
|
|
1507
|
+
// "Something went wrong." The /r/<shortCode> HTTP path already
|
|
1508
|
+
// derives these via deriveBundleOrigins; this is the per-call
|
|
1509
|
+
// resource mirror.
|
|
1510
|
+
const gadgetOrigins = deriveBundleOrigins(picked.source);
|
|
1511
|
+
return shellContents(uri, html, augmentCspMeta(gadgetOrigins));
|
|
1512
|
+
}
|
|
799
1513
|
// Single shared handler powers both templates. `blueprintKey` is
|
|
800
1514
|
// optional in the variables map — present for the resume URI shape,
|
|
801
1515
|
// absent for the legacy single-segment shape.
|
|
@@ -803,197 +1517,97 @@ export function registerGguiRenderResourceTemplate(server, opts) {
|
|
|
803
1517
|
const sessionIdRaw = variables["sessionId"];
|
|
804
1518
|
const sessionId = Array.isArray(sessionIdRaw) ? sessionIdRaw[0] : sessionIdRaw;
|
|
805
1519
|
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
806
|
-
|
|
1520
|
+
// A URI with no session segment names no locator, which is the
|
|
1521
|
+
// same thing as naming one that does not exist.
|
|
1522
|
+
throw new ResourceReadFailure(NOT_FOUND_FAILURE);
|
|
807
1523
|
}
|
|
808
1524
|
const blueprintKeyRaw = variables["blueprintKey"];
|
|
809
1525
|
const blueprintKey = Array.isArray(blueprintKeyRaw) ? blueprintKeyRaw[0] : blueprintKeyRaw;
|
|
810
1526
|
const hasResumeKey = typeof blueprintKey === "string" && blueprintKey.length > 0;
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
// the
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
|
|
817
|
-
const
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
catch {
|
|
871
|
-
// Silent — bundle write failure degrades to no client-side
|
|
872
|
-
// validators (server-side gate is authoritative).
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
// The codeUrl gate is applied AFTER the live-channel mint below, so
|
|
876
|
-
// a render with no static codeUrl still mounts via live-mode
|
|
877
|
-
// (wsUrl + wsToken) instead of stalling on the loading shell —
|
|
878
|
-
// parity with the `/r/<shortCode>` path. (See the gate after the
|
|
879
|
-
// mint.) This matters for deployments that wire `mintWsToken` but no
|
|
880
|
-
// `codeStore`/`codeBaseUrl` (e.g. the cloud pod): the agent-server
|
|
881
|
-
// inlines THIS resource, so without the fallback every cloud render
|
|
882
|
-
// hung on the dead "Generating UI…" shell.
|
|
883
|
-
// Project the wrapper catalog AND the union-filtered
|
|
884
|
-
// publicEnv onto the inline bootstrap so the resource-served
|
|
885
|
-
// iframe matches the MCP-Apps postMessage path. Without this,
|
|
886
|
-
// wrapper-using contracts rendered through `resources/read`
|
|
887
|
-
// mount as STDLIB-only.
|
|
888
|
-
let resourcePublicEnv;
|
|
889
|
-
if (opts.appMetadataStore) {
|
|
890
|
-
try {
|
|
891
|
-
const appRecord = await opts.appMetadataStore.get(stored.appId);
|
|
892
|
-
resourcePublicEnv = derivePublicEnvProjection(picked.source, appRecord?.publicEnv);
|
|
893
|
-
}
|
|
894
|
-
catch {
|
|
895
|
-
// Silent — wrappers calling getPublicEnv throw clearly.
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
// Live-channel bootstrap — when the operator wired
|
|
899
|
-
// {@link GguiRenderResourceTemplateOptions.mintWsToken}, mint a
|
|
900
|
-
// wsToken for this render so the iframe-runtime opens a
|
|
901
|
-
// WebSocket on mount and receives `props_update` frames.
|
|
902
|
-
// Without this, the resource shell renders in static-component
|
|
903
|
-
// mode only — `ggui_update` server-side mutations never
|
|
904
|
-
// visibly reach the live iframe (hosts must re-fetch
|
|
905
|
-
// `resources/read` after every update tool result to see new
|
|
906
|
-
// state).
|
|
907
|
-
let wsUrl;
|
|
908
|
-
let wsToken;
|
|
909
|
-
let wsExpiresAt;
|
|
910
|
-
if (opts.mintWsToken) {
|
|
911
|
-
try {
|
|
912
|
-
const minted = opts.mintWsToken(sessionId, stored.appId);
|
|
913
|
-
wsUrl = minted.wsUrl;
|
|
914
|
-
wsToken = minted.token;
|
|
915
|
-
// Forward the token TTL so the iframe-runtime can degrade to
|
|
916
|
-
// static-only mode once it lapses (parity with the render-tool
|
|
917
|
-
// slice projection, render.ts). Dropping it left the live-mode
|
|
918
|
-
// resource shell unable to know when its WS token expired.
|
|
919
|
-
wsExpiresAt = minted.expiresAt;
|
|
920
|
-
}
|
|
921
|
-
catch {
|
|
922
|
-
// Silent — falls back to static-component mode.
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
// Mount-mode gate (moved below the live-channel mint): emit the
|
|
926
|
-
// loading shell ONLY when NEITHER a static codeUrl channel NOR a
|
|
927
|
-
// live-channel wsToken is available. A deployment that wires no
|
|
928
|
-
// codeStore (codeUrl === undefined) but DOES wire mintWsToken now
|
|
929
|
-
// mounts via live-mode rather than hanging on "Generating UI…".
|
|
930
|
-
if (!isSystem && codeUrl === undefined && (wsUrl === undefined || wsToken === undefined)) {
|
|
931
|
-
return loadingShell(uri, sessionId);
|
|
932
|
-
}
|
|
933
|
-
const html = buildSelfContainedShell({
|
|
934
|
-
sessionId,
|
|
935
|
-
appId: stored.appId,
|
|
936
|
-
...(isSystem
|
|
937
|
-
? { systemKind: picked.kind }
|
|
938
|
-
: codeUrl !== undefined
|
|
939
|
-
? {
|
|
940
|
-
codeUrl,
|
|
941
|
-
...(codeHash !== undefined ? { codeHash } : {}),
|
|
942
|
-
}
|
|
943
|
-
: // No static codeUrl → live-mode (wsUrl + token spread below)
|
|
944
|
-
// carries the render; buildSelfContainedShell accepts
|
|
945
|
-
// live-mode without codeUrl.
|
|
946
|
-
{}),
|
|
947
|
-
runtimeUrl: opts.runtimeUrl,
|
|
948
|
-
...(wsUrl !== undefined && wsToken !== undefined
|
|
949
|
-
? {
|
|
950
|
-
wsUrl,
|
|
951
|
-
token: wsToken,
|
|
952
|
-
...(wsExpiresAt !== undefined ? { expiresAt: wsExpiresAt } : {}),
|
|
953
|
-
}
|
|
954
|
-
: {}),
|
|
955
|
-
...(opts.themeId !== undefined ? { themeId: opts.themeId } : {}),
|
|
956
|
-
...(opts.themeMode !== undefined ? { themeMode: opts.themeMode } : {}),
|
|
957
|
-
// Per-app theme overlay projected by `deriveRenderMeta` from
|
|
958
|
-
// the render's `theme` sidecar — forwarded so the
|
|
959
|
-
// resource-served iframe matches the postMessage path.
|
|
960
|
-
...(view.theme !== undefined ? { theme: view.theme } : {}),
|
|
961
|
-
...(view.propsJson !== undefined ? { propsJson: view.propsJson } : {}),
|
|
962
|
-
...(view.contextSlots !== undefined ? { contextSlots: view.contextSlots } : {}),
|
|
963
|
-
...(view.permissionsPolicy !== undefined
|
|
964
|
-
? { permissionsPolicy: view.permissionsPolicy }
|
|
965
|
-
: {}),
|
|
966
|
-
...(view.gadgets !== undefined && view.gadgets.length > 0
|
|
967
|
-
? { gadgets: view.gadgets }
|
|
968
|
-
: {}),
|
|
969
|
-
...(contractHash !== undefined && validatorsUrl !== undefined
|
|
970
|
-
? { contractHash, validatorsUrl }
|
|
971
|
-
: {}),
|
|
972
|
-
...(resourcePublicEnv !== undefined && Object.keys(resourcePublicEnv).length > 0
|
|
973
|
-
? { publicEnv: resourcePublicEnv }
|
|
974
|
-
: {}),
|
|
975
|
-
// R6 — ledger cursor stamp for polling-cursor alignment.
|
|
976
|
-
lastSequence: stored.eventSequence,
|
|
977
|
-
});
|
|
978
|
-
// Augment per-call CSP with gadget-declared bundle / style /
|
|
979
|
-
// API origins. Without this, claude.ai's iframe CSP only allows
|
|
980
|
-
// the publicBaseUrl origin, so Leaflet wrapper bundles fetched
|
|
981
|
-
// from registry.ggui.ai, leaflet.css fetched from same, and
|
|
982
|
-
// OSM tile requests to tile.openstreetmap.org all get blocked
|
|
983
|
-
// → the component throws and the React error boundary renders
|
|
984
|
-
// "Something went wrong." The /r/<shortCode> HTTP path already
|
|
985
|
-
// derives these via deriveBundleOrigins; this is the per-call
|
|
986
|
-
// resource mirror.
|
|
987
|
-
const gadgetOrigins = deriveBundleOrigins(picked.source);
|
|
988
|
-
return shellContents(uri, html, augmentCspMeta(gadgetOrigins));
|
|
1527
|
+
// The failure this read ends in if nothing mounts. Seeded from a
|
|
1528
|
+
// property of the SERVER, never of the locator, so a caller cannot
|
|
1529
|
+
// read the answer as a statement about which locators exist; the
|
|
1530
|
+
// branches below refine it only where the access check has already
|
|
1531
|
+
// passed.
|
|
1532
|
+
let failure = durableSubstrate() === null ? NOT_SUPPORTED_FAILURE : NOT_FOUND_FAILURE;
|
|
1533
|
+
const stored = await opts.renderStore.get(sessionId);
|
|
1534
|
+
// Rehydration access control (spec §3): gate BEFORE any shell
|
|
1535
|
+
// bytes, code hashing, or token mint. Refusal does NOT
|
|
1536
|
+
// early-return — it nulls out row access into `accessibleStored`
|
|
1537
|
+
// so every downstream branch (mount, re-mint, registry fallback,
|
|
1538
|
+
// terminal failure) runs exactly as if the row were absent. A
|
|
1539
|
+
// refusal on the resume URI must fall through to the SAME
|
|
1540
|
+
// registry-only fallback a genuine miss would hit (the fallback is
|
|
1541
|
+
// keyed off the caller-supplied blueprintKey + registry defaults,
|
|
1542
|
+
// never off the refused row) — otherwise refusal would
|
|
1543
|
+
// short-circuit to its own error while a miss of the same
|
|
1544
|
+
// blueprintKey resolves the registry shell, leaking row existence
|
|
1545
|
+
// to a same-probe attacker.
|
|
1546
|
+
const accessibleStored = stored !== null &&
|
|
1547
|
+
stored !== undefined &&
|
|
1548
|
+
readAllowed(sessionId, {
|
|
1549
|
+
appId: stored.appId,
|
|
1550
|
+
// #446 — the row's SUBJECT is `userId`, written at commit.
|
|
1551
|
+
// This used to project `endUserIdentity`, which nothing has
|
|
1552
|
+
// written since the repo split, so the subject rung never
|
|
1553
|
+
// bound.
|
|
1554
|
+
...(stored.userId !== undefined ? { userId: stored.userId } : {}),
|
|
1555
|
+
})
|
|
1556
|
+
? stored
|
|
1557
|
+
: null;
|
|
1558
|
+
// Live state first: render present and renderable mounts with the
|
|
1559
|
+
// current props + current contextSpec values.
|
|
1560
|
+
if (accessibleStored) {
|
|
1561
|
+
const served = await serveMount(uri, sessionId, accessibleStored);
|
|
1562
|
+
if (served !== null)
|
|
1563
|
+
return served;
|
|
1564
|
+
// The row is here and the caller may read it; it simply has no
|
|
1565
|
+
// component yet, because the generation that will fill it has not
|
|
1566
|
+
// committed. Safe to say so — this branch is past the check, so
|
|
1567
|
+
// it can only ever describe a locator the caller is entitled to.
|
|
1568
|
+
failure = NOT_YET_COMMITTED_FAILURE;
|
|
1569
|
+
}
|
|
1570
|
+
// Re-mint: the row is GONE and the deployment keeps a durable
|
|
1571
|
+
// record of what it was. Gated on the row being genuinely absent
|
|
1572
|
+
// rather than merely unreadable — a re-mint COMMITS, and a commit
|
|
1573
|
+
// fired while a row exists would overwrite live state (or another
|
|
1574
|
+
// party's) with a reconstruction. A refused read still reaches the
|
|
1575
|
+
// same fallback and the same terminal failure a miss reaches, so
|
|
1576
|
+
// nothing here tells the two apart.
|
|
1577
|
+
if (stored === null || stored === undefined) {
|
|
1578
|
+
const reminted = await remintFromRecord(sessionId);
|
|
1579
|
+
if (reminted.ok) {
|
|
1580
|
+
const served = await serveMount(uri, sessionId, reminted.row);
|
|
1581
|
+
if (served !== null)
|
|
1582
|
+
return served;
|
|
1583
|
+
}
|
|
1584
|
+
else {
|
|
1585
|
+
failure = reminted.failure;
|
|
989
1586
|
}
|
|
990
1587
|
}
|
|
991
1588
|
// Registry-only fallback: render is gone (TTL / restart) but the
|
|
992
1589
|
// blueprint is still in the registry. Synthesize the shell from
|
|
993
1590
|
// the blueprint's componentCode + propsSpec defaults — strictly
|
|
994
1591
|
// worse than the live mount (no current props, no preserved
|
|
995
|
-
// context state), but
|
|
996
|
-
//
|
|
1592
|
+
// context state), but a real mount rather than a failure.
|
|
1593
|
+
//
|
|
1594
|
+
// It runs on EVERY path that has not mounted, including a refused
|
|
1595
|
+
// one, and that is load-bearing: it is keyed by the caller-supplied
|
|
1596
|
+
// blueprintKey under the registry default and never by the row, so
|
|
1597
|
+
// a refusal and a miss of the same key resolve the same shell. A
|
|
1598
|
+
// refusal that skipped it would be distinguishable from a miss.
|
|
1599
|
+
//
|
|
1600
|
+
// The lookup runs HERE, not before the gate. It is keyed by a
|
|
1601
|
+
// caller-supplied blueprintKey under a registry default, so firing
|
|
1602
|
+
// it ahead of the access check spent a lookup on every read and
|
|
1603
|
+
// put blueprint-existence work in front of the one check that
|
|
1604
|
+
// decides whether the caller may learn anything at all.
|
|
1605
|
+
const blueprint = hasResumeKey && opts.vectorStore && opts.index && opts.defaultAppIdFallback
|
|
1606
|
+
? await findBlueprintExact({ vectorStore: opts.vectorStore, index: opts.index }, opts.defaultAppIdFallback, "template",
|
|
1607
|
+
// Resume URI carries only a contract hash — omit variantKey
|
|
1608
|
+
// so the lookup resolves the default variant.
|
|
1609
|
+
blueprintKey)
|
|
1610
|
+
: null;
|
|
997
1611
|
if (blueprint && opts.defaultAppIdFallback) {
|
|
998
1612
|
const html = await buildShellFromBlueprint({
|
|
999
1613
|
sessionId,
|
|
@@ -1008,18 +1622,24 @@ export function registerGguiRenderResourceTemplate(server, opts) {
|
|
|
1008
1622
|
if (html !== undefined) {
|
|
1009
1623
|
return shellContents(uri, html);
|
|
1010
1624
|
}
|
|
1011
|
-
//
|
|
1625
|
+
// A blueprint matched, but `buildShellFromBlueprint` needs the
|
|
1626
|
+
// static-delivery pair to turn one into a shell. Its own failure,
|
|
1627
|
+
// not the one held above: what the read found is a component it
|
|
1628
|
+
// cannot deliver, and that answer depends only on the
|
|
1629
|
+
// caller-supplied key and this server's wiring — identical for a
|
|
1630
|
+
// refused read and a miss of the same key.
|
|
1631
|
+
throw new ResourceReadFailure(BLUEPRINT_UNDELIVERABLE_FAILURE);
|
|
1012
1632
|
}
|
|
1013
|
-
|
|
1633
|
+
throw new ResourceReadFailure(failure);
|
|
1014
1634
|
}
|
|
1015
1635
|
server.registerResource("ggui-render-self-contained", legacyTemplate, {
|
|
1016
1636
|
title: "ggui render (self-contained, legacy URI)",
|
|
1017
|
-
description: "Per-render self-contained shell — single-segment URI shape predating the resume contract.
|
|
1637
|
+
description: "Per-render self-contained shell — single-segment URI shape predating the resume contract. A read returns a mountable shell or exactly one typed JSON-RPC error, never a shell that cannot paint. Carrying no blueprintKey, this shape cannot reach the blueprint-registry fallback; a server that keeps durable identity records still re-mints it, since that path is keyed by sessionId alone. Failures: NOT_FOUND (-32002) when nothing resolves the locator, and identically when the caller may not read one that does; NOT_SUPPORTED (-32006) in place of NOT_FOUND on a server that keeps no durable record, for both of those cases alike; BLUEPRINT_UNRESOLVABLE (-32006) when a record names a component that is gone; NOT_MOUNTABLE (-32006) when the caller's own render resolved but nothing mountable can be produced from it, on any server.",
|
|
1018
1638
|
mimeType: GGUI_RENDER_RESOURCE_MIME,
|
|
1019
1639
|
}, handle);
|
|
1020
1640
|
server.registerResource("ggui-render-self-contained-resume", resumeTemplate, {
|
|
1021
1641
|
title: "ggui render (self-contained, resume URI)",
|
|
1022
|
-
description: "Per-render self-contained shell — two-segment URI shape carrying both sessionId AND blueprintKey.
|
|
1642
|
+
description: "Per-render self-contained shell — two-segment URI shape carrying both sessionId AND blueprintKey. A read returns a mountable shell or exactly one typed JSON-RPC error, never a shell that cannot paint. When the render has been evicted the handler tries, in order and only after the access check: a re-mint from the durable identity record, then a registry-only static render from the blueprintKey. Failures: NOT_FOUND (-32002) when neither resolves the locator, and identically when the caller may not read one that does; NOT_SUPPORTED (-32006) in place of NOT_FOUND on a server that keeps no durable record, for both of those cases alike; BLUEPRINT_UNRESOLVABLE (-32006) when a record names a component that is gone; NOT_MOUNTABLE (-32006) when the caller's own render, or a blueprint matching the supplied key, resolved but nothing mountable can be produced from it, on any server.",
|
|
1023
1643
|
mimeType: GGUI_RENDER_RESOURCE_MIME,
|
|
1024
1644
|
}, handle);
|
|
1025
1645
|
}
|