@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 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((user) => {
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 context = { signal: controller.signal, user: { id: header(request, 'X-Harbour-Verified-User-ID'), email: header(request, 'X-Harbour-Verified-User-Email') }, tenant, app, requestId: requestId ?? '' };
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,2 @@
1
+ export declare function userGroups(value: unknown): string[];
2
+ export declare function userGroupsFromHeader(raw: string): string[];
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "1.5.3",
3
+ "version": "1.6.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",