@isomorph.ai/app-sdk 2.2.0 → 2.3.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/dist/aws.js CHANGED
@@ -67,7 +67,11 @@ export async function runtimeCredentials(env = process.env, { fetch: fetcher = f
67
67
  throw new Error('the runtime has no AWS credentials (AWS_ACCESS_KEY_ID, or AWS_ROLE_ARN with AWS_WEB_IDENTITY_TOKEN_FILE)');
68
68
  if (webIdentity?.roleArn === roleArn && webIdentity.expiresAt - now() > 5 * 60_000)
69
69
  return webIdentity.credentials;
70
- const region = env.AWS_REGION || env.AWS_DEFAULT_REGION || 'us-east-1';
70
+ // STS is called in the runtime's own region, which the runtime always has (Lambda and the kit chart set
71
+ // it). No default: a cell outside us-east-1 under a region-pinning policy must never fall back there.
72
+ const region = env.AWS_REGION || env.AWS_DEFAULT_REGION;
73
+ if (!region)
74
+ throw new Error('the runtime has no AWS region (AWS_REGION) for its STS exchange');
71
75
  // The token is read on each exchange: the kubelet rotates the file.
72
76
  const body = new URLSearchParams({ Action: 'AssumeRoleWithWebIdentity', Version: '2011-06-15', RoleArn: roleArn, RoleSessionName: env.AWS_ROLE_SESSION_NAME || 'isomorph-runtime', WebIdentityToken: (await readFile(tokenFile, 'utf8')).trim() });
73
77
  const response = await fetcher(`https://sts.${region}.amazonaws.com/`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: body.toString(), signal: AbortSignal.timeout(10_000) });
@@ -10,6 +10,8 @@ export function computerUseApi({ env = process.env, fetch } = {}) {
10
10
  const base = env.ISOMORPH_COMPUTER_USE_URL?.replace(/\/+$/, '');
11
11
  if (!base)
12
12
  throw new Error('This app has no computer use API (ISOMORPH_COMPUTER_USE_URL is not set).');
13
+ // The computer use API is Harbour's own, in Harbour's region: this default names a Harbour-owned endpoint,
14
+ // not the cell's region, and stays until the call moves behind the app gateway (ADR 0078).
13
15
  const region = env.ISOMORPH_COMPUTER_USE_REGION || 'us-east-1';
14
16
  const transport = fetch ? { fetch } : {};
15
17
  const call = async (path, body) => signedJsonPost({ ...await runtimeCredentials(env, transport), region }, 'execute-api', `${base}${path}`, body, transport);
package/dist/index.d.ts CHANGED
@@ -36,6 +36,23 @@ export interface IsomorphUser {
36
36
  */
37
37
  role: IsomorphUserRole;
38
38
  appUser?: unknown;
39
+ /**
40
+ * Who is calling: a `'person'` signed in through the browser or an MCP
41
+ * client, or a `'service'` holding a credential the company's IT issued,
42
+ * whose `id` and `name` are the credential's. Absent means a person.
43
+ */
44
+ kind?: 'person' | 'service';
45
+ }
46
+ /**
47
+ * `export const expose = { description, input }` on an action, beside
48
+ * `export const trusted = true`: the action may be called from outside the
49
+ * app by a service credential or an MCP client. `description` is read by
50
+ * people and agents; `input` is a JSON Schema object literal the platform
51
+ * validates the request body against. Literal only.
52
+ */
53
+ export interface ActionExposure {
54
+ description: string;
55
+ input: Record<string, unknown>;
39
56
  }
40
57
  export interface IsomorphResult<T> {
41
58
  data: T;
package/dist/jobs.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ActionExposure } from './index.js';
1
2
  export declare const JOBS_DIR = "jobs";
2
3
  export interface EmailArrivalTrigger {
3
4
  type: 'email.arrived';
@@ -25,6 +26,8 @@ export interface JobDefinition {
25
26
  schedule?: string;
26
27
  trigger?: EventTrigger;
27
28
  }
29
+ /** The action's `export const expose = { description, input }`, read without importing it: undefined when the module has none, does not parse, or declares anything but a literal object of that shape. */
30
+ export declare function actionExposure(file: string): Promise<ActionExposure | undefined>;
28
31
  export declare function eventTrigger(value: unknown): EventTrigger;
29
32
  export interface JobContext {
30
33
  scheduledAt: string;
package/dist/jobs.js CHANGED
@@ -8,25 +8,55 @@ export const JOBS_DIR = 'jobs';
8
8
  const LOGICAL_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
9
9
  export const FILE_MIME_TYPES = ['application/pdf', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'image/png', 'image/jpeg'];
10
10
  export const providerFileId = (value) => typeof value === 'string' && /^[A-Za-z0-9!._,=-]{1,256}$/.test(value);
11
- function literalObject(node) {
11
+ function literalObject(node, what = 'trigger') {
12
12
  const value = node;
13
13
  if (value?.type === 'Literal')
14
14
  return value.value;
15
15
  if (value?.type === 'ArrayExpression')
16
- return value.elements.map(literalObject);
16
+ return value.elements.map(element => literalObject(element, what));
17
17
  if (value?.type !== 'ObjectExpression')
18
- throw new Error('trigger must be a literal object');
18
+ throw new Error(`${what} must be a literal object`);
19
19
  const result = {};
20
20
  for (const property of value.properties ?? []) {
21
21
  if (property.type !== 'Property' || property.computed || property.kind !== 'init' || property.method)
22
- throw new Error('trigger must be a literal object');
22
+ throw new Error(`${what} must be a literal object`);
23
23
  const key = property.key.name ?? property.key.value;
24
24
  if (typeof key !== 'string' || Object.hasOwn(result, key))
25
- throw new Error('trigger contains an invalid or repeated field');
26
- Object.defineProperty(result, key, { value: literalObject(property.value), enumerable: true });
25
+ throw new Error(`${what} contains an invalid or repeated field`);
26
+ Object.defineProperty(result, key, { value: literalObject(property.value, what), enumerable: true });
27
27
  }
28
28
  return result;
29
29
  }
30
+ const plainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
31
+ /** The action's `export const expose = { description, input }`, read without importing it: undefined when the module has none, does not parse, or declares anything but a literal object of that shape. */
32
+ export async function actionExposure(file) {
33
+ let statements;
34
+ try {
35
+ statements = parseModule(await readFile(file, 'utf8'));
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ for (const statement of statements) {
41
+ if (statement.type !== 'ExportNamedDeclaration' || statement.declaration?.type !== 'VariableDeclaration' || statement.declaration.kind !== 'const')
42
+ continue;
43
+ for (const declaration of statement.declaration.declarations) {
44
+ if (declaration.id.type !== 'Identifier' || declaration.id.name !== 'expose')
45
+ continue;
46
+ let value;
47
+ try {
48
+ value = literalObject(declaration.init, 'expose');
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ if (!plainObject(value) || Object.keys(value).some(key => key !== 'description' && key !== 'input') || typeof value.description !== 'string' || !plainObject(value.input))
54
+ return undefined;
55
+ return { description: value.description, input: value.input };
56
+ }
57
+ }
58
+ return undefined;
59
+ }
30
60
  export function eventTrigger(value) {
31
61
  if (value?.type === 'file.created') {
32
62
  const t = value;
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 IsomorphUser, type JobRun, type JobRunSummary } from './index.js';
2
+ import { type ActionExposure, type IsomorphClient, type IsomorphUser, type JobRun, type JobRunSummary } from './index.js';
3
3
  import { type JobDefinition } from './jobs.js';
4
4
  import { type LocalGatewayOptions } from './local-gateway.js';
5
5
  export { JOBS_DIR, type JobContext } from './jobs.js';
@@ -37,6 +37,8 @@ export interface RuntimeInventory {
37
37
  actions: string[];
38
38
  /** The actions whose module says `export const trusted = true`: they act as the app on behalf of the caller. */
39
39
  trusted: string[];
40
+ /** The actions whose module says `export const expose = { description, input }`, by name: what the kit gate records as exposed (ADR 0100). */
41
+ exposed: Record<string, ActionExposure>;
40
42
  jobs: JobDefinition[];
41
43
  }
42
44
  /** Every `actions/<name>.ts` and `jobs/<name>.ts` the app carries, by logical name. */
package/dist/runtime.js CHANGED
@@ -68,7 +68,7 @@ import { pathToFileURL } from 'node:url';
68
68
  import { createClient, gatewayRequest, IsomorphError } from './index.js';
69
69
  import { userGroupsFromHeader } from './user-groups.js';
70
70
  import { userRole } from './user-role.js';
71
- import { FILE_MIME_TYPES, providerFileId, discoverJobs, exportsTrue, invokeJob, loadHandler, JOBS_DIR } from './jobs.js';
71
+ import { FILE_MIME_TYPES, providerFileId, actionExposure, discoverJobs, exportsTrue, invokeJob, loadHandler, JOBS_DIR } from './jobs.js';
72
72
  import { LocalGateway } from './local-gateway.js';
73
73
  export { JOBS_DIR } from './jobs.js';
74
74
  const actionIdentity = new AsyncLocalStorage();
@@ -118,8 +118,10 @@ export async function discover(root) {
118
118
  };
119
119
  const actions = (await list(ACTIONS_DIR)).filter((name) => !name.startsWith(RESERVED_PREFIX));
120
120
  const trusted = await Promise.all(actions.map((name) => exportsTrue(join(root, ACTIONS_DIR, `${name}.ts`), 'trusted')));
121
+ const exposures = await Promise.all(actions.map((name) => actionExposure(join(root, ACTIONS_DIR, `${name}.ts`))));
122
+ const exposed = Object.fromEntries(actions.flatMap((name, index) => exposures[index] ? [[name, exposures[index]]] : []));
121
123
  const jobs = await discoverJobs(root);
122
- return { actions, trusted: actions.filter((_, index) => trusted[index]), jobs };
124
+ return { actions, trusted: actions.filter((_, index) => trusted[index]), exposed, jobs };
123
125
  }
124
126
  // ---- Cron ------------------------------------------------------------------
125
127
  /** One field of a five-field cron expression: numbers, `*`, lists, ranges and steps, no names. */
@@ -313,7 +315,7 @@ export class Runs {
313
315
  }
314
316
  export class Runtime {
315
317
  options;
316
- inventory = { actions: [], trusted: [], jobs: [] };
318
+ inventory = { actions: [], trusted: [], exposed: {}, jobs: [] };
317
319
  localGateway;
318
320
  running = new Set();
319
321
  timers = [];
@@ -683,8 +685,13 @@ export class Runtime {
683
685
  const abort = () => controller.abort();
684
686
  response.once('close', abort);
685
687
  const directory = header(request, 'X-Harbour-Verified-Directory');
688
+ const userId = header(request, 'X-Harbour-Verified-User-ID');
686
689
  const user = {
687
- id: header(request, 'X-Harbour-Verified-User-ID'),
690
+ id: userId,
691
+ // A service credential's call arrives with the credential as the verified user
692
+ // (`service:<clientId>`, ADR 0101); the action sees it as `kind: 'service'`, the
693
+ // shape `/_harbour/identity/current` already answers. Anything else is a person.
694
+ ...(userId.startsWith('service:') ? { kind: 'service' } : {}),
688
695
  email: header(request, 'X-Harbour-Verified-User-Email'),
689
696
  groups: userGroupsFromHeader(header(request, 'X-Harbour-Verified-User-Groups')),
690
697
  role: userRole(header(request, 'X-Harbour-Verified-User-Role')),
@@ -826,7 +833,7 @@ export class Runtime {
826
833
  const owner = context.user.id;
827
834
  switch (name) {
828
835
  case PING_ACTION: {
829
- const answer = { ok: true, mode: this.options.mode, actions: this.inventory.actions, trusted: this.inventory.trusted, jobs: this.inventory.jobs.map((job) => job.name) };
836
+ const answer = { ok: true, mode: this.options.mode, actions: this.inventory.actions, trusted: this.inventory.trusted, exposed: this.inventory.exposed, jobs: this.inventory.jobs.map((job) => job.name) };
830
837
  return this.options.mode === 'gate' ? { ...answer, loadFailures: await this.loadFailures() } : answer;
831
838
  }
832
839
  case ENQUEUE_ACTION: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",