@isomorph.ai/app-sdk 1.5.1 → 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
@@ -79,3 +79,12 @@ Requires compatible SDK/CLI/toolkit publication, gateway protocol fixes, governa
79
79
 
80
80
  A value that is not an `http(s)` origin — a path, a trailing slash, another
81
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,15 @@ 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);
148
157
  // Captured here, at createClient() time, not when this module is
149
158
  // evaluated: a kit page's credential shim (a plain <script> in <head>)
150
159
  // wraps window.fetch before any module script runs, so the fetch bound
@@ -171,9 +180,12 @@ class Transport {
171
180
  if (renewOnUnauthorized && this.renewal) {
172
181
  // One renewal, one resend, whatever the renewal answered: a resend
173
182
  // after a signed-out answer costs one request and yields the
174
- // definitive refusal below, with no second renewal.
175
- await this.renewal.renew(sentAt).catch(() => undefined);
176
- 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);
177
189
  }
178
190
  // Not before the resend: a page listening to identity.onChange would
179
191
  // otherwise see the user flicker to null while the edge was healing.
@@ -209,6 +221,14 @@ function applicationBaseUrl() {
209
221
  const match = /^\/p\/([a-z0-9-]+\.[a-z0-9-]+)(?:\/|$)/.exec(location.pathname);
210
222
  return match ? `/p/${match[1]}` : '';
211
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
+ }
212
232
  function isHttpOrigin(value) {
213
233
  try {
214
234
  const url = new URL(value);
@@ -362,6 +382,41 @@ export function createClient(options = {}) {
362
382
  await refreshIdentity();
363
383
  },
364
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
+ }
365
420
  const scheduleIdentityRefresh = () => {
366
421
  if (refreshTimer !== undefined || authListeners.size === 0 || identityRefreshMs <= 0)
367
422
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",