@isomorph.ai/app-sdk 2.7.0 → 2.8.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
@@ -392,6 +392,30 @@ await isomorph.actions.invokePublic('signup', { email });
392
392
 
393
393
  (2.6.0)
394
394
 
395
+ ## Calling another app as the signed-in person
396
+
397
+ An action calls another app of the company as the person who started it, with no credential:
398
+
399
+ ```ts
400
+ // actions/my-account.ts in the Candidate Portal
401
+ export default async function myAccount(_input: unknown) {
402
+ return isomorph.apps.invoke('shadowfax-ats', 'portal-account', { version: 2, operation: 'me' });
403
+ }
404
+ ```
405
+
406
+ The other app (named by its address, as `appApiUrl` takes it) lists this one in its `.isomorph/app.json`:
407
+ `"callers": [{ "app": "candidate-portal", "actions": ["portal-account", "portal-*"] }]`. A pattern never reaches a
408
+ trusted action; name a trusted action exactly. Its action sees the person in `ctx.user` (role `member`) and this app
409
+ in `ctx.caller = { app, environment }` (`app` is the calling app's id; `local` under `isomorph dev` and
410
+ `isomorph check`, where `app` is the calling address). `ctx.caller` is absent on its own pages, service and MCP calls.
411
+ The call goes through this app's own gateway with the workload token and the person's context; a refusal is an
412
+ `IsomorphError` with the gateway's category (`AUTH_REQUIRED`, `FORBIDDEN` when not listed, `NOT_FOUND`,
413
+ `RATE_LIMITED`). It works only from an action a signed-in person started: a job keeps using a service credential.
414
+ A journey of the called app plays the caller with a POST to `/_harbour/api/<action>` carrying
415
+ `X-Harbour-Call-Host: <caller address>` and one of the check's identity tokens as the bearer.
416
+
417
+ (2.8.0; data plane ADR 2026-11-07)
418
+
395
419
  ## Browser read tools
396
420
 
397
421
  Install optional peers `ai` and `playwright-core@1.58.2`. Locally run `npx playwright-core install chromium --only-shell`; V2 packages Chromium in the existing app image. An existing system executable can be supplied as executablePath.
@@ -588,6 +612,14 @@ and no second GET is sent. When the refresh fails, `identity.current()`
588
612
  answers `null` and listeners hear `null`, as before. On the page's own origin
589
613
  the identity GET is never renewed (it is the renewal). (1.5.3)
590
614
 
615
+ When the renewal cannot restore the sign-in — the refresh failed, or the
616
+ resend was refused again — the session has ended (a platform operator's
617
+ session never renews; any session ends at its lifetime). That refusal's
618
+ message is `Your sign-in has ended. Reload the page to sign in again.`, which
619
+ you can show as is; its `category` (`AUTH_REQUIRED`), `status` (401),
620
+ `requestId` and `details` are unchanged, so branch on `category`. A 401 your
621
+ own action or a connected service gave keeps its own message. (2.7.1)
622
+
591
623
  ## External OTP users
592
624
 
593
625
  Use `user.id` for ownership and database row rules. External users can sign in with a phone number and have no email. `user.name`, `user.phone`, `user.email` and the `emailVerified`/`phoneVerified` flags describe their profile; an optional contact is not verified automatically. `user.directory` identifies `company` or `external`. Database transactions also expose `current_setting('harbour.directory', true)` when a row rule needs that distinction.
package/dist/index.d.ts CHANGED
@@ -1953,6 +1953,10 @@ export interface ActionIdentity {
1953
1953
  trusted: boolean;
1954
1954
  transactionId?: string;
1955
1955
  baseUrl?: string;
1956
+ /** The request id the action runs under, which a call to another app carries on. */
1957
+ requestId?: string;
1958
+ /** Brings ISOMORPH_WORKLOAD_TOKEN up to date (a serverless runtime's session is renewed on demand). */
1959
+ workloadToken?: () => Promise<void>;
1956
1960
  }
