@guuey/agent-client 0.6.0 → 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.
@@ -95,6 +95,27 @@ export interface CreateUiResourceReaderOptions {
95
95
  apiBaseUrl: string;
96
96
  /** The thread whose persisted locators this reader may resolve. */
97
97
  threadId: string;
98
+ /**
99
+ * The pod base (or full invoke URL — same normalization as the invoke
100
+ * transport). When set, the reader tries the POD door first
101
+ * (`GET <base>/agent/ui-resource`, guuey#209 C1): the pod is the only
102
+ * party that can vouch for a locator whose turn is still streaming —
103
+ * persisted `kind:'card'` rows land at turn COMPLETION, so the platform
104
+ * door 404s mid-turn by construction. Completed turns 404 on the pod
105
+ * (past its grace window) and resolve on the platform door instead: one
106
+ * authority per lifecycle phase, and this reader tries both in that
107
+ * order. Absent → platform door only (pre-#209 behavior) — which means
108
+ * **a card produced mid-turn cannot resolve until its turn completes**:
109
+ * under a producer that inlines no mount material (any plain-locator MCP
110
+ * server, ggui's read-plane-only posture) every fresh card renders
111
+ * "expired" until reload. A live surface that holds an invoke endpoint
112
+ * MUST pass it here; omitting it is only correct for a pure history
113
+ * viewer with no pod (SelfHostedThreadViewer). The reader warns once at
114
+ * construction when a platform door is configured without a pod door,
115
+ * because the failure it prevents is silent by nature (guuey#209 /
116
+ * ggui cac966a2d — both first external embeds shipped without it).
117
+ */
118
+ endpointUrl?: string | null;
98
119
  /** Signed-in bearer — wins over the guest secret (same rule as the transport). */
99
120
  getAccessToken?: (opts?: {
100
121
  forceRefresh?: boolean;
@@ -104,19 +125,29 @@ export interface CreateUiResourceReaderOptions {
104
125
  /** Injectable for tests. */
105
126
  fetchImpl?: typeof fetch;
106
127
  }
128
+ /** @internal test seam — the once-flag is module state; suites reset it between cases. */
129
+ export declare function __resetReaderEndpointWarning(): void;
107
130
  /**
108
- * Build a `UiResourceReader` over guuey's authenticated resources/read proxy
109
- * (guuey#122 Gap 1: `GET /v1/threads/:threadId/ui-resource?uri=…`).
131
+ * Build a `UiResourceReader` over guuey's authenticated resources/read
132
+ * doors — the pod door for LIVE turns (guuey#209 C1:
133
+ * `GET <pod>/agent/ui-resource?uri=…`, when {@link CreateUiResourceReaderOptions.endpointUrl}
134
+ * is set) and the platform proxy for persisted locators (guuey#122 Gap 1:
135
+ * `GET /v1/threads/:threadId/ui-resource?uri=…`). Both doors answer the
136
+ * same body and speak the same identity (bearer wins, guest header
137
+ * otherwise — the pod's `resolveIdentity` and the proxy's identity chain
138
+ * accept the identical carriers), so one parse serves both.
110
139
  *
111
140
  * This is `@guuey/mcp-apps-host`'s `createMcpUiResourceReader` assembly over
112
141
  * a guuey-platform transport (guuey#127) — channel resolution and payload
113
142
  * narrowing live in the host package; only the transport is guuey-shaped.
114
- * The proxy owns EVERYTHING trust-shaped: caller identity (the same three
115
- * families as the history read), thread ownership, the locator-to-thread
116
- * scope guard, and the per-user federation mint. This transport only carries
117
- * the surface's existing credential and maps EVERY non-OK — 401/403/404/502
118
- * alike to `undefined`: deny is byte-identical to a miss, and a miss
119
- * renders the host's placeholder, never an error surface.
143
+ * The doors own EVERYTHING trust-shaped: caller identity (the same three
144
+ * families as the history read), tenancy (the pod's live-card ledger; the
145
+ * proxy's thread-ownership + locator-to-thread scope guard), and the
146
+ * per-user federation mint. This transport only carries the surface's
147
+ * existing credential and maps EVERY non-OK 401/403/404/502 alike to
148
+ * "try the next door", and a miss on the last door to `undefined`: deny is
149
+ * byte-identical to a miss, and a miss renders the host's placeholder,
150
+ * never an error surface.
120
151
  */
121
152
  export declare function createUiResourceReader(options: CreateUiResourceReaderOptions): (resourceUri: string) => Promise<ResolvedViewMount | undefined>;
122
153
  /** Options for {@link createUiActionRelay} — same credential surface as the reader. */
@@ -1 +1 @@
1
- {"version":3,"file":"web-adapters.d.ts","sourceRoot":"","sources":["../src/web-adapters.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAIL,KAAK,iBAAiB,EAEtB,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACrB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAAE,mBAAmB,EAAmB,aAAa,EAAE,MAAM,YAAY,CAAC;AAItF,sEAAsE;AACtE,eAAO,MAAM,uBAAuB,EAAE,aAiBrC,CAAC;AAEF,mEAAmE;AACnE,wBAAgB,aAAa,IAAI,MAAM,CAKtC;AAED,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;;;;;;;;;;;OAiBG;IACH,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAuCG;IACH,cAAc,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;CACtC;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,GAAE,wBAA6B,GAClC,mBAAmB,CA8ErB;AAED,mGAAmG;AACnG,MAAM,WAAW,6BAA6B;IAC5C,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAC;IACnB,mEAAmE;IACnE,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,4BAA4B;IAC5B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,6BAA6B,GACrC,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAoDjE;AAED,uFAAuF;AACvF,MAAM,WAAW,0BAA0B;IACzC,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAC;IACnB,+DAA+D;IAC/D,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,4BAA4B;IAC5B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,0BAA0B,GAClC,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,CAAC,iBAAiB,CAAC,CA+C1D"}
1
+ {"version":3,"file":"web-adapters.d.ts","sourceRoot":"","sources":["../src/web-adapters.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAIL,KAAK,iBAAiB,EAEtB,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACrB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAAE,mBAAmB,EAAmB,aAAa,EAAE,MAAM,YAAY,CAAC;AAKtF,sEAAsE;AACtE,eAAO,MAAM,uBAAuB,EAAE,aAiBrC,CAAC;AAEF,mEAAmE;AACnE,wBAAgB,aAAa,IAAI,MAAM,CAKtC;AAED,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;;;;;;;;;;;OAiBG;IACH,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAuCG;IACH,cAAc,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;CACtC;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,GAAE,wBAA6B,GAClC,mBAAmB,CA8ErB;AAED,mGAAmG;AACnG,MAAM,WAAW,6BAA6B;IAC5C,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAC;IACnB,mEAAmE;IACnE,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,kFAAkF;IAClF,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,4BAA4B;IAC5B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAID,0FAA0F;AAC1F,wBAAgB,4BAA4B,IAAI,IAAI,CAEnD;AAOD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,6BAA6B,GACrC,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAsFjE;AAED,uFAAuF;AACvF,MAAM,WAAW,0BAA0B;IACzC,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAC;IACnB,+DAA+D;IAC/D,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,4BAA4B;IAC5B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,0BAA0B,GAClC,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,CAAC,iBAAiB,CAAC,CA+C1D"}
@@ -9,6 +9,7 @@
9
9
  import { createMcpUiActionRelay, createMcpUiResourceReader, } from "@guuey/mcp-apps-host";
10
10
  import { fetchThreadHistory, HistoryUnauthorizedError } from "./history.js";
11
11
  import { fetchStreamTransport, sendableGuestSecret, GUEST_HEADER } from "./transport.js";
12
+ import { toInvokeUrl } from "./invoke-turn.js";
12
13
  /** Persists the threadId in `window.localStorage` (synchronously). */
13
14
  export const localStorageThreadStore = {
14
15
  load(key) {
@@ -124,24 +125,59 @@ export function createWebAdapters(opts = {}) {
124
125
  }
125
126
  return adapters;
126
127
  }
128
+ /** Warn once per module load — the misconfiguration is per-surface, not per-read. */
129
+ let readerEndpointWarned = false;
130
+ /** @internal test seam — the once-flag is module state; suites reset it between cases. */
131
+ export function __resetReaderEndpointWarning() {
132
+ readerEndpointWarned = false;
133
+ }
134
+ /** `<pod base>/agent/ui-resource` from whatever endpoint shape the surface holds. */
135
+ function toUiResourceUrl(endpointUrl) {
136
+ return toInvokeUrl(endpointUrl).replace(/\/agent\/invoke$/, "/agent/ui-resource");
137
+ }
127
138
  /**
128
- * Build a `UiResourceReader` over guuey's authenticated resources/read proxy
129
- * (guuey#122 Gap 1: `GET /v1/threads/:threadId/ui-resource?uri=…`).
139
+ * Build a `UiResourceReader` over guuey's authenticated resources/read
140
+ * doors — the pod door for LIVE turns (guuey#209 C1:
141
+ * `GET <pod>/agent/ui-resource?uri=…`, when {@link CreateUiResourceReaderOptions.endpointUrl}
142
+ * is set) and the platform proxy for persisted locators (guuey#122 Gap 1:
143
+ * `GET /v1/threads/:threadId/ui-resource?uri=…`). Both doors answer the
144
+ * same body and speak the same identity (bearer wins, guest header
145
+ * otherwise — the pod's `resolveIdentity` and the proxy's identity chain
146
+ * accept the identical carriers), so one parse serves both.
130
147
  *
131
148
  * This is `@guuey/mcp-apps-host`'s `createMcpUiResourceReader` assembly over
132
149
  * a guuey-platform transport (guuey#127) — channel resolution and payload
133
150
  * narrowing live in the host package; only the transport is guuey-shaped.
134
- * The proxy owns EVERYTHING trust-shaped: caller identity (the same three
135
- * families as the history read), thread ownership, the locator-to-thread
136
- * scope guard, and the per-user federation mint. This transport only carries
137
- * the surface's existing credential and maps EVERY non-OK — 401/403/404/502
138
- * alike to `undefined`: deny is byte-identical to a miss, and a miss
139
- * renders the host's placeholder, never an error surface.
151
+ * The doors own EVERYTHING trust-shaped: caller identity (the same three
152
+ * families as the history read), tenancy (the pod's live-card ledger; the
153
+ * proxy's thread-ownership + locator-to-thread scope guard), and the
154
+ * per-user federation mint. This transport only carries the surface's
155
+ * existing credential and maps EVERY non-OK 401/403/404/502 alike to
156
+ * "try the next door", and a miss on the last door to `undefined`: deny is
157
+ * byte-identical to a miss, and a miss renders the host's placeholder,
158
+ * never an error surface.
140
159
  */
141
160
  export function createUiResourceReader(options) {
142
161
  const fetchImpl = options.fetchImpl ?? fetch;
143
- const readResource = async (resourceUri) => {
162
+ // A platform door without a pod door is almost always a live surface
163
+ // that forgot `endpointUrl` — its cards would die silently for the whole
164
+ // mid-turn window. `null` is the explicit "I am a history-only viewer,
165
+ // there is no pod" opt-out; `undefined` is the forgotten case.
166
+ if (options.endpointUrl === undefined && !readerEndpointWarned) {
167
+ readerEndpointWarned = true;
168
+ console.warn("createUiResourceReader: no `endpointUrl` — cards produced mid-turn cannot resolve until the turn completes (the pod door is the only authority while a turn streams). Pass the surface's invoke endpoint, or `endpointUrl: null` to declare a history-only viewer.");
169
+ }
170
+ /** One door: fetch + the history adapter's 401-forceRefresh recovery + parse. */
171
+ const readDoor = async (requestUrl) => {
172
+ // Exactly ONE identity carrier per read, the invoke transport's rule
173
+ // (`streamInvokeOnce`): bearer → guest header → else cookie credentials,
174
+ // which round-trip the HttpOnly `guuey_guest` cookie the pod mints for
175
+ // anonymous browser callers. Without the third arm a cookie-mode guest
176
+ // sent an identity-less read and every locator rendered as expired
177
+ // (guuey#221). Never two at once: a request carrying either header does
178
+ // NOT also send cookies.
144
179
  const headers = {};
180
+ const init = { headers };
145
181
  const token = options.getAccessToken ? await options.getAccessToken() : null;
146
182
  const guest = sendableGuestSecret(options.guestSecret);
147
183
  if (token) {
@@ -150,17 +186,20 @@ export function createUiResourceReader(options) {
150
186
  else if (guest) {
151
187
  headers[GUEST_HEADER] = guest;
152
188
  }
153
- const requestUrl = `${options.apiBaseUrl}/threads/${encodeURIComponent(options.threadId)}/ui-resource?uri=${encodeURIComponent(resourceUri)}`;
189
+ else {
190
+ init.credentials = "include";
191
+ }
154
192
  let res;
155
193
  try {
156
- res = await fetchImpl(requestUrl, { headers });
194
+ res = await fetchImpl(requestUrl, init);
157
195
  }
158
196
  catch {
159
- return undefined; // transport failure == miss == placeholder
197
+ return undefined; // transport failure == miss (the next door may still answer)
160
198
  }
161
199
  // One forceRefresh retry on 401 with a bearer in play — the same
162
200
  // expired-but-refreshable recovery the history adapter performs;
163
- // without it a stale token degrades to a permanent placeholder.
201
+ // without it a stale token degrades to a permanent placeholder. The
202
+ // retry carries the fresh bearer and nothing else (same one-carrier rule).
164
203
  if (res.status === 401 && options.getAccessToken) {
165
204
  const fresh = await options.getAccessToken({ forceRefresh: true }).catch(() => null);
166
205
  if (fresh) {
@@ -183,8 +222,8 @@ export function createUiResourceReader(options) {
183
222
  catch {
184
223
  return undefined;
185
224
  }
186
- // The proxy passes the blob arm through (a blob-only resource is not
187
- // silently a miss — its route contract); mirror that here.
225
+ // Both doors pass the blob arm through (a blob-only resource is not
226
+ // silently a miss — the route contract); mirror that here.
188
227
  if (typeof body.uri !== "string")
189
228
  return undefined;
190
229
  if (typeof body.text !== "string" && typeof body.blob !== "string")
@@ -196,6 +235,16 @@ export function createUiResourceReader(options) {
196
235
  ...(typeof body.blob === "string" ? { blob: body.blob } : {}),
197
236
  };
198
237
  };
238
+ const podUrl = options.endpointUrl ? toUiResourceUrl(options.endpointUrl) : null;
239
+ const readResource = async (resourceUri) => {
240
+ const query = `?uri=${encodeURIComponent(resourceUri)}`;
241
+ if (podUrl !== null) {
242
+ const live = await readDoor(`${podUrl}${query}`);
243
+ if (live !== undefined)
244
+ return live;
245
+ }
246
+ return readDoor(`${options.apiBaseUrl}/threads/${encodeURIComponent(options.threadId)}/ui-resource${query}`);
247
+ };
199
248
  return createMcpUiResourceReader({ readResource });
200
249
  }
201
250
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guuey/agent-client",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Client SDK for Guuey's agent runtime: the `useAgentInvoke` React hook + pure SSE helpers that speak the /agent/invoke streaming contract, plus the paginated thread-history read plane. Host adapters (storage / id / transport) are injected, so it runs on web (Next) and React Native alike.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@silverprotocol/core": "0.5.0",
38
- "@guuey/mcp-apps-host": "0.6.0"
38
+ "@guuey/mcp-apps-host": "0.7.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "react": ">=18"
@@ -18,6 +18,7 @@ import {
18
18
  import type { AgentInvokeAdapters, InvokeTransport, ThreadIdStore } from "./types.js";
19
19
  import { fetchThreadHistory, HistoryUnauthorizedError } from "./history.js";
20
20
  import { fetchStreamTransport, sendableGuestSecret, GUEST_HEADER } from "./transport.js";
21
+ import { toInvokeUrl } from "./invoke-turn.js";
21
22
 
22
23
  /** Persists the threadId in `window.localStorage` (synchronously). */
23
24
  export const localStorageThreadStore: ThreadIdStore = {
@@ -210,6 +211,27 @@ export interface CreateUiResourceReaderOptions {
210
211
  apiBaseUrl: string;
211
212
  /** The thread whose persisted locators this reader may resolve. */
212
213
  threadId: string;
214
+ /**
215
+ * The pod base (or full invoke URL — same normalization as the invoke
216
+ * transport). When set, the reader tries the POD door first
217
+ * (`GET <base>/agent/ui-resource`, guuey#209 C1): the pod is the only
218
+ * party that can vouch for a locator whose turn is still streaming —
219
+ * persisted `kind:'card'` rows land at turn COMPLETION, so the platform
220
+ * door 404s mid-turn by construction. Completed turns 404 on the pod
221
+ * (past its grace window) and resolve on the platform door instead: one
222
+ * authority per lifecycle phase, and this reader tries both in that
223
+ * order. Absent → platform door only (pre-#209 behavior) — which means
224
+ * **a card produced mid-turn cannot resolve until its turn completes**:
225
+ * under a producer that inlines no mount material (any plain-locator MCP
226
+ * server, ggui's read-plane-only posture) every fresh card renders
227
+ * "expired" until reload. A live surface that holds an invoke endpoint
228
+ * MUST pass it here; omitting it is only correct for a pure history
229
+ * viewer with no pod (SelfHostedThreadViewer). The reader warns once at
230
+ * construction when a platform door is configured without a pod door,
231
+ * because the failure it prevents is silent by nature (guuey#209 /
232
+ * ggui cac966a2d — both first external embeds shipped without it).
233
+ */
234
+ endpointUrl?: string | null;
213
235
  /** Signed-in bearer — wins over the guest secret (same rule as the transport). */
214
236
  getAccessToken?: (opts?: { forceRefresh?: boolean }) => Promise<string | null>;
215
237
  /** Caller-owned anonymous guest secret (widget / guest chat). */
@@ -218,43 +240,85 @@ export interface CreateUiResourceReaderOptions {
218
240
  fetchImpl?: typeof fetch;
219
241
  }
220
242
 
243
+ /** Warn once per module load — the misconfiguration is per-surface, not per-read. */
244
+ let readerEndpointWarned = false;
245
+ /** @internal test seam — the once-flag is module state; suites reset it between cases. */
246
+ export function __resetReaderEndpointWarning(): void {
247
+ readerEndpointWarned = false;
248
+ }
249
+
250
+ /** `<pod base>/agent/ui-resource` from whatever endpoint shape the surface holds. */
251
+ function toUiResourceUrl(endpointUrl: string): string {
252
+ return toInvokeUrl(endpointUrl).replace(/\/agent\/invoke$/, "/agent/ui-resource");
253
+ }
254
+
221
255
  /**
222
- * Build a `UiResourceReader` over guuey's authenticated resources/read proxy
223
- * (guuey#122 Gap 1: `GET /v1/threads/:threadId/ui-resource?uri=…`).
256
+ * Build a `UiResourceReader` over guuey's authenticated resources/read
257
+ * doors — the pod door for LIVE turns (guuey#209 C1:
258
+ * `GET <pod>/agent/ui-resource?uri=…`, when {@link CreateUiResourceReaderOptions.endpointUrl}
259
+ * is set) and the platform proxy for persisted locators (guuey#122 Gap 1:
260
+ * `GET /v1/threads/:threadId/ui-resource?uri=…`). Both doors answer the
261
+ * same body and speak the same identity (bearer wins, guest header
262
+ * otherwise — the pod's `resolveIdentity` and the proxy's identity chain
263
+ * accept the identical carriers), so one parse serves both.
224
264
  *
225
265
  * This is `@guuey/mcp-apps-host`'s `createMcpUiResourceReader` assembly over
226
266
  * a guuey-platform transport (guuey#127) — channel resolution and payload
227
267
  * narrowing live in the host package; only the transport is guuey-shaped.
228
- * The proxy owns EVERYTHING trust-shaped: caller identity (the same three
229
- * families as the history read), thread ownership, the locator-to-thread
230
- * scope guard, and the per-user federation mint. This transport only carries
231
- * the surface's existing credential and maps EVERY non-OK — 401/403/404/502
232
- * alike to `undefined`: deny is byte-identical to a miss, and a miss
233
- * renders the host's placeholder, never an error surface.
268
+ * The doors own EVERYTHING trust-shaped: caller identity (the same three
269
+ * families as the history read), tenancy (the pod's live-card ledger; the
270
+ * proxy's thread-ownership + locator-to-thread scope guard), and the
271
+ * per-user federation mint. This transport only carries the surface's
272
+ * existing credential and maps EVERY non-OK 401/403/404/502 alike to
273
+ * "try the next door", and a miss on the last door to `undefined`: deny is
274
+ * byte-identical to a miss, and a miss renders the host's placeholder,
275
+ * never an error surface.
234
276
  */
235
277
  export function createUiResourceReader(
236
278
  options: CreateUiResourceReaderOptions,
237
279
  ): (resourceUri: string) => Promise<ResolvedViewMount | undefined> {
238
280
  const fetchImpl = options.fetchImpl ?? fetch;
239
- const readResource = async (resourceUri: string): Promise<McpResourceReadResult | undefined> => {
281
+ // A platform door without a pod door is almost always a live surface
282
+ // that forgot `endpointUrl` — its cards would die silently for the whole
283
+ // mid-turn window. `null` is the explicit "I am a history-only viewer,
284
+ // there is no pod" opt-out; `undefined` is the forgotten case.
285
+ if (options.endpointUrl === undefined && !readerEndpointWarned) {
286
+ readerEndpointWarned = true;
287
+ console.warn(
288
+ "createUiResourceReader: no `endpointUrl` — cards produced mid-turn cannot resolve until the turn completes (the pod door is the only authority while a turn streams). Pass the surface's invoke endpoint, or `endpointUrl: null` to declare a history-only viewer.",
289
+ );
290
+ }
291
+
292
+ /** One door: fetch + the history adapter's 401-forceRefresh recovery + parse. */
293
+ const readDoor = async (requestUrl: string): Promise<McpResourceReadResult | undefined> => {
294
+ // Exactly ONE identity carrier per read, the invoke transport's rule
295
+ // (`streamInvokeOnce`): bearer → guest header → else cookie credentials,
296
+ // which round-trip the HttpOnly `guuey_guest` cookie the pod mints for
297
+ // anonymous browser callers. Without the third arm a cookie-mode guest
298
+ // sent an identity-less read and every locator rendered as expired
299
+ // (guuey#221). Never two at once: a request carrying either header does
300
+ // NOT also send cookies.
240
301
  const headers: Record<string, string> = {};
302
+ const init: RequestInit = { headers };
241
303
  const token = options.getAccessToken ? await options.getAccessToken() : null;
242
304
  const guest = sendableGuestSecret(options.guestSecret);
243
305
  if (token) {
244
306
  headers["authorization"] = `Bearer ${token}`;
245
307
  } else if (guest) {
246
308
  headers[GUEST_HEADER] = guest;
309
+ } else {
310
+ init.credentials = "include";
247
311
  }
248
- const requestUrl = `${options.apiBaseUrl}/threads/${encodeURIComponent(options.threadId)}/ui-resource?uri=${encodeURIComponent(resourceUri)}`;
249
312
  let res: Response;
250
313
  try {
251
- res = await fetchImpl(requestUrl, { headers });
314
+ res = await fetchImpl(requestUrl, init);
252
315
  } catch {
253
- return undefined; // transport failure == miss == placeholder
316
+ return undefined; // transport failure == miss (the next door may still answer)
254
317
  }
255
318
  // One forceRefresh retry on 401 with a bearer in play — the same
256
319
  // expired-but-refreshable recovery the history adapter performs;
257
- // without it a stale token degrades to a permanent placeholder.
320
+ // without it a stale token degrades to a permanent placeholder. The
321
+ // retry carries the fresh bearer and nothing else (same one-carrier rule).
258
322
  if (res.status === 401 && options.getAccessToken) {
259
323
  const fresh = await options.getAccessToken({ forceRefresh: true }).catch(() => null);
260
324
  if (fresh) {
@@ -274,8 +338,8 @@ export function createUiResourceReader(
274
338
  } catch {
275
339
  return undefined;
276
340
  }
277
- // The proxy passes the blob arm through (a blob-only resource is not
278
- // silently a miss — its route contract); mirror that here.
341
+ // Both doors pass the blob arm through (a blob-only resource is not
342
+ // silently a miss — the route contract); mirror that here.
279
343
  if (typeof body.uri !== "string") return undefined;
280
344
  if (typeof body.text !== "string" && typeof body.blob !== "string") return undefined;
281
345
  return {
@@ -285,6 +349,18 @@ export function createUiResourceReader(
285
349
  ...(typeof body.blob === "string" ? { blob: body.blob } : {}),
286
350
  };
287
351
  };
352
+
353
+ const podUrl = options.endpointUrl ? toUiResourceUrl(options.endpointUrl) : null;
354
+ const readResource = async (resourceUri: string): Promise<McpResourceReadResult | undefined> => {
355
+ const query = `?uri=${encodeURIComponent(resourceUri)}`;
356
+ if (podUrl !== null) {
357
+ const live = await readDoor(`${podUrl}${query}`);
358
+ if (live !== undefined) return live;
359
+ }
360
+ return readDoor(
361
+ `${options.apiBaseUrl}/threads/${encodeURIComponent(options.threadId)}/ui-resource${query}`,
362
+ );
363
+ };
288
364
  return createMcpUiResourceReader({ readResource });
289
365
  }
290
366