@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 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 | boolean>;
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 (it IS the renewal), and under Node
137
- // the runner's identity is a static token no GET can renew, so a refusal there
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((user) => {
359
- notifyIdentity(user, true);
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; a network or
388
- // CORS failure (no gateway session: governance redirects to the IdP), a
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 true;
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 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.2",
3
+ "version": "1.6.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",