@isomorph.ai/app-sdk 1.5.0 → 1.5.2

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/README.md CHANGED
@@ -61,3 +61,30 @@ App code declares exact HTTPS pages and named click/fill selectors reviewed for
61
61
  `isomorph dev` uses canned server-model fixtures. `dev --real` supplies an approved development connection to the action process. Hosted actions resolve their exact ACTIVE version key. The current V2 PLAN is published after LIVE; before key activation, an AI action fails and later actions retry. LIVE alone is not AI acceptance.
62
62
 
63
63
  Requires compatible SDK/CLI/toolkit publication, gateway protocol fixes, governance connection routes and V2 packaging/version wiring. No deployment is claimed here. Real model/file authorization and hosted Chromium sandbox checks remain release acceptance. Search, company skills, integration/browser writes, video, audio, STT, TTS and realtime audio are deferred.
64
+
65
+ ## Where the browser client sends its calls
66
+
67
+ `createClient()` with no `baseUrl` picks its base URL like this:
68
+
69
+ - In Node (the retained-check runner, the kit runtime), `ISOMORPH_GATEWAY_URL`.
70
+ - In a browser, `window.__isomorphApiOrigin` when it is an `http(s)` origin
71
+ string such as `https://fourier-app-x.api.preview.beta.apps.isomorph.ai`.
72
+ A deployed kit page carries the platform's credential shim, a `<script>` in
73
+ `<head>` that sets this global to the app's own API host and wraps
74
+ `window.fetch` so calls there carry the sign-in cookie; the realtime socket
75
+ and `files.getPublicUrl` follow the same host. Nothing in the app sets it.
76
+ - Otherwise the page-relative default (`''`, or `/p/<org>.<app>` on the
77
+ shared edge): a local `isomorph run` and a page without the shim are
78
+ unchanged.
79
+
80
+ A value that is not an `http(s)` origin — a path, a trailing slash, another
81
+ scheme, a non-string — is ignored. An explicit `baseUrl` always wins.
82
+
83
+ A refused call (401) is renewed once and resent once. On the page's own
84
+ origin the renewal is the identity GET, which the platform edge heals. On a
85
+ separate API host (the global above, when it is not the page origin) it is a
86
+ credentialed `GET <api>/__harbour/context/refresh?return_to=%2F_harbour%2Fidentity%2Fcurrent`:
87
+ the host mints a fresh identity cookie from its sign-in session and redirects
88
+ to the identity GET. When that refresh does not end in a 200 with the user —
89
+ the session is gone, the network failed — the original 401 is surfaced
90
+ without a resend, and the page's sign-in handling takes over.
package/dist/index.d.ts CHANGED
@@ -250,12 +250,13 @@ interface Query {
250
250
  }
251
251
  interface IdentityRenewal {
252
252
  renewals(): number;
253
- renew(sentAt: number): Promise<void>;
253
+ renew(sentAt: number): Promise<void | boolean>;
254
254
  }
