@ggui-ai/iframe-runtime 0.25.0 → 0.27.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 (40) hide show
  1. package/README.md +2 -2
  2. package/dist/channels/data.d.ts +8 -0
  3. package/dist/channels/data.js +2 -0
  4. package/dist/credential-controller.d.ts +71 -0
  5. package/dist/credential-controller.js +135 -0
  6. package/dist/iframe-runtime.js +97 -94
  7. package/dist/iframe-runtime.meta.json +1 -1
  8. package/dist/index.d.ts +2 -2
  9. package/dist/ladders.d.ts +77 -0
  10. package/dist/ladders.js +383 -0
  11. package/dist/meta-parse.js +38 -8
  12. package/dist/observability.d.ts +22 -1
  13. package/dist/react-renderer.d.ts +20 -0
  14. package/dist/react-renderer.js +22 -0
  15. package/dist/registry-subscribe.d.ts +37 -1
  16. package/dist/registry-subscribe.js +34 -8
  17. package/dist/render-failure-report.d.ts +15 -0
  18. package/dist/render-failure-report.js +61 -0
  19. package/dist/render-item.d.ts +3 -0
  20. package/dist/render-item.js +2 -0
  21. package/dist/runtime-telemetry.d.ts +28 -18
  22. package/dist/runtime-telemetry.js +68 -29
  23. package/dist/runtime.d.ts +114 -35
  24. package/dist/runtime.js +556 -309
  25. package/dist/stream-seq.d.ts +56 -0
  26. package/dist/stream-seq.js +46 -0
  27. package/dist/system-cards/ProtocolProbeCard.d.ts +16 -0
  28. package/dist/system-cards/ProtocolProbeCard.js +17 -14
  29. package/dist/tool-result-error.d.ts +6 -0
  30. package/dist/tool-result-error.js +32 -0
  31. package/dist/types.d.ts +49 -1
  32. package/dist/view-origin.d.ts +29 -0
  33. package/dist/view-origin.js +33 -0
  34. package/dist/view-proof-signer.d.ts +31 -0
  35. package/dist/view-proof-signer.js +99 -0
  36. package/dist/view-root.d.ts +51 -0
  37. package/dist/view-root.js +56 -0
  38. package/dist/wire-config.d.ts +13 -0
  39. package/dist/wire-config.js +39 -1
  40. package/package.json +8 -7
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The view's stream cursor (ggui#1496, rev 2.4).
3
+ *
4
+ * `StreamEnvelope.seq` is session-scoped and gap-free, and stream delivery
5
+ * is at-least-once, so the view applies a stamped envelope only when its
6
+ * `seq` is above the highest one it has applied. That highest `seq` is
7
+ * also where a new subscribe resumes the stream from
8
+ * (`SubscribePayload.fromSeq`). An unstamped envelope (a deployment with no
9
+ * stream buffer) is single-shot and always passes, and it moves nothing.
10
+ *
11
+ * A server's counter can restart (a process restart, an in-memory buffer, a
12
+ * stored counter that expired), after which its `seq` starts over below the
13
+ * view's cursor and every new envelope would be dropped. The ack reveals
14
+ * it: its `streamSeq` is the server's current counter, and a monotonic
15
+ * counter can never be below a `seq` this view applied. So an ack whose
16
+ * `streamSeq` is lower means the counter restarted, and the cursor is
17
+ * forgotten.
18
+ *
19
+ * That numeric test misses a counter that restarted and has already
20
+ * climbed past the cursor before the next ack. The stream EPOCH closes it
21
+ * (ggui#1531): the server names each counter generation, on every stamped
22
+ * envelope and on the ack, and a different epoch means a new generation
23
+ * whatever the numbers say, so the cursor resets to nothing applied. A
24
+ * missing epoch (a server, or an envelope, from before epochs) is
25
+ * unknown, never a mismatch: the `seq` rule alone decides, and a first
26
+ * epoch after unknown history is adopted without a reset.
27
+ *
28
+ * The cursor never moves back to an epoch it has left: it remembers the
29
+ * last {@link LEFT_EPOCHS_KEPT}, and drops their frames and ignores their
30
+ * acks. Otherwise a late frame of the old generation (a cross-replica frame
31
+ * published just before the restart) would move it back with nothing
32
+ * applied, and the next frame of the new one would move it forward again
33
+ * with an empty floor, letting a duplicate through. The server keeps the
34
+ * same rule (SPEC §12.2.1 invariant 4).
35
+ */
36
+ export interface StreamSeqTracker {
37
+ /**
38
+ * Whether to apply an envelope with this `seq` and `streamEpoch`; records
39
+ * it when so. An envelope from a different epoch first resets the cursor.
40
+ */
41
+ admit(seq: number | undefined, epoch?: string): boolean;
42
+ /** The highest `seq` applied so far; `undefined` before the first stamped envelope. */
43
+ last(): number | undefined;
44
+ /** The counter generation the cursor belongs to; `undefined` until one is seen. */
45
+ epoch(): string | undefined;
46
+ /**
47
+ * An ack's stream position (`AckPayload.streamSeq`, `AckPayload.streamEpoch`).
48
+ * A different epoch, or a `streamSeq` below the highest applied `seq`,
49
+ * means the counter restarted: the cursor is forgotten, and `true` says
50
+ * so.
51
+ */
52
+ observeAck(streamSeq: number | undefined, streamEpoch: string | undefined): boolean;
53
+ }
54
+ /** How many epochs the cursor remembers having left; the server keeps as many. */
55
+ export declare const LEFT_EPOCHS_KEPT = 4;
56
+ export declare function createStreamSeqTracker(): StreamSeqTracker;
@@ -0,0 +1,46 @@
1
+ /** How many epochs the cursor remembers having left; the server keeps as many. */
2
+ export const LEFT_EPOCHS_KEPT = 4;
3
+ export function createStreamSeqTracker() {
4
+ let highest;
5
+ let current;
6
+ let left = [];
7
+ const hasLeft = (epoch) => epoch !== undefined && left.includes(epoch);
8
+ /** Adopt `epoch`; a different known epoch resets the cursor. Returns whether it reset. */
9
+ const meetEpoch = (epoch) => {
10
+ if (epoch === undefined)
11
+ return false;
12
+ const from = current;
13
+ const changed = from !== undefined && epoch !== from;
14
+ current = epoch;
15
+ if (changed) {
16
+ left = [...left, from].slice(-LEFT_EPOCHS_KEPT);
17
+ highest = undefined;
18
+ }
19
+ return changed;
20
+ };
21
+ return {
22
+ admit(seq, epoch) {
23
+ if (seq === undefined)
24
+ return true;
25
+ if (hasLeft(epoch))
26
+ return false;
27
+ meetEpoch(epoch);
28
+ if (highest !== undefined && seq <= highest)
29
+ return false;
30
+ highest = seq;
31
+ return true;
32
+ },
33
+ last: () => highest,
34
+ epoch: () => current,
35
+ observeAck(streamSeq, streamEpoch) {
36
+ if (hasLeft(streamEpoch))
37
+ return false;
38
+ if (meetEpoch(streamEpoch))
39
+ return true;
40
+ if (streamSeq === undefined || highest === undefined || streamSeq >= highest)
41
+ return false;
42
+ highest = undefined;
43
+ return true;
44
+ },
45
+ };
46
+ }
@@ -49,6 +49,21 @@
49
49
  * the submit-action bridge + native idioms in response.
50
50
  */
51
51
  import * as React from 'react';
52
+ /**
53
+ * One canonical View→Host method the spec defines (apps.mdx:963-1102).
54
+ * Keep this list aligned with the §"Requests (View → Host)" section.
55
+ * Adding a new probe button means adding one entry here + one
56
+ * matching renderer in the JSX below.
57
+ */
58
+ type ProbeMethod = 'ui/message' | 'ui/update-model-context' | 'ui/open-link' | 'ui/request-display-mode' | 'tools/call';
59
+ /**
60
+ * Build a default param payload for each canonical method. The shapes
61
+ * mirror the spec examples (apps.mdx:963-1102) exactly so any host
62
+ * with a strict validator accepts them.
63
+ *
64
+ * @internal — exported for unit tests.
65
+ */
66
+ export declare function defaultParams(method: ProbeMethod): unknown;
52
67
  export interface ProtocolProbeCardProps {
53
68
  /**
54
69
  * Optional: the original prompt that triggered the probe render.
@@ -58,3 +73,4 @@ export interface ProtocolProbeCardProps {
58
73
  readonly intent?: string;
59
74
  }
60
75
  export declare function ProtocolProbeCard({ intent, }: ProtocolProbeCardProps): React.JSX.Element;
76
+ export {};
@@ -51,6 +51,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
51
51
  */
52
52
  import * as React from 'react';
53
53
  import { Card, Stack, Heading, Text, Button, Badge, } from '@ggui-ai/design/primitives';
54
+ import { withViewProof } from '../view-origin.js';
54
55
  const PROBE_TIMEOUT_MS = 5_000;
55
56
  /**
56
57
  * Lifetime-scoped JSON-RPC caller. One instance per card mount —
@@ -158,8 +159,10 @@ function useNotificationLog() {
158
159
  * Build a default param payload for each canonical method. The shapes
159
160
  * mirror the spec examples (apps.mdx:963-1102) exactly so any host
160
161
  * with a strict validator accepts them.
162
+ *
163
+ * @internal — exported for unit tests.
161
164
  */
162
- function defaultParams(method) {
165
+ export function defaultParams(method) {
163
166
  switch (method) {
164
167
  case 'ui/message':
165
168
  // Empirical 2026-05-03: claude.ai's validator wants `content`
@@ -199,20 +202,20 @@ function defaultParams(method) {
199
202
  // The probe uses `kind: 'dispatch'` so the chained 3-message
200
203
  // follow-up (`ui/update-model-context` + `ui/message`)
201
204
  // semantically matches a real submit-action click.
202
- return {
203
- name: 'ggui_runtime_submit_action',
204
- arguments: {
205
- kind: 'dispatch',
206
- payload: {
207
- intent: 'probe-click',
208
- data: { note: 'hello from the protocol probe' },
209
- },
210
- sessionId: 'probe-render',
211
- appId: 'probe-app',
212
- actionId: '00000000', // overwritten in onClick with real FNV-1a
213
- firedAt: new Date().toISOString(),
205
+ // Through the one wrapper, like every call of the three runtime tools
206
+ // (ggui#1415). The probe's fixed session is keyed by no door, so it
207
+ // carries a proof only if a root for it were ever held.
208
+ return withViewProof('ggui_runtime_submit_action', {
209
+ kind: 'dispatch',
210
+ payload: {
211
+ intent: 'probe-click',
212
+ data: { note: 'hello from the protocol probe' },
214
213
  },
215
- };
214
+ sessionId: 'probe-render',
215
+ appId: 'probe-app',
216
+ actionId: '00000000',
217
+ firedAt: new Date().toISOString(),
218
+ });
216
219
  }
217
220
  }
218
221
  /**
@@ -0,0 +1,6 @@
1
+ /** Whether a tool result is an error result. */
2
+ export declare function isErrorToolResult(result: unknown): boolean;
3
+ /** The text of a tool result's first text block, if it has one. */
4
+ export declare function toolResultText(result: unknown): string | undefined;
5
+ /** The domain-error code an error result carries; `undefined` for any other result. */
6
+ export declare function domainErrorCodeOf(result: unknown): string | undefined;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Reading a tool result the server answered with an error (ggui#1496).
3
+ *
4
+ * A server tool that throws reaches the view, through the host's
5
+ * `tools/call` relay, as a result with `isError: true` whose first text
6
+ * block is the error's text. A domain error's text is `<code>: <detail>`,
7
+ * read by the protocol's own parser.
8
+ */
9
+ import { parseDomainErrorText } from '@ggui-ai/protocol/wire';
10
+ /** Whether a tool result is an error result. */
11
+ export function isErrorToolResult(result) {
12
+ return typeof result === 'object' && result !== null && Reflect.get(result, 'isError') === true;
13
+ }
14
+ /** The text of a tool result's first text block, if it has one. */
15
+ export function toolResultText(result) {
16
+ if (typeof result !== 'object' || result === null)
17
+ return undefined;
18
+ const content = Reflect.get(result, 'content');
19
+ if (!Array.isArray(content) || content.length === 0)
20
+ return undefined;
21
+ const first = content[0];
22
+ if (typeof first !== 'object' || first === null)
23
+ return undefined;
24
+ const text = Reflect.get(first, 'text');
25
+ return typeof text === 'string' ? text : undefined;
26
+ }
27
+ /** The domain-error code an error result carries; `undefined` for any other result. */
28
+ export function domainErrorCodeOf(result) {
29
+ if (!isErrorToolResult(result))
30
+ return undefined;
31
+ return parseDomainErrorText(toolResultText(result) ?? '')?.code;
32
+ }
package/dist/types.d.ts CHANGED
@@ -97,8 +97,56 @@ export type McpAppAiGguiMetaParseResult = {
97
97
  readonly ok: true;
98
98
  readonly meta: McpAppAiGguiRenderMeta;
99
99
  readonly hostContext?: HostContextProjection;
100
+ /**
101
+ * The live credential the parse dropped because it had expired
102
+ * (ggui#1496). The meta carries no live trio then; the view may
103
+ * refresh this through its host.
104
+ */
105
+ readonly held?: HeldCredential;
106
+ /** The slice's view key root, when it carries one (ggui#1415). */
107
+ readonly viewRoot?: ViewRoot;
100
108
  } | {
101
109
  readonly ok: false;
102
- readonly reason: McpAppAiGguiMetaParseFailureReason;
110
+ readonly reason: Exclude<McpAppAiGguiMetaParseFailureReason, 'EXPIRED_BOOTSTRAP'>;
111
+ } | {
112
+ readonly ok: false;
113
+ /**
114
+ * A live-only slice whose credential expired. It still carries the
115
+ * projected meta (with no live trio) and the held credential, so a
116
+ * view with a host relay can refresh before giving up (ggui#1496).
117
+ */
118
+ readonly reason: 'EXPIRED_BOOTSTRAP';
119
+ readonly meta: McpAppAiGguiRenderMeta;
120
+ readonly held: HeldCredential;
121
+ /** The slice's view key root, captured before its credential was dropped (ggui#1415). */
122
+ readonly viewRoot?: ViewRoot;
103
123
  };
124
+ /**
125
+ * A view's live credential, with the URLs that carry it (ggui#1496).
126
+ * Runtime-local, never a protocol member: the parse returns an expired one
127
+ * as `held`, and the credential controller refreshes it.
128
+ */
129
+ export interface HeldCredential {
130
+ readonly wsToken: string;
131
+ readonly wsUrl: string;
132
+ /** ISO-8601, as the slice or the refresh stated it. */
133
+ readonly expiresAt?: string;
134
+ readonly sseUrl?: string;
135
+ readonly pollingUrl?: string;
136
+ /** `root`: minted with the render. `refreshed`: came back from a refresh. */
137
+ readonly origin: 'root' | 'refreshed';
138
+ }
139
+ /**
140
+ * The key root a view proves its app-only runtime calls with (ggui#1415):
141
+ * `P`, the payload segment of the slice's `wsToken`, and `K`, the slice's
142
+ * `viewKey`. Runtime-local, never a protocol member: the parse captures it
143
+ * from the slice before an expired credential is dropped, and the runtime
144
+ * adopts it only once `P` decodes to the slice's own session.
145
+ */
146
+ export interface ViewRoot {
147
+ /** `P`: the `wsToken`'s payload segment, base64url. */
148
+ readonly root: string;
149
+ /** `K`: the slice's `viewKey`, base64url. */
150
+ readonly key: string;
151
+ }
104
152
  export {};
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The document's view origin (ggui#1415): its one key-root holder, the
3
+ * prover over it, and the user-activation reading a dispatch signs with.
4
+ *
5
+ * One per document, like the mount it proves for. The boot and every later
6
+ * tool result offer their slice's root to {@link documentViewRoots}; every
7
+ * `tools/call` of the three app-only runtime tools goes through
8
+ * {@link withViewProof}. Everything here is held in this module's closure
9
+ * and captured when the runtime bundle evaluates, before card code runs.
10
+ */
11
+ import type { JsonObject } from '@ggui-ai/protocol/wire';
12
+ import { type ViewRootHolder } from './view-root.js';
13
+ import { type ViewProofOptions, type ViewToolCall } from './view-proof-signer.js';
14
+ /** The document's view key root. */
15
+ export declare const documentViewRoots: ViewRootHolder;
16
+ /**
17
+ * The one wrapper for a view's `tools/call`: the normalized arguments, and a
18
+ * proof at `_meta["ai.ggui/view"]` for the three app-only runtime tools when
19
+ * the document holds a root for the call's session. Any other tool passes
20
+ * through untouched. Never throws.
21
+ */
22
+ export declare function withViewProof<A extends object>(name: string, args: A, opts?: ViewProofOptions): ViewToolCall<A | JsonObject>;
23
+ /**
24
+ * Whether the current task carries transient user activation, read through
25
+ * the accessor captured at load. `false` where the browser does not expose
26
+ * it. A dispatch reads it at entry and signs it as flag bit 0: measured,
27
+ * never enforced.
28
+ */
29
+ export declare function userActivationIsActive(): boolean;
@@ -0,0 +1,33 @@
1
+ import { createViewRootHolder } from './view-root.js';
2
+ import { createViewProver } from './view-proof-signer.js';
3
+ /** The document's view key root. */
4
+ export const documentViewRoots = createViewRootHolder();
5
+ const documentProver = createViewProver({ roots: documentViewRoots });
6
+ /**
7
+ * The one wrapper for a view's `tools/call`: the normalized arguments, and a
8
+ * proof at `_meta["ai.ggui/view"]` for the three app-only runtime tools when
9
+ * the document holds a root for the call's session. Any other tool passes
10
+ * through untouched. Never throws.
11
+ */
12
+ export function withViewProof(name, args, opts) {
13
+ return documentProver.withViewProof(name, args, opts);
14
+ }
15
+ // `navigator.userActivation` and its `isActive` getter, captured before card
16
+ // code can replace either.
17
+ const capturedActivation = typeof navigator !== 'undefined' && navigator.userActivation !== undefined ? navigator.userActivation : undefined;
18
+ const capturedIsActive = capturedActivation !== undefined
19
+ ? Object.getOwnPropertyDescriptor(Object.getPrototypeOf(capturedActivation), 'isActive')?.get
20
+ : undefined;
21
+ /**
22
+ * Whether the current task carries transient user activation, read through
23
+ * the accessor captured at load. `false` where the browser does not expose
24
+ * it. A dispatch reads it at entry and signs it as flag bit 0: measured,
25
+ * never enforced.
26
+ */
27
+ export function userActivationIsActive() {
28
+ if (capturedActivation === undefined)
29
+ return false;
30
+ if (capturedIsActive !== undefined)
31
+ return capturedIsActive.call(capturedActivation) === true;
32
+ return capturedActivation.isActive;
33
+ }
@@ -0,0 +1,31 @@
1
+ import { MCP_APP_AI_GGUI_VIEW_META_KEY } from '@ggui-ai/protocol/integrations/mcp-apps';
2
+ import type { JsonObject } from '@ggui-ai/protocol/wire';
3
+ import type { ViewRootHolder } from './view-root.js';
4
+ /** A `tools/call` request's params, as a view sends them. */
5
+ export interface ViewToolCall<A> {
6
+ readonly name: string;
7
+ readonly arguments: A;
8
+ readonly _meta?: {
9
+ readonly [MCP_APP_AI_GGUI_VIEW_META_KEY]: string;
10
+ };
11
+ }
12
+ export interface ViewProofOptions {
13
+ /**
14
+ * Whether the gesture carried transient user activation, read at dispatch
15
+ * entry (`navigator.userActivation.isActive`). Sets flag bit 0. Measured,
16
+ * never enforced: any holder of the key can set it.
17
+ */
18
+ readonly userActivation?: boolean;
19
+ }
20
+ export interface ViewProver {
21
+ withViewProof<A extends object>(name: string, args: A, opts?: ViewProofOptions): ViewToolCall<A | JsonObject>;
22
+ }
23
+ export interface ViewProverDeps {
24
+ readonly roots: ViewRootHolder;
25
+ /** Epoch milliseconds. Defaults to the clock captured when this module loaded. */
26
+ readonly now?: () => number;
27
+ /** Fills `bytes` with random values. Defaults to the source captured when this module loaded. */
28
+ readonly randomBytes?: (bytes: Uint8Array) => void;
29
+ }
30
+ /** A prover over the view's key root holder. */
31
+ export declare function createViewProver(deps: ViewProverDeps): ViewProver;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The view's prover (ggui#1415): the one wrapper every `tools/call` of the
3
+ * three app-only runtime tools goes through.
4
+ *
5
+ * {@link ViewProver.withViewProof} sends the JSON-normalized arguments and,
6
+ * when the view holds a key root for the call's session, a v1 proof at
7
+ * `_meta["ai.ggui/view"]`, the only `_meta` key it sets. Any other tool
8
+ * passes through untouched. It never throws and never changes whether or
9
+ * where a call is sent: a failure means the call goes without a proof.
10
+ *
11
+ * The HMAC is synchronous (`@noble/hashes`), so wrapping a raw send keeps
12
+ * its place in the send order, and it works where `crypto.subtle` is absent
13
+ * (a plain-http self-host).
14
+ */
15
+ import { hmac } from '@noble/hashes/hmac';
16
+ import { sha256 } from '@noble/hashes/sha2';
17
+ import { MCP_APP_AI_GGUI_VIEW_META_KEY, VIEW_PROOF_FLAG_USER_ACTIVATION, VIEW_PROOF_V1_PATTERN, formatViewProofV1, isViewProofTool, viewProofArgsBytes, viewProofCallBytes, } from '@ggui-ai/protocol/integrations/mcp-apps';
18
+ // Captured when the runtime bundle evaluates, before any card code runs in
19
+ // this realm, so a card that replaces these globals later does not reach
20
+ // the prover.
21
+ const capturedNow = Date.now.bind(Date);
22
+ const capturedCrypto = typeof crypto !== 'undefined' ? crypto : undefined;
23
+ const capturedGetRandomValues = capturedCrypto?.getRandomValues.bind(capturedCrypto);
24
+ function capturedRandomBytes(bytes) {
25
+ if (capturedGetRandomValues === undefined)
26
+ throw new Error('no random source');
27
+ capturedGetRandomValues(bytes);
28
+ }
29
+ const NONCE_BYTES = 16;
30
+ function toBase64Url(bytes) {
31
+ let binary = '';
32
+ for (const byte of bytes)
33
+ binary += String.fromCharCode(byte);
34
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
35
+ }
36
+ const BASE64URL = /^[A-Za-z0-9_-]*$/;
37
+ function fromBase64Url(text) {
38
+ if (!BASE64URL.test(text))
39
+ throw new Error('not base64url');
40
+ const padded = text.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((text.length + 3) % 4);
41
+ return Uint8Array.from(atob(padded), (ch) => ch.charCodeAt(0));
42
+ }
43
+ function isJsonObject(value) {
44
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
45
+ }
46
+ /** The arguments as JSON carries them, or `undefined` when JSON cannot carry them. */
47
+ function normalize(args) {
48
+ try {
49
+ const text = JSON.stringify(args);
50
+ if (text === undefined)
51
+ return undefined;
52
+ const value = JSON.parse(text);
53
+ return isJsonObject(value) ? value : undefined;
54
+ }
55
+ catch {
56
+ // A BigInt or a cycle: JSON cannot carry these arguments, so there is
57
+ // nothing to sign, and the call goes as the caller built it.
58
+ return undefined;
59
+ }
60
+ }
61
+ /** A prover over the view's key root holder. */
62
+ export function createViewProver(deps) {
63
+ const now = deps.now ?? capturedNow;
64
+ const randomBytes = deps.randomBytes ?? capturedRandomBytes;
65
+ return {
66
+ withViewProof(name, args, opts) {
67
+ if (!isViewProofTool(name))
68
+ return { name, arguments: args };
69
+ const normalized = normalize(args);
70
+ if (normalized === undefined)
71
+ return { name, arguments: args };
72
+ const sessionId = normalized['sessionId'];
73
+ const root = typeof sessionId === 'string' ? deps.roots.current(sessionId) : undefined;
74
+ if (root === undefined)
75
+ return { name, arguments: normalized };
76
+ try {
77
+ const key = fromBase64Url(root.key);
78
+ const argmac = toBase64Url(hmac(sha256, key, viewProofArgsBytes(name, normalized)));
79
+ const nonceBytes = new Uint8Array(NONCE_BYTES);
80
+ randomBytes(nonceBytes);
81
+ const nonce = toBase64Url(nonceBytes);
82
+ const vtime = String(Math.trunc(now()));
83
+ const flags = (opts?.userActivation === true ? VIEW_PROOF_FLAG_USER_ACTIVATION : 0).toString(16);
84
+ const callmac = toBase64Url(hmac(sha256, key, viewProofCallBytes({ toolName: name, nonce, vtime, flags, argmac })));
85
+ const proof = formatViewProofV1({ root: root.root, nonce, vtime, flags, argmac, callmac });
86
+ // A clock or a key outside the grammar makes a string no verifier
87
+ // would parse: send none rather than one that can only fail.
88
+ if (!VIEW_PROOF_V1_PATTERN.test(proof))
89
+ return { name, arguments: normalized };
90
+ return { name, arguments: normalized, _meta: { [MCP_APP_AI_GGUI_VIEW_META_KEY]: proof } };
91
+ }
92
+ catch {
93
+ // Total by contract: a failed mint sends the call without a proof,
94
+ // which the server counts as `missing`, never a failed call.
95
+ return { name, arguments: normalized };
96
+ }
97
+ },
98
+ };
99
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The view's key root (ggui#1415): what the parse captures from a render
3
+ * slice, and which root the view proves its app-only runtime calls with.
4
+ *
5
+ * A slice that carries a `viewKey` roots it in its `wsToken`: `K` is
6
+ * derived by the server from `P`, the token's payload segment. The parse
7
+ * takes the pair before an expired credential is dropped from the meta
8
+ * ({@link viewRootOf}), and {@link createViewRootHolder} decides whether a
9
+ * later slice's root replaces the one the view holds.
10
+ */
11
+ import { type McpAppAiGguiRenderMeta, type ViewRootClaims } from '@ggui-ai/protocol/integrations/mcp-apps';
12
+ import type { ViewRoot } from './types.js';
13
+ /**
14
+ * The slice's key root: `P` from its `wsToken`, `K` from its `viewKey`.
15
+ * `undefined` when either is absent, since a key means nothing without the
16
+ * token it is rooted in. The protocol parser already keeps a `viewKey` only
17
+ * beside its ws envelope and only in its shape.
18
+ */
19
+ export declare function viewRootOf(meta: Pick<McpAppAiGguiRenderMeta, 'wsToken' | 'viewKey'>): ViewRoot | undefined;
20
+ /** A held root, with the claims its `P` decoded to. */
21
+ export interface HeldViewRoot extends ViewRoot {
22
+ readonly claims: ViewRootClaims;
23
+ }
24
+ /**
25
+ * Why an offered root was not adopted. `no_key`: the slice carried none (a
26
+ * `/state` poll, a keyless door). `oversize`: `P` is longer than a proof can
27
+ * carry. `malformed`: `P` does not decode to ws claims. `session_mismatch`:
28
+ * `P` names another session than the slice. `not_newer`: the view already
29
+ * holds a root for this session at the same or a later `iat`.
30
+ */
31
+ export type ViewRootRefusal = 'no_key' | 'oversize' | 'malformed' | 'session_mismatch' | 'not_newer';
32
+ export type ViewRootOffer = {
33
+ readonly adopted: true;
34
+ } | {
35
+ readonly adopted: false;
36
+ readonly reason: ViewRootRefusal;
37
+ };
38
+ export interface ViewRootHolder {
39
+ /** Offer the root a slice for `sessionId` carried. */
40
+ offer(sessionId: string, root: ViewRoot | undefined): ViewRootOffer;
41
+ /** The held root, only when it was adopted for `sessionId`. */
42
+ current(sessionId: string): HeldViewRoot | undefined;
43
+ }
44
+ /**
45
+ * One view's key root. It adopts a root only when `P` fits a proof and
46
+ * decodes to the offering slice's own session. For the session it already
47
+ * holds, it adopts only a greater `iat`, so a replayed or reordered slice
48
+ * never winds the root back. A slice for another session replaces it: a
49
+ * view proves calls only for the session they name.
50
+ */
51
+ export declare function createViewRootHolder(): ViewRootHolder;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The view's key root (ggui#1415): what the parse captures from a render
3
+ * slice, and which root the view proves its app-only runtime calls with.
4
+ *
5
+ * A slice that carries a `viewKey` roots it in its `wsToken`: `K` is
6
+ * derived by the server from `P`, the token's payload segment. The parse
7
+ * takes the pair before an expired credential is dropped from the meta
8
+ * ({@link viewRootOf}), and {@link createViewRootHolder} decides whether a
9
+ * later slice's root replaces the one the view holds.
10
+ */
11
+ import { VIEW_PROOF_ROOT_MAX_CHARS, decodeViewRootClaims, } from '@ggui-ai/protocol/integrations/mcp-apps';
12
+ /**
13
+ * The slice's key root: `P` from its `wsToken`, `K` from its `viewKey`.
14
+ * `undefined` when either is absent, since a key means nothing without the
15
+ * token it is rooted in. The protocol parser already keeps a `viewKey` only
16
+ * beside its ws envelope and only in its shape.
17
+ */
18
+ export function viewRootOf(meta) {
19
+ const { wsToken, viewKey } = meta;
20
+ if (typeof viewKey !== 'string' || viewKey.length === 0)
21
+ return undefined;
22
+ if (typeof wsToken !== 'string' || wsToken.length === 0)
23
+ return undefined;
24
+ return { root: wsToken.split('.')[0] ?? '', key: viewKey };
25
+ }
26
+ /**
27
+ * One view's key root. It adopts a root only when `P` fits a proof and
28
+ * decodes to the offering slice's own session. For the session it already
29
+ * holds, it adopts only a greater `iat`, so a replayed or reordered slice
30
+ * never winds the root back. A slice for another session replaces it: a
31
+ * view proves calls only for the session they name.
32
+ */
33
+ export function createViewRootHolder() {
34
+ let held;
35
+ return {
36
+ offer(sessionId, root) {
37
+ if (root === undefined)
38
+ return { adopted: false, reason: 'no_key' };
39
+ if (root.root.length > VIEW_PROOF_ROOT_MAX_CHARS)
40
+ return { adopted: false, reason: 'oversize' };
41
+ const claims = decodeViewRootClaims(root.root);
42
+ if (typeof claims === 'string')
43
+ return { adopted: false, reason: 'malformed' };
44
+ if (claims.sessionId !== sessionId)
45
+ return { adopted: false, reason: 'session_mismatch' };
46
+ if (held !== undefined && held.claims.sessionId === sessionId && claims.iat <= held.claims.iat) {
47
+ return { adopted: false, reason: 'not_newer' };
48
+ }
49
+ held = { root: root.root, key: root.key, claims };
50
+ return { adopted: true };
51
+ },
52
+ current(sessionId) {
53
+ return held !== undefined && held.claims.sessionId === sessionId ? held : undefined;
54
+ },
55
+ };
56
+ }
@@ -55,6 +55,13 @@ export interface BuildRootWireConfigOptions {
55
55
  * Passed through as wire's `spentInputsChanged` and `pendingInputsChanged`.
56
56
  */
57
57
  readonly renderChanges?: (listener: () => void) => () => void;
58
+ /**
59
+ * ggui#1424 — the `oneShot` names the SERVER refused for this card as
60
+ * already spent (a `CONTRACT_VIOLATION` at `actionSpec.<name>.oneShot`),
61
+ * which the runtime marks so the guard reads them beside the card's
62
+ * persisted record. Read on every dispatch; `undefined` means none.
63
+ */
64
+ readonly refusedSpentOneShots?: () => readonly string[] | undefined;
58
65
  /** Handle to the renderer's WS manager; used for outbound `action` frames. */
59
66
  readonly manager: RendererSendSurface;
60
67
  /** Shared bus for inbound stream deliveries. */
@@ -116,6 +123,12 @@ export interface BuildRootWireConfigOptions {
116
123
  * relay (see iframe-runtime's `routeDispatch`).
117
124
  */
118
125
  readonly onDispatchEnvelope?: (envelope: ActionEnvelope) => void;
126
+ /**
127
+ * Called with the action's name when the outbound check refuses a
128
+ * dispatch (ggui#1536), after the refusal is posted to the embedding
129
+ * host. The renderer tells the visitor the tap did nothing.
130
+ */
131
+ readonly onDispatchRefused?: (actionName: string) => void;
119
132
  }
120
133
  /**
121
134
  * Build the per-render `WireConfig` for the iframe runtime.