@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.
- package/dist/web-adapters.d.ts +39 -8
- package/dist/web-adapters.d.ts.map +1 -1
- package/dist/web-adapters.js +64 -15
- package/package.json +2 -2
- package/src/web-adapters.ts +91 -15
package/dist/web-adapters.d.ts
CHANGED
|
@@ -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
|
|
109
|
-
* (guuey#
|
|
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
|
|
115
|
-
* families as the history read),
|
|
116
|
-
* scope guard, and the
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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;
|
|
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"}
|
package/dist/web-adapters.js
CHANGED
|
@@ -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
|
|
129
|
-
* (guuey#
|
|
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
|
|
135
|
-
* families as the history read),
|
|
136
|
-
* scope guard, and the
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
189
|
+
else {
|
|
190
|
+
init.credentials = "include";
|
|
191
|
+
}
|
|
154
192
|
let res;
|
|
155
193
|
try {
|
|
156
|
-
res = await fetchImpl(requestUrl,
|
|
194
|
+
res = await fetchImpl(requestUrl, init);
|
|
157
195
|
}
|
|
158
196
|
catch {
|
|
159
|
-
return undefined; // transport failure == miss
|
|
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
|
-
//
|
|
187
|
-
// silently a miss —
|
|
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.
|
|
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.
|
|
38
|
+
"@guuey/mcp-apps-host": "0.7.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"react": ">=18"
|
package/src/web-adapters.ts
CHANGED
|
@@ -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
|
|
223
|
-
* (guuey#
|
|
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
|
|
229
|
-
* families as the history read),
|
|
230
|
-
* scope guard, and the
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
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
|
-
|
|
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,
|
|
314
|
+
res = await fetchImpl(requestUrl, init);
|
|
252
315
|
} catch {
|
|
253
|
-
return undefined; // transport failure == miss
|
|
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
|
-
//
|
|
278
|
-
// silently a miss —
|
|
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
|
|