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