@ggui-ai/mcp-server 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/api-renders-routes.d.ts.map +1 -1
  2. package/dist/api-renders-routes.js +9 -1
  3. package/dist/browser-cors.d.ts +29 -0
  4. package/dist/browser-cors.d.ts.map +1 -0
  5. package/dist/browser-cors.js +64 -0
  6. package/dist/build-mcp.d.ts.map +1 -1
  7. package/dist/build-mcp.js +5 -1
  8. package/dist/code-store-fs.d.ts +3 -0
  9. package/dist/code-store-fs.d.ts.map +1 -1
  10. package/dist/code-store-fs.js +27 -3
  11. package/dist/ggui-session-channel/outbound.d.ts.map +1 -1
  12. package/dist/ggui-session-channel/outbound.js +12 -0
  13. package/dist/ggui-session-channel/socket-router.d.ts +33 -0
  14. package/dist/ggui-session-channel/socket-router.d.ts.map +1 -1
  15. package/dist/ggui-session-channel/socket-router.js +155 -0
  16. package/dist/ggui-session-channel/subscribe.d.ts.map +1 -1
  17. package/dist/ggui-session-channel/subscribe.js +43 -2
  18. package/dist/ggui-session-channel.d.ts +114 -0
  19. package/dist/ggui-session-channel.d.ts.map +1 -1
  20. package/dist/ggui-session-channel.js +77 -2
  21. package/dist/health-routes.d.ts +3 -0
  22. package/dist/health-routes.d.ts.map +1 -1
  23. package/dist/health-routes.js +11 -1
  24. package/dist/mcp-apps-outbound.d.ts +191 -30
  25. package/dist/mcp-apps-outbound.d.ts.map +1 -1
  26. package/dist/mcp-apps-outbound.js +843 -223
  27. package/dist/origin-validation.d.ts +120 -0
  28. package/dist/origin-validation.d.ts.map +1 -0
  29. package/dist/origin-validation.js +199 -0
  30. package/dist/render-read-gate.d.ts +50 -0
  31. package/dist/render-read-gate.d.ts.map +1 -0
  32. package/dist/render-read-gate.js +36 -0
  33. package/dist/server.d.ts +164 -4
  34. package/dist/server.d.ts.map +1 -1
  35. package/dist/server.js +206 -20
  36. package/package.json +12 -12
@@ -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 + loading 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
- * Minimal "loading" HTML served when a per-render resource is fetched
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
- * Hosts SHOULD re-fetch when they observe additional `ggui_render`
656
- * results on the same render — the per-call `_meta.ui.resourceUri`
657
- * value stays stable across commits for a render, so re-fetching the
658
- * same URI returns fresher HTML on the second try.
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
- * @public
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
- export function buildSelfContainedLoadingShell(sessionId) {
663
- // Standalone served-iframe loading document — same surface-backdrop
664
- // paint + gating as the thin/self-contained shells so the canvas is
665
- // dark while the render is still in flight (no Safari white flash).
666
- return `<!doctype html>
667
- <html lang="en" style="background-color:${GGUI_RENDER_SHELL_SURFACE}"><head><meta charset="utf-8"><title>ggui render</title></head>
668
- <body style="background-color:${GGUI_RENDER_SHELL_SURFACE}">
669
- <div id="ggui-root" data-ggui-shell="loading" data-ggui-session-id="${sessionId
670
- .replace(/&/g, "&amp;")
671
- .replace(/"/g, "&quot;")
672
- .replace(/</g, "&lt;")
673
- .replace(/>/g, "&gt;")}">Generating UI…</div>
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
- * Failure modes:
718
- * - GguiSession not found → loading shell (host re-fetches; absent
719
- * render is a transient state immediately after `ggui_render`).
720
- * - GguiSession found, no componentCode yet → loading shell.
721
- * - GguiSession found, componentCode present → self-contained shell.
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 (loading shell on render miss).
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 do:
738
- // (a) parallel render + blueprint registry lookup (no data
739
- // dependency between them), (b) registry-only fallback when
740
- // the render is gone but the blueprint is still cached
741
- // (renders the original card with default props/context
742
- // instead of the dead loading shell).
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
- const loadingShell = (uri, sessionId) => shellContents(uri, buildSelfContainedLoadingShell(sessionId));
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
- return loadingShell(uri, "unknown");
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
- // Parallel lookup. The render and the blueprint registry are
812
- // independent — even though the render's componentCode could feed
813
- // the renderable directly, we ALSO want the blueprint entry as a
814
- // registry-only fallback when the render is gone but the blueprint
815
- // is still cached (chat-history rehydrate after render TTL or
816
- // process restart).
817
- const [stored, blueprint] = await Promise.all([
818
- opts.renderStore.get(sessionId),
819
- hasResumeKey && opts.vectorStore && opts.index && opts.defaultAppIdFallback
820
- ? findBlueprintExact({ vectorStore: opts.vectorStore, index: opts.index }, opts.defaultAppIdFallback, "template",
821
- // Resume URI carries only a contract hash — omit variantKey
822
- // so the lookup resolves the default variant.
823
- blueprintKey)
824
- : Promise.resolve(null),
825
- ]);
826
- // Happy path: render present and renderable. Mount with the live
827
- // state (current props, current contextSpec values).
828
- if (stored) {
829
- const picked = pickComponentFromGguiSession(stored.render);
830
- if (picked) {
831
- // Project the active render to the transport-agnostic bootstrap
832
- // view — same source of truth the render-mutation handler and
833
- // `/r/<shortCode>` consume. Carries permissionsPolicy when
834
- // clientCapabilities declares permissions. The MCP Apps
835
- // resource path emits this only into the inline bootstrap
836
- // (the browser-enforced gate ultimately comes from the host's
837
- // `allow=""` attribute when the host translates
838
- // `_meta.ui.permissions` — set by McpAppIframe consumers).
839
- const view = deriveRenderMeta(picked.source);
840
- const isSystem = picked.kind !== undefined;
841
- // Static-component delivery via codeUrl. The compiled-component
842
- // path mints a content-addressable URL the iframe-runtime
843
- // fetches at boot; the loading shell takes over when codeStore +
844
- // codeBaseUrl aren't wired.
845
- let codeUrl;
846
- let codeHash;
847
- let contractHash;
848
- let validatorsUrl;
849
- if (!isSystem && opts.codeStore && opts.codeBaseUrl) {
850
- try {
851
- const hash = opts.codeStore.hashOf(picked.componentCode);
852
- await opts.codeStore.put(hash, picked.componentCode);
853
- codeHash = hash;
854
- const base = opts.codeBaseUrl.replace(/\/$/, "");
855
- codeUrl = `${base}/code/${hash}.js`;
856
- }
857
- catch {
858
- // Silent — falls through to loading shell below.
859
- }
860
- // Content-addressable contract-validator bundle (#109).
861
- try {
862
- const bundle = await deriveContractBundle(picked.source);
863
- if (bundle) {
864
- await opts.codeStore.put(bundle.contractHash, bundle.bundleSource);
865
- contractHash = bundle.contractHash;
866
- const base = opts.codeBaseUrl.replace(/\/$/, "");
867
- validatorsUrl = `${base}/contract/${bundle.contractHash}.js`;
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 strictly better than the dead loading
996
- // shell.
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
- // Fallthrough to loading shell when codeStore isn't wired.
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
- return loadingShell(uri, sessionId);
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. Falls back to loading shell when the render is gone (no blueprintKey to do registry-only render).",
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. Resource handler runs Promise.all over render + registry; falls back to registry-only static render when the render has been evicted but the blueprint is still cached.",
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
  }