@isomorph.ai/app-sdk 1.5.3 → 1.6.1
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 +18 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +14 -3
- 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
|
@@ -95,3 +95,21 @@ same way: its refresh ends at the identity GET, so that answer is the identity
|
|
|
95
95
|
and no second GET is sent. When the refresh fails, `identity.current()`
|
|
96
96
|
answers `null` and listeners hear `null`, as before. On the page's own origin
|
|
97
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> {
|
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;
|
|
@@ -354,6 +355,12 @@ export function createClient(options = {}) {
|
|
|
354
355
|
for (const listener of authListeners)
|
|
355
356
|
listener(user);
|
|
356
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
|
+
};
|
|
357
364
|
const transport = new Transport(options, () => notifyIdentity(null));
|
|
358
365
|
const socketFactory = options.websocket ?? ((url) => defaultSocketFactory(url, options.identity));
|
|
359
366
|
const refreshIdentity = () => {
|
|
@@ -361,7 +368,8 @@ export function createClient(options = {}) {
|
|
|
361
368
|
return refreshInFlight;
|
|
362
369
|
refreshesStarted += 1;
|
|
363
370
|
refreshInFlight = transport.json(IDENTITY_PATH)
|
|
364
|
-
.then((
|
|
371
|
+
.then((answer) => {
|
|
372
|
+
const user = answer === null ? null : withGroups(answer);
|
|
365
373
|
// A user answered by the API host's context refresh was announced by
|
|
366
374
|
// that refresh already (it is the same object): once is enough.
|
|
367
375
|
if (user === null || user !== currentUser)
|
|
@@ -412,7 +420,7 @@ export function createClient(options = {}) {
|
|
|
412
420
|
const user = (await response.json())?.data;
|
|
413
421
|
if (typeof user?.id !== 'string')
|
|
414
422
|
return false;
|
|
415
|
-
notifyIdentity(user, true);
|
|
423
|
+
notifyIdentity(withGroups(user), true);
|
|
416
424
|
return user;
|
|
417
425
|
})
|
|
418
426
|
.catch(() => false)
|
|
@@ -665,8 +673,11 @@ export function createClient(options = {}) {
|
|
|
665
673
|
method: 'POST', body: JSON.stringify({ connection, operation, resource, ...(input !== undefined ? { input } : {}), ...(idempotencyKey !== undefined ? { idempotencyKey } : {}), ...(operation === 'slack.message.post' && mode !== undefined ? { mode } : {}) }),
|
|
666
674
|
});
|
|
667
675
|
},
|
|
676
|
+
// In a browser the consent returns the person to this page (without its
|
|
677
|
+
// fragment); governance accepts it only as a registered page of this app
|
|
678
|
+
// (ADR 0030). Outside a browser there is no page, and none is sent.
|
|
668
679
|
connect: (connection) => transport.json('/_harbour/integrations/connect', {
|
|
669
|
-
method: 'POST', body: JSON.stringify({ connection }),
|
|
680
|
+
method: 'POST', body: JSON.stringify({ connection, ...(typeof location !== 'undefined' && typeof location.href === 'string' ? { returnUrl: location.href.split('#')[0] } : {}) }),
|
|
670
681
|
}),
|
|
671
682
|
disconnect: (connection) => transport.json('/_harbour/integrations/connect', {
|
|
672
683
|
method: 'DELETE', body: JSON.stringify({ connection }),
|
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
|
+
}
|