@isomorph.ai/app-sdk 2.7.1 → 2.8.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 +26 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +35 -0
- package/dist/runtime.d.ts +12 -0
- package/dist/runtime.js +31 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -392,6 +392,32 @@ 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
|
+
The input may be up to 16 MiB (5 MiB when the called app runs on a serverless cell), as on the direct
|
|
417
|
+
integration route; the answer stays at most 4 MiB.
|
|
418
|
+
|
|
419
|
+
(2.8.0, input limits 2.8.1; data plane ADR 2026-11-07)
|
|
420
|
+
|
|
395
421
|
## Browser read tools
|
|
396
422
|
|
|
397
423
|
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.
|
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
|
@@ -872,6 +872,41 @@ export function createClient(options = {}) {
|
|
|
872
872
|
return body.data;
|
|
873
873
|
},
|
|
874
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
|
+
},
|
|
875
910
|
jobs: {
|
|
876
911
|
/** Select the owner's folder for a declared configurable file trigger. */
|
|
877
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
|
@@ -90,6 +90,9 @@ export const CANCEL_RUN_ACTION = 'isomorph-jobs-cancel';
|
|
|
90
90
|
export const LIST_RUNS_ACTION = 'isomorph-jobs-list';
|
|
91
91
|
const LOGICAL_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
92
92
|
const MAX_BODY_BYTES = 1 << 20;
|
|
93
|
+
// Another app's call (ctx.caller) carries what the gateway's direct route admits: 16 MiB to a cluster runtime, 5 MiB
|
|
94
|
+
// to a Lambda; the gateway holds every other entrance to 1 MiB.
|
|
95
|
+
const APP_CALL_MAX_BODY_BYTES = 16 << 20;
|
|
93
96
|
const LEASE_MS = 5 * 60_000;
|
|
94
97
|
const QUEUE_WORKERS = 2;
|
|
95
98
|
const QUEUE_POLL_MS = 2_000;
|
|
@@ -122,6 +125,23 @@ const DATA_PATH_RETRY_MS = 3_000;
|
|
|
122
125
|
const PROGRESS_MEDIA_TYPE = 'application/x-ndjson';
|
|
123
126
|
const MAX_PROGRESS_EVENTS = 256;
|
|
124
127
|
const MAX_PROGRESS_LINE_BYTES = 1 << 10;
|
|
128
|
+
const CALLER_ENVIRONMENTS = ['preview', 'production', 'local'];
|
|
129
|
+
/** The calling app the gateway verified (`X-Harbour-Verified-Caller-*`), when another app called. */
|
|
130
|
+
function verifiedCaller(request) {
|
|
131
|
+
const app = header(request, 'X-Harbour-Verified-Caller-App');
|
|
132
|
+
const environment = header(request, 'X-Harbour-Verified-Caller-Environment');
|
|
133
|
+
return app && CALLER_ENVIRONMENTS.includes(environment) ? { app, environment: environment } : undefined;
|
|
134
|
+
}
|
|
135
|
+
/** `.isomorph/app.json` `callers` as written, for the local gateway to expand (`isomorph dev`); [] when there are none. */
|
|
136
|
+
async function declaredCallers(root) {
|
|
137
|
+
try {
|
|
138
|
+
const callers = JSON.parse(await readFile(join(root, '.isomorph', 'app.json'), 'utf8')).callers;
|
|
139
|
+
return Array.isArray(callers) ? callers : [];
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
125
145
|
/** Every `actions/<name>.ts` and `jobs/<name>.ts` the app carries, by logical name. */
|
|
126
146
|
export async function discover(root) {
|
|
127
147
|
const list = async (dir) => {
|
|
@@ -208,12 +228,12 @@ function refusal(error) {
|
|
|
208
228
|
return { status: REFUSAL_STATUS[code], category: code, message: error.message };
|
|
209
229
|
return undefined;
|
|
210
230
|
}
|
|
211
|
-
async function readBody(request) {
|
|
231
|
+
async function readBody(request, limit = MAX_BODY_BYTES) {
|
|
212
232
|
const chunks = [];
|
|
213
233
|
let size = 0;
|
|
214
234
|
for await (const chunk of request) {
|
|
215
235
|
size += chunk.length;
|
|
216
|
-
if (size >
|
|
236
|
+
if (size > limit)
|
|
217
237
|
return undefined;
|
|
218
238
|
chunks.push(chunk);
|
|
219
239
|
}
|
|
@@ -795,10 +815,12 @@ export class Runtime {
|
|
|
795
815
|
response.writeHead(200, { 'content-type': PROGRESS_MEDIA_TYPE, ...(requestId ? { 'x-request-id': requestId } : {}) });
|
|
796
816
|
response.write(line);
|
|
797
817
|
};
|
|
798
|
-
const
|
|
799
|
-
const
|
|
818
|
+
const caller = verifiedCaller(request);
|
|
819
|
+
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 } : {}) };
|
|
820
|
+
const limit = caller ? APP_CALL_MAX_BODY_BYTES : MAX_BODY_BYTES;
|
|
821
|
+
const raw = await readBody(request, limit);
|
|
800
822
|
if (raw === undefined)
|
|
801
|
-
return refuse(response, 413, 'VALIDATION_FAILED',
|
|
823
|
+
return refuse(response, 413, 'VALIDATION_FAILED', `the request body is larger than ${limit >> 20} MiB`, requestId);
|
|
802
824
|
if (name === 'isomorph-mail-arrival' && header(request, 'X-Harbour-Verified-Hook')) {
|
|
803
825
|
// The gateway's hooks route: the provider's raw body and headers, the gateway's verified
|
|
804
826
|
// headers beside them. The answer is what the provider reads, so it is not enveloped.
|
|
@@ -849,7 +871,7 @@ export class Runtime {
|
|
|
849
871
|
await this.prepareAI();
|
|
850
872
|
const handler = await loadHandler(this.options.root, ACTIONS_DIR, name);
|
|
851
873
|
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 };
|
|
874
|
+
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
875
|
const result = await actionIdentity.run(identity, () => handler(input, context));
|
|
854
876
|
return answer(response, 200, { data: result === undefined ? null : result }, requestId);
|
|
855
877
|
}
|
|
@@ -922,7 +944,9 @@ export class Runtime {
|
|
|
922
944
|
case PING_ACTION: {
|
|
923
945
|
// `hooks` is what the local gateway's hooks entrance serves under `isomorph dev` (data plane ADR 0112).
|
|
924
946
|
const hooks = this.inventory.jobs.flatMap((job) => job.trigger?.type === 'webhook' ? [{ job: job.name, provider: job.trigger.provider }] : []);
|
|
925
|
-
|
|
947
|
+
// `callers` is what the local gateway admits another app's call by under `isomorph dev` (data plane ADR 2026-11-07).
|
|
948
|
+
const callers = await declaredCallers(this.options.root);
|
|
949
|
+
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
950
|
return this.options.mode === 'gate' ? { ...answer, loadFailures: await this.loadFailures() } : answer;
|
|
927
951
|
}
|
|
928
952
|
case ENQUEUE_ACTION: {
|