@guuey/agent-client 0.1.0 → 0.2.1

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.
@@ -12,7 +12,7 @@ import type {
12
12
  InvokeTransport,
13
13
  ThreadIdStore,
14
14
  } from "./types";
15
- import { fetchThreadHistory } from "./history";
15
+ import { fetchThreadHistory, HistoryUnauthorizedError } from "./history";
16
16
 
17
17
  /**
18
18
  * Thrown when the pod returns a non-2xx status on `/agent/invoke` (before any
@@ -61,16 +61,66 @@ export function webGenerateId(): string {
61
61
  }
62
62
 
63
63
  /**
64
- * Web SSE transport. When `accessToken` is present the pod identifies the
65
- * caller by their verified Cognito access token (the same identity the
66
- * history read plane uses, so persisted threads round-trip on reload).
67
- * Otherwise it falls back to `credentials: "include"`, which round-trips the
68
- * HttpOnly `guuey_guest` cookie the pod mints for anonymous browser callers.
64
+ * Header carrying a caller-owned anonymous guest secret. A LOCAL MIRROR of the
65
+ * two server-side constants the pod's `GUEST_HEADER_NAME`
66
+ * (`backend/services/nocode-runtime/src/identity.ts`) and the read plane's
67
+ * `GUEST_HEADER` (`backend/amplify/functions/publicApi/identity.ts`) because
68
+ * this is a published npm package and cannot take a `@guuey-private` dep (same
69
+ * arrangement as `@guuey/host`'s mirrored fs-contract constants). The string is
70
+ * a wire contract: both planes already advertise it in
71
+ * `Access-Control-Allow-Headers`, so changing it is a breaking protocol change,
72
+ * not a rename.
73
+ */
74
+ const GUEST_HEADER = "x-guuey-guest";
75
+
76
+ /**
77
+ * A well-formed guest secret: exactly 32 bytes as 64 LOWERCASE hex chars —
78
+ * the shape `crypto.getRandomValues` + hex-encoding mints.
79
+ *
80
+ * Deliberately stricter than the server's `/^[a-f0-9]{64}$/i` (pod
81
+ * `identity.ts`, publicApi `identity.ts`): both sides lowercase before
82
+ * hashing, so an uppercase secret would in fact be accepted, but the only
83
+ * supported mint path emits lowercase and a non-canonical value means the
84
+ * caller's storage is not what this adapter expects. Anything that fails is
85
+ * IGNORED — the request falls through to cookie mode rather than sending a
86
+ * secret the two identity planes might key differently.
87
+ */
88
+ const GUEST_SECRET_RE = /^[0-9a-f]{64}$/;
89
+
90
+ /**
91
+ * Narrow a caller-supplied guest secret to a value that is safe to put on the
92
+ * wire, or `null`. The single gate for the header: every write of
93
+ * {@link GUEST_HEADER} in this module goes through it, so a malformed secret
94
+ * can never reach a request. The value is never logged (here or anywhere on
95
+ * this path) — it IS the anonymous identity, so a leak is an impersonation.
96
+ */
97
+ function sendableGuestSecret(secret: string | null | undefined): string | null {
98
+ return typeof secret === "string" && GUEST_SECRET_RE.test(secret) ? secret : null;
99
+ }
100
+
101
+ /**
102
+ * Web SSE transport. Exactly ONE identity carrier per request, in order:
103
+ *
104
+ * 1. `accessToken` → `Authorization: Bearer` — the pod identifies the caller
105
+ * by their verified access token (the same identity the history read
106
+ * plane uses, so persisted threads round-trip on reload).
107
+ * 2. a well-formed `guestSecret` → `x-guuey-guest` — the caller owns and
108
+ * persists its own anonymous secret. The path for hosts with no usable
109
+ * cookie jar: React-Native, and the embedded widget, whose third-party
110
+ * iframe cannot rely on the pod's cookie surviving browser partitioning.
111
+ * The pod never mints a cookie for a header client.
112
+ * 3. neither → `credentials: "include"`, which round-trips the HttpOnly
113
+ * `guuey_guest` cookie the pod mints for anonymous browser callers.
114
+ *
115
+ * Never two at once: a bearer wins over a guest secret, and a request that
116
+ * carries either header does NOT also send cookie credentials.
117
+ *
69
118
  * Reads the body via `ReadableStream.getReader()` (browser).
70
119
  */
