@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 +16 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +74 -8
- package/package.json +1 -1
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
|
|
132
|
-
// the runner's identity is a static token no GET
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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;
|