255
255
  declare class Transport {
256
256
  private readonly onUnauthorized?;
257
257
  readonly baseUrl: string;
258
258
  readonly fetch: Fetch;
259
+ readonly separateApiHost: boolean;
259
260
  private readonly identity;
260
261
  renewal: IdentityRenewal | undefined;
261
262
  constructor(options: ClientOptions, onUnauthorized?: (() => void) | undefined);
package/dist/index.js CHANGED
@@ -102,6 +102,11 @@ function defaultSocketFactory(url, identity) {
102
102
  return new Socket(url, { headers });
103
103
  }
104
104
  const IDENTITY_PATH = '/_harbour/identity/current';
105
+ // On a kit app's separate API host nothing heals a lapsed context on the way
106
+ // in (Envoy answers 401), so the renewal asks the host's login handler to mint
107
+ // a fresh cookie from its own gateway session and redirect back to the
108
+ // identity GET; fetch follows that 302 (ADR 0025).
109
+ const CONTEXT_REFRESH_PATH = `/__harbour/context/refresh?return_to=${encodeURIComponent(IDENTITY_PATH)}`;
105
110
  // The platform edge attaches the signed identity context to every request
106
111
  // from a fresh context cookie, but when that cookie is absent or stale it can
107
112
  // only heal it on a GET/HEAD: healing is a redirect through
@@ -140,11 +145,20 @@ class Transport {
140
145
  onUnauthorized;
141
146
  baseUrl;
142
147
  fetch;
148
+ // The base URL is the page's separate API host (the shim's global), not
149
+ // the page origin: renewal goes through that host's context refresh.
150
+ separateApiHost;
143
151
  identity;
144
152
  renewal;
145
153
  constructor(options, onUnauthorized) {
146
154
  this.onUnauthorized = onUnauthorized;
147
155
  this.baseUrl = (options.baseUrl ?? applicationBaseUrl()).replace(/\/$/, '');
156
+ this.separateApiHost = isSeparateApiHost(this.baseUrl);
157
+ // Captured here, at createClient() time, not when this module is
158
+ // evaluated: a kit page's credential shim (a plain <script> in <head>)
159
+ // wraps window.fetch before any module script runs, so the fetch bound
160
+ // below is the wrapped one, and a call to the API origin leaves with
161
+ // credentials: 'include' although the transport asks for 'same-origin'.
148
162
  this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
149
163
  this.identity = options.identity;
150
164
  }
@@ -166,9 +180,12 @@ class Transport {
166
180
  if (renewOnUnauthorized && this.renewal) {
167
181
  // One renewal, one resend, whatever the renewal answered: a resend
168
182
  // after a signed-out answer costs one request and yields the
169
- // definitive refusal below, with no second renewal.
170
- await this.renewal.renew(sentAt).catch(() => undefined);
171
- return this.request(path, init, false);
183
+ // definitive refusal below, with no second renewal. The one
184
+ // exception is an API-host refresh that failed: the original 401
185
+ // surfaces, and the page's shim takes over with its login hop.
186
+ const renewed = await this.renewal.renew(sentAt).catch(() => undefined);
187
+ if (renewed !== false)
188
+ return this.request(path, init, false);
172
189
  }
173
190
  // Not before the resend: a page listening to identity.onChange would
174
191
  // otherwise see the user flicker to null while the edge was healing.
@@ -192,9 +209,35 @@ function applicationBaseUrl() {
192
209
  const runtime = globalThis.process;
193
210
  return (runtime?.env?.ISOMORPH_GATEWAY_URL ?? '').replace(/\/$/, '');
194
211
  }
212
+ // A kit page served from CloudFront talks to its own API host, not to the
213
+ // page origin: the transform injects the credential shim into the page and
214
+ // the shim publishes that host as window.__isomorphApiOrigin (an origin,
215
+ // https://<org>-<app>.api.<zone>, no path). Anything that is not an http(s)
216
+ // origin is ignored, so a page without the shim (isomorph run, a git app on
217
+ // the shared edge) keeps the page-relative base URL below.
218
+ const apiOrigin = globalThis.__isomorphApiOrigin;
219
+ if (typeof apiOrigin === 'string' && isHttpOrigin(apiOrigin))
220
+ return apiOrigin;
195
221
  const match = /^\/p\/([a-z0-9-]+\.[a-z0-9-]+)(?:\/|$)/.exec(location.pathname);
196
222
  return match ? `/p/${match[1]}` : '';
197
223
  }
224
+ // True in a browser whose base URL is the shim's API origin and that origin is
225
+ // not the page's own; a page-relative or explicit same-origin base is not.
226
+ function isSeparateApiHost(baseUrl) {
227
+ if (typeof location === 'undefined')
228
+ return false;
229
+ const apiOrigin = globalThis.__isomorphApiOrigin;
230
+ return typeof apiOrigin === 'string' && isHttpOrigin(apiOrigin) && baseUrl === apiOrigin && apiOrigin !== location.origin;
231
+ }
232
+ function isHttpOrigin(value) {
233
+ try {
234
+ const url = new URL(value);
235
+ return (url.protocol === 'https:' || url.protocol === 'http:') && url.origin === value;
236
+ }
237
+ catch {
238
+ return false;
239
+ }
240
+ }
198
241
  // Result is what awaiting the builder resolves to: T[] for a list query, T
199
242
  // after .single(), T | null after .maybeSingle(). The gateway applies the
200
243
  // cardinality; the type follows it so a reader of these declarations sees the
@@ -339,6 +382,41 @@ export function createClient(options = {}) {
339
382
  await refreshIdentity();
340
383
  },
341
384
  };
385
+ // On a separate API host the same sharing rule holds, but the renewal is
386
+ // the host's context refresh, credentialed and followed to the identity
387
+ // GET. It succeeds only on a 200 carrying the signed-in user; a network or
388
+ // CORS failure (no gateway session: governance redirects to the IdP), a
389
+ // non-200 or an HTML page answers false.
390
+ if (transport.separateApiHost) {
391
+ let contextRefreshes = 0;
392
+ let contextRefreshInFlight;
393
+ const refreshContext = () => {
394
+ if (contextRefreshInFlight)
395
+ return contextRefreshInFlight;
396
+ contextRefreshes += 1;
397
+ contextRefreshInFlight = transport.fetch(`${transport.baseUrl}${CONTEXT_REFRESH_PATH}`, { credentials: 'include' })
398
+ .then(async (response) => {
399
+ if (response.status !== 200)
400
+ return false;
401
+ const user = (await response.json())?.data;
402
+ if (typeof user?.id !== 'string')
403
+ return false;
404
+ notifyIdentity(user, true);
405
+ return true;
406
+ })
407
+ .catch(() => false)
408
+ .finally(() => { contextRefreshInFlight = undefined; });
409
+ return contextRefreshInFlight;
410
+ };
411
+ transport.renewal = {
412
+ renewals: () => contextRefreshes,
413
+ renew: async (sentAt) => {
414
+ if (contextRefreshInFlight && contextRefreshes <= sentAt)
415
+ await contextRefreshInFlight;
416
+ return refreshContext();
417
+ },
418
+ };
419
+ }
342
420
  const scheduleIdentityRefresh = () => {
343
421
  if (refreshTimer !== undefined || authListeners.size === 0 || identityRefreshMs <= 0)
344
422
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",