@isomorph.ai/app-sdk 1.5.1 → 1.5.3

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,19 @@ 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.
91
+
92
+ On a separate API host the identity GET itself (`identity.current()`, and the
93
+ background refresh every 60 s that drives `identity.onChange`) is renewed the
94
+ same way: its refresh ends at the identity GET, so that answer is the identity
95
+ and no second GET is sent. When the refresh fails, `identity.current()`
96
+ answers `null` and listeners hear `null`, as before. On the page's own origin
97
+ the identity GET is never renewed (it is the renewal). (1.5.3)
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 | false | IsomorphUser>;
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
@@ -128,9 +133,11 @@ const IDENTITY_PATH = '/_harbour/identity/current';
128
133
  // database: the handler never ran, so the retry is the first and only write.
129
134
  // The body is a JSON string, FormData or Blob — all replayable as-is.
130
135
  //
131
- // The identity GET itself is never renewed (it IS the renewal), and under Node
132
- // the runner's identity is a static token no GET can renew, so a refusal there
133
- // surfaces at once.
136
+ // The identity GET itself is never renewed on the page-relative path (it IS
137
+ // the renewal), and under Node the runner's identity is a static token no GET
138
+ // can renew, so a refusal there surfaces at once. On a separate API host the
139
+ // renewal is the context refresh instead, so the identity GET is renewed too
140
+ // (Transport.json, ADR 0027).
134
141
  function renewableOnUnauthorized(path) {
135
142
  if (typeof location === 'undefined')
136
143
  return false;
@@ -140,11 +147,15 @@ class Transport {
140
147
  onUnauthorized;
141
148
  baseUrl;
142
149
  fetch;
150
+ // The base URL is the page's separate API host (the shim's global), not
151
+ // the page origin: renewal goes through that host's context refresh.
152
+ separateApiHost;
143
153
  identity;
144
154
  renewal;
145
155
  constructor(options, onUnauthorized) {
146
156
  this.onUnauthorized = onUnauthorized;
147
157
  this.baseUrl = (options.baseUrl ?? applicationBaseUrl()).replace(/\/$/, '');
158
+ this.separateApiHost = isSeparateApiHost(this.baseUrl);
148
159
  // Captured here, at createClient() time, not when this module is
149
160
  // evaluated: a kit page's credential shim (a plain <script> in <head>)
150
161
  // wraps window.fetch before any module script runs, so the fetch bound
@@ -154,7 +165,7 @@ class Transport {
154
165
  this.identity = options.identity;
155
166
  }
156
167
  json(path, init = {}) {
157
- return this.request(path, init, renewableOnUnauthorized(path));
168
+ return this.request(path, init, renewableOnUnauthorized(path) || (this.separateApiHost && path === IDENTITY_PATH));
158
169
  }
159
170
  async request(path, init, renewOnUnauthorized) {
160
171
  const sentAt = this.renewal?.renewals() ?? 0;
@@ -171,9 +182,16 @@ class Transport {
171
182
  if (renewOnUnauthorized && this.renewal) {
172
183
  // One renewal, one resend, whatever the renewal answered: a resend
173
184
  // 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);
185
+ // definitive refusal below, with no second renewal. The one
186
+ // exception is an API-host refresh that failed: the original 401
187
+ // surfaces, and the page's shim takes over with its login hop.
188
+ const renewed = await this.renewal.renew(sentAt).catch(() => undefined);
189
+ // The API host's refresh is followed to the identity GET, so its
190
+ // answer IS the identity: the GET is not sent a second time.
191
+ if (renewed && path === IDENTITY_PATH)
192
+ return renewed;
193
+ if (renewed !== false)
194
+ return this.request(path, init, false);
177
195
  }
178
196
  // Not before the resend: a page listening to identity.onChange would
179
197
  // otherwise see the user flicker to null while the edge was healing.
@@ -209,6 +227,14 @@ function applicationBaseUrl() {
209
227
  const match = /^\/p\/([a-z0-9-]+\.[a-z0-9-]+)(?:\/|$)/.exec(location.pathname);
210
228
  return match ? `/p/${match[1]}` : '';
211
229
  }
230
+ // True in a browser whose base URL is the shim's API origin and that origin is
231
+ // not the page's own; a page-relative or explicit same-origin base is not.
232
+ function isSeparateApiHost(baseUrl) {
233
+ if (typeof location === 'undefined')
234
+ return false;
235
+ const apiOrigin = globalThis.__isomorphApiOrigin;
236
+ return typeof apiOrigin === 'string' && isHttpOrigin(apiOrigin) && baseUrl === apiOrigin && apiOrigin !== location.origin;
237
+ }
212
238
  function isHttpOrigin(value) {
213
239
  try {
214
240
  const url = new URL(value);
@@ -336,7 +362,10 @@ export function createClient(options = {}) {
336
362
  refreshesStarted += 1;
337
363
  refreshInFlight = transport.json(IDENTITY_PATH)
338
364
  .then((user) => {
339
- notifyIdentity(user, true);
365
+ // A user answered by the API host's context refresh was announced by
366
+ // that refresh already (it is the same object): once is enough.
367
+ if (user === null || user !== currentUser)
368
+ notifyIdentity(user, true);
340
369
  return user;
341
370
  })
342
371
  .catch((error) => {
@@ -362,6 +391,43 @@ export function createClient(options = {}) {
362
391
  await refreshIdentity();
363
392
  },
364
393
  };
394
+ // On a separate API host the same sharing rule holds, but the renewal is
395
+ // the host's context refresh, credentialed and followed to the identity
396
+ // GET. It succeeds only on a 200 carrying the signed-in user, and answers
397
+ // that user; a network or CORS failure (no gateway session: governance
398
+ // redirects to the IdP), a non-200 or an HTML page answers false. The
399
+ // identity GET itself is renewed this way too (ADR 0027), so the periodic
400
+ // refresh behind identity.onChange survives a lapsed context.
401
+ if (transport.separateApiHost) {
402
+ let contextRefreshes = 0;
403
+ let contextRefreshInFlight;
404
+ const refreshContext = () => {
405
+ if (contextRefreshInFlight)
406
+ return contextRefreshInFlight;
407
+ contextRefreshes += 1;
408
+ contextRefreshInFlight = transport.fetch(`${transport.baseUrl}${CONTEXT_REFRESH_PATH}`, { credentials: 'include' })
409
+ .then(async (response) => {
410
+ if (response.status !== 200)
411
+ return false;
412
+ const user = (await response.json())?.data;
413
+ if (typeof user?.id !== 'string')
414
+ return false;
415
+ notifyIdentity(user, true);
416
+ return user;
417
+ })
418
+ .catch(() => false)
419
+ .finally(() => { contextRefreshInFlight = undefined; });
420
+ return contextRefreshInFlight;
421
+ };
422
+ transport.renewal = {
423
+ renewals: () => contextRefreshes,
424
+ renew: async (sentAt) => {
425
+ if (contextRefreshInFlight && contextRefreshes <= sentAt)
426
+ await contextRefreshInFlight;
427
+ return refreshContext();
428
+ },
429
+ };
430
+ }
365
431
  const scheduleIdentityRefresh = () => {
366
432
  if (refreshTimer !== undefined || authListeners.size === 0 || identityRefreshMs <= 0)
367
433
  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.3",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",