@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 +9 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +58 -3
- package/package.json +1 -1
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
|
-
|
|
176
|
-
|
|
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;
|