@isomorph.ai/app-sdk 1.5.2 → 1.6.0
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 +25 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +30 -11
- package/dist/runtime.d.ts +2 -5
- package/dist/runtime.js +7 -1
- package/dist/user-groups.d.ts +2 -0
- package/dist/user-groups.js +16 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -88,3 +88,28 @@ the host mints a fresh identity cookie from its sign-in session and redirects
|
|
|
88
88
|
to the identity GET. When that refresh does not end in a 200 with the user —
|
|
89
89
|
the session is gone, the network failed — the original 401 is surfaced
|
|
90
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)
|
|
98
|
+
|
|
99
|
+
## The signed-in user's groups
|
|
100
|
+
|
|
101
|
+
`user.groups` — the company groups your identity provider sent at sign-in; use
|
|
102
|
+
for in-app roles. App access itself is enforced before your code runs.
|
|
103
|
+
|
|
104
|
+
It is on `identity.current()`, `identity.onChange` and an action's `ctx.user`,
|
|
105
|
+
and it is always an array (`[]` when the identity provider sent none):
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// actions/approve.ts
|
|
109
|
+
export default async function approve(input: { id: string }, ctx: { user: { groups: string[] } }) {
|
|
110
|
+
if (!ctx.user.groups.includes('Finance')) throw new Error('Only Finance can approve.');
|
|
111
|
+
// ...
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
(1.6.0)
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,12 @@ export declare class IsomorphError extends Error {
|
|
|
12
12
|
export interface IsomorphUser {
|
|
13
13
|
id: string;
|
|
14
14
|
email: string;
|
|
15
|
+
/**
|
|
16
|
+
* The company groups your identity provider sent at sign-in, `[]` when it
|
|
17
|
+
* sent none. Use them for in-app roles: who may open the app at all is
|
|
18
|
+
* enforced before your code runs.
|
|
19
|
+
*/
|
|
20
|
+
groups: string[];
|
|
15
21
|
appUser?: unknown;
|
|
16
22
|
}
|
|
17
23
|
export interface IsomorphResult<T> {
|
|
@@ -250,7 +256,7 @@ interface Query {
|
|
|
250
256
|
}
|
|
251
257
|
interface IdentityRenewal {
|
|
252
258
|
renewals(): number;
|
|
253
|
-
renew(sentAt: number): Promise<void |
|
|
259
|
+
renew(sentAt: number): Promise<void | false | IsomorphUser>;
|
|
254
260
|
}
|
|
255
261
|
declare class Transport {
|
|
256
262
|
private readonly onUnauthorized?;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { validateIntegrationExecuteInput } from './integration-execute-input-rules.js';
|
|
2
|
+
import { userGroups } from './user-groups.js';
|
|
2
3
|
export class IsomorphError extends Error {
|
|
3
4
|
category;
|
|
4
5
|
requestId;
|
|
@@ -133,9 +134,11 @@ const CONTEXT_REFRESH_PATH = `/__harbour/context/refresh?return_to=${encodeURICo
|
|
|
133
134
|
// database: the handler never ran, so the retry is the first and only write.
|
|
134
135
|
// The body is a JSON string, FormData or Blob — all replayable as-is.
|
|
135
136
|
//
|
|
136
|
-
// The identity GET itself is never renewed
|
|
137
|
-
// the runner's identity is a static token no GET
|
|
138
|
-
// surfaces at once.
|
|
137
|
+
// The identity GET itself is never renewed on the page-relative path (it IS
|
|
138
|
+
// the renewal), and under Node the runner's identity is a static token no GET
|
|
139
|
+
// can renew, so a refusal there surfaces at once. On a separate API host the
|
|
140
|
+
// renewal is the context refresh instead, so the identity GET is renewed too
|
|
141
|
+
// (Transport.json, ADR 0027).
|
|
139
142
|
function renewableOnUnauthorized(path) {
|
|
140
143
|
if (typeof location === 'undefined')
|
|
141
144
|
return false;
|
|
@@ -163,7 +166,7 @@ class Transport {
|
|
|
163
166
|
this.identity = options.identity;
|
|
164
167
|
}
|
|
165
168
|
json(path, init = {}) {
|
|
166
|
-
return this.request(path, init, renewableOnUnauthorized(path));
|
|
169
|
+
return this.request(path, init, renewableOnUnauthorized(path) || (this.separateApiHost && path === IDENTITY_PATH));
|
|
167
170
|
}
|
|
168
171
|
async request(path, init, renewOnUnauthorized) {
|
|
169
172
|
const sentAt = this.renewal?.renewals() ?? 0;
|
|
@@ -184,6 +187,10 @@ class Transport {
|
|
|
184
187
|
// exception is an API-host refresh that failed: the original 401
|
|
185
188
|
// surfaces, and the page's shim takes over with its login hop.
|
|
186
189
|
const renewed = await this.renewal.renew(sentAt).catch(() => undefined);
|
|
190
|
+
// The API host's refresh is followed to the identity GET, so its
|
|
191
|
+
// answer IS the identity: the GET is not sent a second time.
|
|
192
|
+
if (renewed && path === IDENTITY_PATH)
|
|
193
|
+
return renewed;
|
|
187
194
|
if (renewed !== false)
|
|
188
195
|
return this.request(path, init, false);
|
|
189
196
|
}
|
|
@@ -348,6 +355,12 @@ export function createClient(options = {}) {
|
|
|
348
355
|
for (const listener of authListeners)
|
|
349
356
|
listener(user);
|
|
350
357
|
};
|
|
358
|
+
// The gateway's answer becomes an IsomorphUser in place: the same object
|
|
359
|
+
// must reach every caller (the API-host refresh below relies on it).
|
|
360
|
+
const withGroups = (user) => {
|
|
361
|
+
user.groups = userGroups(user.groups);
|
|
362
|
+
return user;
|
|
363
|
+
};
|
|
351
364
|
const transport = new Transport(options, () => notifyIdentity(null));
|
|
352
365
|
const socketFactory = options.websocket ?? ((url) => defaultSocketFactory(url, options.identity));
|
|
353
366
|
const refreshIdentity = () => {
|
|
@@ -355,8 +368,12 @@ export function createClient(options = {}) {
|
|
|
355
368
|
return refreshInFlight;
|
|
356
369
|
refreshesStarted += 1;
|
|
357
370
|
refreshInFlight = transport.json(IDENTITY_PATH)
|
|
358
|
-
.then((
|
|
359
|
-
|
|
371
|
+
.then((answer) => {
|
|
372
|
+
const user = answer === null ? null : withGroups(answer);
|
|
373
|
+
// A user answered by the API host's context refresh was announced by
|
|
374
|
+
// that refresh already (it is the same object): once is enough.
|
|
375
|
+
if (user === null || user !== currentUser)
|
|
376
|
+
notifyIdentity(user, true);
|
|
360
377
|
return user;
|
|
361
378
|
})
|
|
362
379
|
.catch((error) => {
|
|
@@ -384,9 +401,11 @@ export function createClient(options = {}) {
|
|
|
384
401
|
};
|
|
385
402
|
// On a separate API host the same sharing rule holds, but the renewal is
|
|
386
403
|
// the host's context refresh, credentialed and followed to the identity
|
|
387
|
-
// GET. It succeeds only on a 200 carrying the signed-in user
|
|
388
|
-
// CORS failure (no gateway session: governance
|
|
389
|
-
// non-200 or an HTML page answers false.
|
|
404
|
+
// GET. It succeeds only on a 200 carrying the signed-in user, and answers
|
|
405
|
+
// that user; a network or CORS failure (no gateway session: governance
|
|
406
|
+
// redirects to the IdP), a non-200 or an HTML page answers false. The
|
|
407
|
+
// identity GET itself is renewed this way too (ADR 0027), so the periodic
|
|
408
|
+
// refresh behind identity.onChange survives a lapsed context.
|
|
390
409
|
if (transport.separateApiHost) {
|
|
391
410
|
let contextRefreshes = 0;
|
|
392
411
|
let contextRefreshInFlight;
|
|
@@ -401,8 +420,8 @@ export function createClient(options = {}) {
|
|
|
401
420
|
const user = (await response.json())?.data;
|
|
402
421
|
if (typeof user?.id !== 'string')
|
|
403
422
|
return false;
|
|
404
|
-
notifyIdentity(user, true);
|
|
405
|
-
return
|
|
423
|
+
notifyIdentity(withGroups(user), true);
|
|
424
|
+
return user;
|
|
406
425
|
})
|
|
407
426
|
.catch(() => false)
|
|
408
427
|
.finally(() => { contextRefreshInFlight = undefined; });
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
2
|
-
import { type IsomorphClient, type JobRun } from './index.js';
|
|
2
|
+
import { type IsomorphClient, type IsomorphUser, type JobRun } from './index.js';
|
|
3
3
|
export declare const ACTIONS_DIR = "actions";
|
|
4
4
|
export declare const JOBS_DIR = "jobs";
|
|
5
5
|
export declare const RUNS_TABLE = "isomorph_job_runs";
|
|
@@ -10,10 +10,7 @@ export declare const GET_RUN_ACTION = "isomorph-jobs-get";
|
|
|
10
10
|
export declare const CANCEL_RUN_ACTION = "isomorph-jobs-cancel";
|
|
11
11
|
export interface ActionContext {
|
|
12
12
|
signal: AbortSignal;
|
|
13
|
-
user:
|
|
14
|
-
id: string;
|
|
15
|
-
email: string;
|
|
16
|
-
};
|
|
13
|
+
user: Pick<IsomorphUser, 'id' | 'email' | 'groups'>;
|
|
17
14
|
tenant: string;
|
|
18
15
|
app: string;
|
|
19
16
|
requestId: string;
|
package/dist/runtime.js
CHANGED
|
@@ -56,6 +56,7 @@ import { register } from 'node:module';
|
|
|
56
56
|
import { join, resolve } from 'node:path';
|
|
57
57
|
import { pathToFileURL } from 'node:url';
|
|
58
58
|
import { createClient, IsomorphError } from './index.js';
|
|
59
|
+
import { userGroupsFromHeader } from './user-groups.js';
|
|
59
60
|
const actionIdentity = new AsyncLocalStorage();
|
|
60
61
|
const identityKey = Symbol.for('isomorph.actionIdentity');
|
|
61
62
|
globalThis[identityKey] = () => actionIdentity.getStore();
|
|
@@ -495,7 +496,12 @@ export class Runtime {
|
|
|
495
496
|
const controller = new AbortController();
|
|
496
497
|
const abort = () => controller.abort();
|
|
497
498
|
response.once('close', abort);
|
|
498
|
-
const
|
|
499
|
+
const user = {
|
|
500
|
+
id: header(request, 'X-Harbour-Verified-User-ID'),
|
|
501
|
+
email: header(request, 'X-Harbour-Verified-User-Email'),
|
|
502
|
+
groups: userGroupsFromHeader(header(request, 'X-Harbour-Verified-User-Groups')),
|
|
503
|
+
};
|
|
504
|
+
const context = { signal: controller.signal, user, tenant, app, requestId: requestId ?? '' };
|
|
499
505
|
const raw = await readBody(request);
|
|
500
506
|
if (raw === undefined)
|
|
501
507
|
return refuse(response, 413, 'VALIDATION_FAILED', 'the request body is larger than 1 MiB', requestId);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The signed-in user's company groups, as the platform hands them over: a
|
|
2
|
+
// JSON array in the gateway's current-user answer (`groups`) and in the
|
|
3
|
+
// X-Harbour-Verified-User-Groups header on an action. Anything else — a
|
|
4
|
+
// missing or malformed value, a non-string or empty entry — reads as no
|
|
5
|
+
// group, so `user.groups` is always an array of names.
|
|
6
|
+
export function userGroups(value) {
|
|
7
|
+
return Array.isArray(value) ? value.filter((group) => typeof group === 'string' && group !== '') : [];
|
|
8
|
+
}
|
|
9
|
+
export function userGroupsFromHeader(raw) {
|
|
10
|
+
try {
|
|
11
|
+
return userGroups(JSON.parse(raw));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
}
|