1957
1961
  interface IdentityRenewal {
1958
1962
  renewals(): number;
@@ -2084,6 +2088,19 @@ export declare function createClient(options?: ClientOptions): {
2084
2088
  */
2085
2089
  invokePublic: <T = unknown>(name: string, input: unknown) => Promise<T>;
2086
2090
  };
2091
+ apps: {
2092
+ /**
2093
+ * Call another app's action as the person who started this one: from an
2094
+ * action's server code only, with no credential. The other app is named
2095
+ * by its address name (what `appApiUrl` takes) and must list this app
2096
+ * among its `callers` for the action in `.isomorph/app.json`; its action
2097
+ * sees that person in `ctx.user` and this app in `ctx.caller`. A job has
2098
+ * no signed-in person and calls with a service credential instead.
2099
+ */
2100
+ invoke: <T = unknown, P = string>(address: string, action: string, input: unknown, options?: {
2101
+ onProgress?: (step: P) => void;
2102
+ }) => Promise<T>;
2103
+ };
2087
2104
  jobs: {
2088
2105
  /** Select the owner's folder for a declared configurable file trigger. */
2089
2106
  configureTrigger: (name: string, configuration: {
package/dist/index.js CHANGED
@@ -183,6 +183,18 @@ function renewableOnUnauthorized(path) {
183
183
  return false;
184
184
  return path.startsWith('/_harbour/') && path !== IDENTITY_PATH;
185
185
  }
186
+ // A browser call still refused after the renewal could not restore the
187
+ // identity (the API-host refresh failed, or the resend was refused again)
188
+ // means the person's sign-in has ended: a platform operator's session never
189
+ // renews (governance ADR 0178), and any session ends at its lifetime. The
190
+ // gateway's wire message is for engineers, and apps showed it verbatim to
191
+ // people mid-task (escplan, 2026-11-07), so that refusal reads as a plain
192
+ // instruction instead; category, status, requestId and details are kept.
193
+ // Only the identity refusal is reworded — the gateway's own sentence, or a
194
+ // 401 with no Isomorph error at all (an edge refusal). A 401 an action or a
195
+ // connected service gave keeps its own words.
196
+ const IDENTITY_REFUSAL = 'signed Isomorph identity is required';
197
+ const SIGN_IN_ENDED = 'Your sign-in has ended. Reload the page to sign in again.';
186
198
  class Transport {
187
199
  onUnauthorized;
188
200
  baseUrl;
@@ -207,7 +219,7 @@ class Transport {
207
219
  json(path, init = {}, onProgress) {
208
220
  return this.request(path, init, renewableOnUnauthorized(path) || (this.separateApiHost && path === IDENTITY_PATH), onProgress);
209
221
  }
210
- async request(path, init, renewOnUnauthorized, onProgress) {
222
+ async request(path, init, renewOnUnauthorized, onProgress, renewalTried = false) {
211
223
  const sentAt = this.renewal?.renewals() ?? 0;
212
224
  const action = currentAction();
213
225
  // (headers with an undefined value are removed so a multipart body can
@@ -245,13 +257,17 @@ class Transport {
245
257
  if (renewed && path === IDENTITY_PATH)
246
258
  return renewed;
247
259
  if (renewed !== false)
248
- return this.request(path, init, false, onProgress);
260
+ return this.request(path, init, false, onProgress, true);
261
+ renewalTried = true;
249
262
  }
250
263
  // Not before the resend: a page listening to identity.onChange would
251
264
  // otherwise see the user flicker to null while the edge was healing.
252
265
  this.onUnauthorized?.();
253
266
  }
254
- throw new IsomorphError(body?.error?.category ?? (response.status === 401 ? 'AUTH_REQUIRED' : response.status === 403 ? 'FORBIDDEN' : 'INTERNAL'), body?.error?.message ?? `Isomorph request failed with ${response.status}`, body?.requestId ?? response.headers.get('x-request-id') ?? undefined, body?.error?.details, response.status);
267
+ const category = body?.error?.category ?? (response.status === 401 ? 'AUTH_REQUIRED' : response.status === 403 ? 'FORBIDDEN' : 'INTERNAL');
268
+ const signInEnded = renewalTried && response.status === 401 && category === 'AUTH_REQUIRED'
269
+ && (body?.error?.message === undefined || body.error.message === IDENTITY_REFUSAL);
270
+ throw new IsomorphError(category, signInEnded ? SIGN_IN_ENDED : body?.error?.message ?? `Isomorph request failed with ${response.status}`, body?.requestId ?? response.headers.get('x-request-id') ?? undefined, body?.error?.details, response.status);
255
271
  }
256
272
  return (body && Object.prototype.hasOwnProperty.call(body, 'data') ? body.data : body);
257
273
  }
@@ -856,6 +872,41 @@ export function createClient(options = {}) {
856
872
  return body.data;
857
873
  },
858
874
  },
875
+ apps: {
876
+ /**
877
+ * Call another app's action as the person who started this one: from an
878
+ * action's server code only, with no credential. The other app is named
879
+ * by its address name (what `appApiUrl` takes) and must list this app
880
+ * among its `callers` for the action in `.isomorph/app.json`; its action
881
+ * sees that person in `ctx.user` and this app in `ctx.caller`. A job has
882
+ * no signed-in person and calls with a service credential instead.
883
+ */
884
+ invoke: async (address, action, input, options = {}) => {
885
+ if (typeof location !== 'undefined')
886
+ throw new IsomorphError('VALIDATION_FAILED', 'isomorph.apps.invoke runs in an action\'s server code, not in the browser.', undefined, undefined, 400);
887
+ const current = currentAction();
888
+ if (!current?.context)
889
+ throw new IsomorphError('AUTH_REQUIRED', 'isomorph.apps.invoke needs an action a signed-in person started; a job uses a service credential.', undefined, undefined, 401);
890
+ const environment = process.env;
891
+ const gateway = environment.ISOMORPH_GATEWAY_URL || environment.HARBOUR_GATEWAY_URL;
892
+ if (!gateway)
893
+ throw new IsomorphError('UNAVAILABLE', 'This app has no gateway to call another app through (ISOMORPH_GATEWAY_URL is not set).', undefined, undefined, 503);
894
+ const host = new URL(appApiUrl(address)).host;
895
+ await current.workloadToken?.();
896
+ if (!environment.ISOMORPH_WORKLOAD_TOKEN)
897
+ throw new IsomorphError('AUTH_REQUIRED', 'The action has no workload token to call another app with.', undefined, undefined, 401);
898
+ return new Transport({ baseUrl: gateway, fetch: transport.fetch }).json(`/_harbour/api/${encodeURIComponent(action)}`, {
899
+ method: 'POST',
900
+ body: JSON.stringify(input ?? {}),
901
+ headers: {
902
+ authorization: `Bearer ${environment.ISOMORPH_WORKLOAD_TOKEN}`,
903
+ 'x-harbour-call-host': host,
904
+ 'x-harbour-tx': undefined,
905
+ ...(current.requestId ? { 'x-request-id': current.requestId } : {}),
906
+ },
907
+ }, options.onProgress);
908
+ },
909
+ },
859
910
  jobs: {
860
911
  /** Select the owner's folder for a declared configurable file trigger. */
861
912
  configureTrigger: (name, configuration) => transport.json('/_harbour/integrations/trigger-config', { method: 'POST', body: JSON.stringify({ name, configuration }) }),
package/dist/runtime.d.ts CHANGED
@@ -32,6 +32,18 @@ export interface ActionContext {
32
32
  * the outer one.
33
33
  */
34
34
  transaction<T>(fn: () => Promise<T>): Promise<T>;
35
+ /**
36
+ * Set when another app of the company called this action as the person
37
+ * signed into it (`isomorph.apps.invoke`), as the gateway verified: that
38
+ * app's id and the environment the call came from (`local` under
39
+ * `isomorph dev` and `isomorph check`, where `app` is the calling app's
40
+ * address name). Absent on this app's own pages, service and MCP calls.
41
+ */
42
+ caller?: ActionCaller;
43
+ }
44
+ export interface ActionCaller {
45
+ app: string;
46
+ environment: 'preview' | 'production' | 'local';
35
47
  }
36
48
  export interface RuntimeInventory {
37
49
  actions: string[];
package/dist/runtime.js CHANGED
@@ -122,6 +122,23 @@ const DATA_PATH_RETRY_MS = 3_000;
122
122
  const PROGRESS_MEDIA_TYPE = 'application/x-ndjson';
123
123
  const MAX_PROGRESS_EVENTS = 256;
124
124
  const MAX_PROGRESS_LINE_BYTES = 1 << 10;
125
+ const CALLER_ENVIRONMENTS = ['preview', 'production', 'local'];
126
+ /** The calling app the gateway verified (`X-Harbour-Verified-Caller-*`), when another app called. */
127
+ function verifiedCaller(request) {
128
+ const app = header(request, 'X-Harbour-Verified-Caller-App');
129
+ const environment = header(request, 'X-Harbour-Verified-Caller-Environment');
130
+ return app && CALLER_ENVIRONMENTS.includes(environment) ? { app, environment: environment } : undefined;
131
+ }
132
+ /** `.isomorph/app.json` `callers` as written, for the local gateway to expand (`isomorph dev`); [] when there are none. */
133
+ async function declaredCallers(root) {
134
+ try {
135
+ const callers = JSON.parse(await readFile(join(root, '.isomorph', 'app.json'), 'utf8')).callers;
136
+ return Array.isArray(callers) ? callers : [];
137
+ }
138
+ catch {
139
+ return [];
140
+ }
141
+ }
125
142
  /** Every `actions/<name>.ts` and `jobs/<name>.ts` the app carries, by logical name. */
126
143
  export async function discover(root) {
127
144
  const list = async (dir) => {
@@ -795,7 +812,8 @@ export class Runtime {
795
812
  response.writeHead(200, { 'content-type': PROGRESS_MEDIA_TYPE, ...(requestId ? { 'x-request-id': requestId } : {}) });
796
813
  response.write(line);
797
814
  };
798
- const context = { computerView: header(request, 'X-Harbour-Computer-View') === '1', signal: controller.signal, user, tenant, app, requestId: requestId ?? '', progress, transaction: (fn) => this.transaction(name, fn) };
815
+ const caller = verifiedCaller(request);
816
+ const context = { computerView: header(request, 'X-Harbour-Computer-View') === '1', signal: controller.signal, user, tenant, app, requestId: requestId ?? '', progress, transaction: (fn) => this.transaction(name, fn), ...(caller ? { caller } : {}) };
799
817
  const raw = await readBody(request);
800
818
  if (raw === undefined)
801
819
  return refuse(response, 413, 'VALIDATION_FAILED', 'the request body is larger than 1 MiB', requestId);
@@ -849,7 +867,7 @@ export class Runtime {
849
867
  await this.prepareAI();
850
868
  const handler = await loadHandler(this.options.root, ACTIONS_DIR, name);
851
869
  const trusted = this.inventory.trusted.includes(name);
852
- const identity = { context: header(request, 'X-Harbour-Identity-Context'), trusted, baseUrl: trusted ? process.env.ISOMORPH_TRUSTED_GATEWAY_URL || undefined : undefined };
870
+ const identity = { context: header(request, 'X-Harbour-Identity-Context'), trusted, baseUrl: trusted ? process.env.ISOMORPH_TRUSTED_GATEWAY_URL || undefined : undefined, requestId, workloadToken: () => this.refreshToken() };
853
871
  const result = await actionIdentity.run(identity, () => handler(input, context));
854
872
  return answer(response, 200, { data: result === undefined ? null : result }, requestId);
855
873
  }
@@ -922,7 +940,9 @@ export class Runtime {
922
940
  case PING_ACTION: {
923
941
  // `hooks` is what the local gateway's hooks entrance serves under `isomorph dev` (data plane ADR 0112).
924
942
  const hooks = this.inventory.jobs.flatMap((job) => job.trigger?.type === 'webhook' ? [{ job: job.name, provider: job.trigger.provider }] : []);
925
- const answer = { ok: true, mode: this.options.mode, actions: this.inventory.actions, trusted: this.inventory.trusted, exposed: this.inventory.exposed, public: this.inventory.public, jobs: this.inventory.jobs.map((job) => job.name), hooks };
943
+ // `callers` is what the local gateway admits another app's call by under `isomorph dev` (data plane ADR 2026-11-07).
944
+ const callers = await declaredCallers(this.options.root);
945
+ const answer = { ok: true, mode: this.options.mode, actions: this.inventory.actions, trusted: this.inventory.trusted, exposed: this.inventory.exposed, public: this.inventory.public, jobs: this.inventory.jobs.map((job) => job.name), hooks, ...(callers.length ? { callers } : {}) };
926
946
  return this.options.mode === 'gate' ? { ...answer, loadFailures: await this.loadFailures() } : answer;
927
947
  }
928
948
  case ENQUEUE_ACTION: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "2.7.0",
3
+ "version": "2.8.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",