71
120
  export async function* fetchStreamTransport(
72
121
  req: InvokeRequest,
73
122
  accessToken?: string | null,
123
+ guestSecret?: string | null,
74
124
  ): AsyncGenerator<string> {
75
125
  const headers: Record<string, string> = {
76
126
  "Content-Type": "application/json",
@@ -82,8 +132,11 @@ export async function* fetchStreamTransport(
82
132
  headers,
83
133
  body: JSON.stringify(req.body),
84
134
  };
135
+ const guest = sendableGuestSecret(guestSecret);
85
136
  if (accessToken) {
86
137
  headers.Authorization = `Bearer ${accessToken}`;
138
+ } else if (guest) {
139
+ headers[GUEST_HEADER] = guest;
87
140
  } else {
88
141
  init.credentials = "include";
89
142
  }
@@ -124,28 +177,81 @@ export interface CreateWebAdaptersOptions {
124
177
  * Resolve the caller's Cognito access token (fresh), or `null` when signed
125
178
  * out. When a token is present the chat transport AND the history read
126
179
  * authenticate as that user, so a reload restores the transcript. Without
127
- * a token the transport falls back to the guest cookie and history is
128
- * skipped the read plane can't identify a cookie-only browser caller
129
- * (it reads the `x-guuey-guest` header or a Bearer, not the HttpOnly
130
- * guest cookie), so there is no identity to replay.
180
+ * a token, identity falls to {@link getGuestSecret} (if supplied) and then
181
+ * to the guest cookie.
182
+ *
183
+ * Called with `{ forceRefresh: true }` exactly once: when the history read
184
+ * gets a 401 on a token this resolver already returned (a token cached
185
+ * before the mount-time history read fired can be stale by the time it
186
+ * runs — the same window the send path's own 401-retry closes). A resolver
187
+ * that caches (Amplify's `fetchAuthSession` does, and so does the widget's
188
+ * `createHostTokenProvider`) MUST bypass that cache for a forced call and
189
+ * obtain a genuinely fresh token — returning the SAME stale value would
190
+ * make the retry indistinguishable from not retrying at all. A resolver
191
+ * with nothing fresher to offer returns `null`, and the read surfaces the
192
+ * ORIGINAL 401 rather than replaying the value that just failed.
193
+ */
194
+ getAccessToken?: (opts?: { forceRefresh?: boolean }) => Promise<string | null>;
195
+ /**
196
+ * Resolve the caller's own persisted anonymous guest secret (64 lowercase
197
+ * hex chars), or `null` when there is none. Supply this on hosts whose
198
+ * cookie jar can't carry the pod's HttpOnly `guuey_guest` — notably the
199
+ * embedded widget, a third-party iframe whose cookies browsers partition
200
+ * or block outright.
201
+ *
202
+ * With a secret, BOTH the chat transport and the history read send
203
+ * `x-guuey-guest`, so an anonymous transcript replays on reload the same
204
+ * way a signed-in one does — the read plane identifies a guest by that
205
+ * header (it cannot see the HttpOnly cookie, which is why a cookie-only
206
+ * caller still gets no history).
207
+ *
208
+ * Called once per request, so a rotated secret takes effect immediately.
209
+ * A value that isn't 64 lowercase hex is ignored (never sent) and the
210
+ * request falls through to cookie mode.
211
+ *
212
+ * **Supply at most ONE identity resolver per mode.** Anonymous hosts pass
213
+ * this one; identified hosts pass {@link getAccessToken} and surface a token
214
+ * failure rather than continuing. Passing BOTH is a hazard, not a fallback
215
+ * chain: `getAccessToken` resolving `null` is indistinguishable here from
216
+ * "signed out on purpose", so a merely *expired or unavailable* token
217
+ * silently downgrades the caller to the anonymous identity. The request then
218
+ * SUCCEEDS — the pod accepts anonymous invokes unconditionally — but the
219
+ * turns land in a different thread (the pod forks on an owner mismatch
220
+ * rather than appending), unreachable from the identified session, which
221
+ * gets its own transcript back minus those turns on the next good load. A
222
+ * 401-then-re-request-token retry loop is exactly this window.
223
+ *
224
+ * MUST be synchronous and MUST NOT throw: a throw propagates and fails the
225
+ * invoke. This is a real hazard for the widget, not a formality —
226
+ * `localStorage` access raises `SecurityError` in a third-party iframe with
227
+ * storage blocked (Safari's default for embedded content), which is normal
228
+ * operation here. A host reading storage owns that handling and MUST return
229
+ * `null` on a blocked read, the way {@link localStorageThreadStore} does for
230
+ * the threadId; `null` degrades to cookie mode, whereas a throw takes the
231
+ * chat down. Deliberately NOT caught at this seam: catching a host-supplied
232
+ * callback would also swallow ordinary host bugs into a silent anonymous
233
+ * downgrade — the same failure this docblock warns about above.
131
234
  */
132
- getAccessToken?: () => Promise<string | null>;
235
+ getGuestSecret?: () => string | null;
133
236
  }
134
237
 
135
238
  /**
136
239
  * Build the web host-adapter bundle for {@link useAgentInvoke}. Pass an
137
- * access-token resolver (and the read-plane base) to authenticate the chat
138
- * transport and enable transcript restore on reload; omit them for an
139
- * anonymous, history-less bundle.
240
+ * access-token resolver and/or a guest-secret resolver (plus the read-plane
241
+ * base) to give the chat transport an identity the read plane can also see,
242
+ * which is what enables transcript restore on reload; omit both for a
243
+ * cookie-only, history-less bundle.
140
244
  */
141
245
  export function createWebAdapters(
142
246
  opts: CreateWebAdaptersOptions = {},
143
247
  ): AgentInvokeAdapters {
144
- const { apiBaseUrl, getAccessToken } = opts;
248
+ const { apiBaseUrl, getAccessToken, getGuestSecret } = opts;
145
249
 
146
250
  const transport: InvokeTransport = async function* (req) {
147
251
  const token = getAccessToken ? await getAccessToken() : null;
148
- yield* fetchStreamTransport(req, token);
252
+ // Both candidates go to the transport; it owns the precedence (and the
253
+ // never-two-carriers rule) so there is exactly one place that decides.
254
+ yield* fetchStreamTransport(req, token, getGuestSecret ? getGuestSecret() : null);
149
255
  };
150
256
 
151
257
  const adapters: AgentInvokeAdapters = {
@@ -154,18 +260,57 @@ export function createWebAdapters(
154
260
  transport,
155
261
  };
156
262
 
157
- if (apiBaseUrl && getAccessToken) {
263
+ // History needs an identity the READ plane can resolve: a Bearer or the
264
+ // `x-guuey-guest` header. Either resolver can supply one, so either one
265
+ // installs the adapter; a cookie-only caller is unidentifiable there and
266
+ // gets no adapter at all.
267
+ if (apiBaseUrl && (getAccessToken || getGuestSecret)) {
158
268
  adapters.history = {
159
269
  load: async (threadId) => {
160
- const token = await getAccessToken();
270
+ // Same precedence, and the same one-carrier rule, as the transport:
271
+ // a bearer wins over the guest header, and the two never combine.
272
+ if (getAccessToken) {
273
+ const token = await getAccessToken();
274
+ if (token) {
275
+ try {
276
+ return await fetchThreadHistory({
277
+ baseUrl: apiBaseUrl,
278
+ threadId,
279
+ includeCards: true,
280
+ requestInit: { headers: { Authorization: `Bearer ${token}` } },
281
+ });
282
+ } catch (err) {
283
+ if (!(err instanceof HistoryUnauthorizedError)) throw err;
284
+ // The one retry the send path already gets on a 401
285
+ // (`withIdentifiedToken`): this read runs from a mount effect,
286
+ // before the send path has asked anyone for anything, so a
287
+ // token cached earlier can be the exact stale value that just
288
+ // failed. `forceRefresh` is the signal that asks past whatever
289
+ // cache the resolver keeps instead of returning that same dead
290
+ // value.
291
+ const fresh = await getAccessToken({ forceRefresh: true });
292
+ if (!fresh) throw err; // nothing fresher to retry with — the ORIGINAL 401 is the honest cause
293
+ return fetchThreadHistory({
294
+ baseUrl: apiBaseUrl,
295
+ threadId,
296
+ includeCards: true,
297
+ requestInit: { headers: { Authorization: `Bearer ${fresh}` } },
298
+ });
299
+ }
300
+ }
301
+ }
302
+ const guest = sendableGuestSecret(getGuestSecret?.());
303
+ if (guest) {
304
+ return fetchThreadHistory({
305
+ baseUrl: apiBaseUrl,
306
+ threadId,
307
+ includeCards: true,
308
+ requestInit: { headers: { [GUEST_HEADER]: guest } },
309
+ });
310
+ }
161
311
  // No readable identity → leave the chat empty (skip) rather than
162
312
  // `gone`, which would clear the persisted threadId.
163
- if (!token) return { messages: [] };
164
- return fetchThreadHistory({
165
- baseUrl: apiBaseUrl,
166
- threadId,
167
- requestInit: { headers: { Authorization: `Bearer ${token}` } },
168
- });
313
+ return { messages: [] };
169
314
  },
170
315
  };
171
316
  }