@isomorph.ai/app-sdk 2.3.1 → 2.5.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
@@ -396,7 +396,7 @@ const computerUse = createComputerUseTool({ runKey: ctx.runId ?? `my-job/${ctx.s
396
396
  // Add { computerUse } to generateText's tools. It returns { status: 'done' | 'failed', summary, finalUrl, downloads }.
397
397
  ```
398
398
 
399
- - The tool calls the task API at `ISOMORPH_COMPUTER_USE_URL` (region `ISOMORPH_COMPUTER_USE_REGION`, default `us-east-1`); Provision sets both when the cell has the API. Each request is signed with SigV4 (`execute-api`) as the runtime's own role: the Lambda environment's credentials, or on a cluster pod its ServiceAccount role through STS `AssumeRoleWithWebIdentity`.
399
+ - Hosted, the tool posts to its App Gateway (`ISOMORPH_GATEWAY_URL`, `/_harbour/computer-use/...`) with the workload token; the gateway and governance carry the call into Harbour's account (data plane ADR 0103). Only a runtime with no gateway URL calls the task API at `ISOMORPH_COMPUTER_USE_URL` directly (region `ISOMORPH_COMPUTER_USE_REGION`, default `us-east-1`), signed with SigV4 as its own role; Provision no longer sets those (ADR 0110).
400
400
  - It starts the task with `POST /tasks`, then checks `POST /tasks/check` every 5 s until the task is `done`, `failed` or `waiting_for_input`. Waiting returns immediately; end the app job rather than holding a worker while a person signs in.
401
401
  - It calls `progress(step count, note)` on every new step and at least once a minute, so the job's lease does not run out.
402
402
  - When `cancelled()` turns true it sends `POST /tasks/cancel` once and keeps checking until the task has `failed`.
@@ -1,5 +1,9 @@
1
- // Harbour's computer use API (CUA) as the runtime's own role, signed with
2
- // SigV4 (ADR 0078): what the computer use tool and the sign-in helpers share.
1
+ // Harbour's computer use API (CUA): what the computer use tool and the sign-in
2
+ // helpers share. A hosted job reaches it through its App Gateway as the app's
3
+ // workload (ADR 0103): the gateway and governance carry the call into
4
+ // Harbour's account, so no customer-account role signs Harbour's API. A
5
+ // runtime with no gateway (older deployments, ISOMORPH_COMPUTER_USE_URL)
6
+ // still signs the API with its own role (ADR 0078).
3
7
  import { runtimeCredentials, signedJsonPost } from './aws.js';
4
8
  import { IsomorphError } from './index.js';
5
9
  export function refused(path, status, body) {
@@ -7,14 +11,24 @@ export function refused(path, status, body) {
7
11
  return new Error(`The computer use API refused ${path} (${status}${typeof error === 'string' ? `: ${error}` : ''}).`);
8
12
  }
9
13
  export function computerUseApi({ env = process.env, fetch } = {}) {
10
- const base = env.ISOMORPH_COMPUTER_USE_URL?.replace(/\/+$/, '');
11
- if (!base)
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).
14
+ const gateway = env.ISOMORPH_GATEWAY_URL?.replace(/\/+$/, '');
15
+ const direct = env.ISOMORPH_COMPUTER_USE_URL?.replace(/\/+$/, '');
16
+ if (!gateway && !direct)
17
+ throw new Error('This app has no computer use API (ISOMORPH_GATEWAY_URL is not set).');
18
+ // The direct call names Harbour's own API in Harbour's region (ADR 0078); the gateway path has no region.
15
19
  const region = env.ISOMORPH_COMPUTER_USE_REGION || 'us-east-1';
16
20
  const transport = fetch ? { fetch } : {};
17
- const call = async (path, body) => signedJsonPost({ ...await runtimeCredentials(env, transport), region }, 'execute-api', `${base}${path}`, body, transport);
21
+ const fetcher = fetch ?? globalThis.fetch;
22
+ const call = gateway
23
+ ? async (path, body) => {
24
+ // The workload token is read on every call: the runtime refreshes it in place.
25
+ const token = env.ISOMORPH_WORKLOAD_TOKEN;
26
+ if (!token)
27
+ throw new Error('This job has no workload token for its computer use call.');
28
+ const response = await fetcher(`${gateway}/_harbour/computer-use${path}`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify(body) });
29
+ return { status: response.status, body: await response.json().catch(() => undefined) };
30
+ }
31
+ : async (path, body) => signedJsonPost({ ...await runtimeCredentials(env, transport), region }, 'execute-api', `${direct}${path}`, body, transport);
18
32
  return {
19
33
  call,
20
34
  /** A call the API must answer with 200; its body. */
package/dist/index.d.ts CHANGED
@@ -2138,3 +2138,4 @@ export interface FileArrivalInput {
2138
2138
  createdAt: string;
2139
2139
  observedAt: string;
2140
2140
  }
2141
+ export type { WebhookReceived, WebhookTrigger, WebhookProvider } from './jobs.js';
package/dist/jobs.d.ts CHANGED
@@ -20,7 +20,27 @@ export interface FileCreatedTrigger {
20
20
  driveId?: string;
21
21
  mimeTypes?: string[];
22
22
  }
23
- export type EventTrigger = EmailArrivalTrigger | FileCreatedTrigger;
23
+ export declare const WEBHOOK_PROVIDERS: readonly ["slack", "none"];
24
+ export type WebhookProvider = typeof WEBHOOK_PROVIDERS[number];
25
+ /**
26
+ * A job a third-party product triggers by posting to the app's hook URL. `provider` names the
27
+ * runtime's sender-verification adapter; `secret` is the NAME of the app secret (an env var in the
28
+ * runtime) holding that provider's signing secret, required when the provider signs (`slack`).
29
+ */
30
+ export interface WebhookTrigger {
31
+ type: 'webhook';
32
+ provider: WebhookProvider;
33
+ secret?: string;
34
+ }
35
+ /** What a webhook job's handler receives as `ctx.input`. */
36
+ export interface WebhookReceived {
37
+ type: 'webhook.received';
38
+ provider: WebhookProvider;
39
+ headers: Record<string, string>;
40
+ body: unknown;
41
+ receivedAt: string;
42
+ }
43
+ export type EventTrigger = EmailArrivalTrigger | FileCreatedTrigger | WebhookTrigger;
24
44
  export interface JobDefinition {
25
45
  name: string;
26
46
  schedule?: string;
package/dist/jobs.js CHANGED
@@ -8,6 +8,7 @@ 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
+ export const WEBHOOK_PROVIDERS = ['slack', 'none'];
11
12
  function literalObject(node, what = 'trigger') {
12
13
  const value = node;
13
14
  if (value?.type === 'Literal')
@@ -57,7 +58,17 @@ export async function actionExposure(file) {
57
58
  }
58
59
  return undefined;
59
60
  }
61
+ const SECRET_NAME = /^[A-Z][A-Z0-9_]{0,127}$/;
60
62
  export function eventTrigger(value) {
63
+ if (value?.type === 'webhook') {
64
+ const t = value;
65
+ if (Object.keys(t).some(k => !['type', 'provider', 'secret'].includes(k)) || !WEBHOOK_PROVIDERS.includes(t.provider)
66
+ || (t.secret !== undefined && (typeof t.secret !== 'string' || !SECRET_NAME.test(t.secret))))
67
+ throw new Error("webhook requires provider 'slack' or 'none' and an optional secret: the UPPER_CASE name of an app secret");
68
+ if (t.provider === 'slack' && !t.secret)
69
+ throw new Error("webhook provider 'slack' requires secret: the name of the app secret holding the Slack signing secret");
70
+ return t;
71
+ }
61
72
  if (value?.type === 'file.created') {
62
73
  const t = value;
63
74
  if (Object.keys(t).some(k => !['type', 'connection', 'resource', 'folderId', 'configurableFolder', 'driveId', 'mimeTypes'].includes(k))
@@ -174,7 +185,7 @@ export async function discoverJobs(root) {
174
185
  if (!statements.some(statement => statement.type === 'ExportDefaultDeclaration'))
175
186
  fail('export one default job handler.');
176
187
  if (schedule && trigger)
177
- fail('a job may declare a schedule or an email trigger, not both.');
188
+ fail('a job may declare a schedule or a trigger, not both.');
178
189
  jobs.push({ name, ...(schedule ? { schedule } : {}), ...(trigger ? { trigger } : {}) });
179
190
  }
180
191
  return jobs.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
package/dist/runtime.d.ts CHANGED
@@ -153,6 +153,19 @@ export declare class Runtime {
153
153
  * path never answered, or the handler threw; the log names which.
154
154
  */
155
155
  acceptEventArrival(value: unknown): Promise<void>;
156
+ /**
157
+ * A third-party product's delivery, forwarded by the gateway's hooks route with the job and
158
+ * provider it verified from the URL token (`X-Harbour-Verified-Hook`, `-Provider`). The provider's
159
+ * adapter checks the sender over the raw body before anything is written; a verified event becomes
160
+ * one durable run keyed by the provider's event id, so a repeated delivery finds the existing row
161
+ * and re-signals the queue (ADR 0107). Answers `{ challenge }` for a handshake, else `{ accepted, runId }`.
162
+ */
163
+ acceptWebhookArrival(app: string, jobName: string, provider: string, headers: Record<string, string>, rawBody: Buffer): Promise<{
164
+ challenge: string;
165
+ } | {
166
+ accepted: true;
167
+ runId: string;
168
+ }>;
156
169
  runJobOnce(name: string, now?: Date): Promise<boolean>;
157
170
  private awaitDataPath;
158
171
  stop(): Promise<void>;
package/dist/runtime.js CHANGED
@@ -57,7 +57,7 @@
57
57
  // ISOMORPH_JOBS_QUEUE_URL an enqueue sends the run to this SQS queue and
58
58
  // the queue loop never runs here: the cell's
59
59
  // Event Function works it (lambda-events-entry.ts)
60
- import { createHash } from 'node:crypto';
60
+ import { createHash, randomBytes } from 'node:crypto';
61
61
  import { AsyncLocalStorage } from 'node:async_hooks';
62
62
  import { createServer } from 'node:http';
63
63
  import { readFile, readdir, stat } from 'node:fs/promises';
@@ -69,6 +69,7 @@ import { createClient, gatewayRequest, IsomorphError } from './index.js';
69
69
  import { userGroupsFromHeader } from './user-groups.js';
70
70
  import { userRole } from './user-role.js';
71
71
  import { FILE_MIME_TYPES, providerFileId, actionExposure, discoverJobs, exportsTrue, invokeJob, loadHandler, JOBS_DIR } from './jobs.js';
72
+ import { WEBHOOK_ADAPTERS } from './webhook-adapters.js';
72
73
  import { LocalGateway } from './local-gateway.js';
73
74
  export { JOBS_DIR } from './jobs.js';
74
75
  const actionIdentity = new AsyncLocalStorage();
@@ -210,6 +211,11 @@ function header(request, name) {
210
211
  const value = request.headers[name.toLowerCase()];
211
212
  return (Array.isArray(value) ? value[0] : value) ?? '';
212
213
  }
214
+ const HOP_HEADERS = new Set(['host', 'connection', 'content-length', 'transfer-encoding', 'authorization']);
215
+ /** The sender's own headers, lower-cased: the gateway's verified headers and the hop's are not the sender's. */
216
+ function providerHeaders(request) {
217
+ return Object.fromEntries(Object.entries(request.headers).flatMap(([name, value]) => value === undefined || HOP_HEADERS.has(name) || name.startsWith('x-harbour-') ? [] : [[name, Array.isArray(value) ? value.join(', ') : value]]));
218
+ }
213
219
  // ---- Runs (through the gateway, as the workload) ------------------------------
214
220
  export class Runs {
215
221
  client;
@@ -439,7 +445,7 @@ export class Runtime {
439
445
  this.inventory = await discover(this.options.root);
440
446
  const job = this.inventory.jobs.find(job => job.name === delivery.job);
441
447
  const input = delivery.input;
442
- if (!job?.trigger || job.trigger.connection !== input.sourceId)
448
+ if (!job?.trigger || job.trigger.type === 'webhook' || job.trigger.connection !== input.sourceId)
443
449
  throw new Error('event trigger is not declared by this deployed app');
444
450
  if (job.trigger.type === 'file.created') {
445
451
  const instant = (v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(v) && Number.isFinite(Date.parse(v));
@@ -463,6 +469,38 @@ export class Runtime {
463
469
  const queued = await this.runs.enqueueEvent(delivery);
464
470
  await this.signalQueuedRun(queued.id);
465
471
  }
472
+ /**
473
+ * A third-party product's delivery, forwarded by the gateway's hooks route with the job and
474
+ * provider it verified from the URL token (`X-Harbour-Verified-Hook`, `-Provider`). The provider's
475
+ * adapter checks the sender over the raw body before anything is written; a verified event becomes
476
+ * one durable run keyed by the provider's event id, so a repeated delivery finds the existing row
477
+ * and re-signals the queue (ADR 0107). Answers `{ challenge }` for a handshake, else `{ accepted, runId }`.
478
+ */
479
+ async acceptWebhookArrival(app, jobName, provider, headers, rawBody) {
480
+ const fail = (status, category, message) => { throw new IsomorphError(category, message, undefined, undefined, status); };
481
+ this.inventory = await discover(this.options.root);
482
+ const job = this.inventory.jobs.find(job => job.name === jobName);
483
+ if (job?.trigger?.type !== 'webhook')
484
+ fail(404, 'NOT_FOUND', `this app has no webhook job ${JOBS_DIR}/${jobName}.ts`);
485
+ const trigger = job.trigger;
486
+ if (trigger.provider !== provider)
487
+ fail(404, 'NOT_FOUND', `${JOBS_DIR}/${jobName}.ts expects deliveries from ${trigger.provider}, not ${provider}`);
488
+ const secret = trigger.secret ? process.env[trigger.secret] : undefined;
489
+ if (trigger.secret && !secret)
490
+ fail(500, 'INTERNAL', `the app secret ${trigger.secret} that ${JOBS_DIR}/${jobName}.ts names for ${trigger.provider} is not set; set it on the app's secrets page`);
491
+ const verdict = WEBHOOK_ADAPTERS[trigger.provider]({ headers, rawBody, secret, now: Date.now() });
492
+ if (verdict.kind === 'challenge')
493
+ return { challenge: verdict.challenge };
494
+ await this.refreshToken(JOB_SESSION_MIN_VALIDITY_MS);
495
+ if (!this.options.gatewayUrl || this.options.mode === 'gate' || this.options.jobRuns === false || !(await this.awaitDataPath()))
496
+ fail(503, 'UNAVAILABLE', 'webhook arrival requires durable job runs');
497
+ const input = { type: 'webhook.received', provider: trigger.provider, headers, body: verdict.body, receivedAt: new Date().toISOString() };
498
+ // A provider without an event id gets at-least-once with no dedup; its handler must be idempotent.
499
+ const deliveryId = verdict.eventId ? createHash('sha256').update(`hook:${app}/${jobName}/${verdict.eventId}`).digest('hex') : randomBytes(32).toString('hex');
500
+ const queued = await this.runs.enqueueEvent({ deliveryId, job: jobName, input, owner: `workload:${app}` });
501
+ await this.signalQueuedRun(queued.id);
502
+ return { accepted: true, runId: queued.id };
503
+ }
466
504
  async runJobOnce(name, now = new Date()) {
467
505
  this.inventory = await discover(this.options.root);
468
506
  await this.refreshToken(JOB_SESSION_MIN_VALIDITY_MS);
@@ -726,6 +764,20 @@ export class Runtime {
726
764
  const raw = await readBody(request);
727
765
  if (raw === undefined)
728
766
  return refuse(response, 413, 'VALIDATION_FAILED', 'the request body is larger than 1 MiB', requestId);
767
+ if (name === 'isomorph-mail-arrival' && header(request, 'X-Harbour-Verified-Hook')) {
768
+ // The gateway's hooks route: the provider's raw body and headers, the gateway's verified
769
+ // headers beside them. The answer is what the provider reads, so it is not enveloped.
770
+ try {
771
+ return answer(response, 200, await this.acceptWebhookArrival(app, header(request, 'X-Harbour-Verified-Hook'), header(request, 'X-Harbour-Verified-Provider'), providerHeaders(request), raw), requestId);
772
+ }
773
+ catch (error) {
774
+ const refused = refusal(error);
775
+ if (refused)
776
+ return refuse(response, refused.status, refused.category, refused.message, requestId);
777
+ this.log(`webhook ${header(request, 'X-Harbour-Verified-Hook')}: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
778
+ return refuse(response, 500, 'INTERNAL', 'the webhook could not be accepted; the log names the cause', requestId);
779
+ }
780
+ }
729
781
  let input = undefined;
730
782
  if (raw.length > 0) {
731
783
  try {
@@ -833,7 +885,9 @@ export class Runtime {
833
885
  const owner = context.user.id;
834
886
  switch (name) {
835
887
  case PING_ACTION: {
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) };
888
+ // `hooks` is what the local gateway's hooks entrance serves under `isomorph dev` (data plane ADR 0112).
889
+ const hooks = this.inventory.jobs.flatMap((job) => job.trigger?.type === 'webhook' ? [{ job: job.name, provider: job.trigger.provider }] : []);
890
+ 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), hooks };
837
891
  return this.options.mode === 'gate' ? { ...answer, loadFailures: await this.loadFailures() } : answer;
838
892
  }
839
893
  case ENQUEUE_ACTION: {
@@ -0,0 +1,33 @@
1
+ import type { WebhookProvider } from './jobs.js';
2
+ export interface WebhookRequest {
3
+ /** The provider's headers, lower-cased names, as the gateway forwarded them. */
4
+ headers: Record<string, string>;
5
+ /** The body exactly as the provider sent it: what the signature covers. */
6
+ rawBody: Buffer;
7
+ /** The signing secret the job named, undefined when the provider has none. */
8
+ secret?: string;
9
+ /** Milliseconds since the epoch, for freshness. */
10
+ now: number;
11
+ }
12
+ export type WebhookVerdict =
13
+ /** Answer the provider's handshake synchronously with `{ challenge }`; nothing is written. */
14
+ {
15
+ kind: 'challenge';
16
+ challenge: string;
17
+ }
18
+ /** A verified delivery: the body (parsed JSON when it is JSON, else the text) and the provider's id for it, when it has one. */
19
+ | {
20
+ kind: 'event';
21
+ eventId?: string;
22
+ body: unknown;
23
+ };
24
+ /** Slack accepts a request timestamp this far from the receiver's clock (Slack's own guidance: five minutes). */
25
+ export declare const SLACK_MAX_AGE_SECONDS: number;
26
+ /** How far ahead of the receiver's clock a Slack timestamp may be: clock skew, not age. */
27
+ export declare const SLACK_MAX_SKEW_SECONDS = 60;
28
+ export declare const parseBody: (rawBody: Buffer) => unknown;
29
+ /** `X-Slack-Signature: v0=<hex HMAC-SHA256(secret, "v0:<X-Slack-Request-Timestamp>:<raw body>")>`. */
30
+ export declare function verifySlack(request: WebhookRequest): WebhookVerdict;
31
+ /** No signature: the hook's URL token is the only check, and no event id means no dedup. */
32
+ export declare const verifyNone: (request: WebhookRequest) => WebhookVerdict;
33
+ export declare const WEBHOOK_ADAPTERS: Record<WebhookProvider, (request: WebhookRequest) => WebhookVerdict>;
@@ -0,0 +1,49 @@
1
+ // Sender verification for webhook arrivals, one adapter per provider: the exact signed bytes,
2
+ // header parsing, timestamp freshness, event-id extraction and any handshake, as pure functions
3
+ // over the raw request. The runtime runs the adapter before it writes anything (ADR 0107).
4
+ import { createHmac, timingSafeEqual } from 'node:crypto';
5
+ import { IsomorphError } from './index.js';
6
+ /** A refusal the runtime answers as it is, with its status, and writes nothing for. */
7
+ function refuse(status, message) { throw new IsomorphError(status === 401 ? 'AUTH_REQUIRED' : 'VALIDATION_FAILED', message, undefined, undefined, status); }
8
+ /** Slack accepts a request timestamp this far from the receiver's clock (Slack's own guidance: five minutes). */
9
+ export const SLACK_MAX_AGE_SECONDS = 5 * 60;
10
+ /** How far ahead of the receiver's clock a Slack timestamp may be: clock skew, not age. */
11
+ export const SLACK_MAX_SKEW_SECONDS = 60;
12
+ export const parseBody = (rawBody) => {
13
+ const text = rawBody.toString('utf8');
14
+ try {
15
+ return JSON.parse(text);
16
+ }
17
+ catch {
18
+ return text;
19
+ }
20
+ };
21
+ /** `X-Slack-Signature: v0=<hex HMAC-SHA256(secret, "v0:<X-Slack-Request-Timestamp>:<raw body>")>`. */
22
+ export function verifySlack(request) {
23
+ if (!request.secret)
24
+ refuse(401, 'the Slack signing secret is not set');
25
+ const timestamp = request.headers['x-slack-request-timestamp'] ?? '';
26
+ const signature = request.headers['x-slack-signature'] ?? '';
27
+ if (!/^\d{1,12}$/.test(timestamp) || !/^v0=[0-9a-f]{64}$/.test(signature))
28
+ refuse(401, 'the request carries no valid X-Slack-Request-Timestamp and X-Slack-Signature');
29
+ const age = Math.floor(request.now / 1000) - Number(timestamp);
30
+ if (age > SLACK_MAX_AGE_SECONDS)
31
+ refuse(401, `the Slack request timestamp is older than ${SLACK_MAX_AGE_SECONDS} seconds`);
32
+ if (-age > SLACK_MAX_SKEW_SECONDS)
33
+ refuse(401, 'the Slack request timestamp is in the future');
34
+ const expected = Buffer.from(`v0=${createHmac('sha256', request.secret).update(`v0:${timestamp}:`).update(request.rawBody).digest('hex')}`);
35
+ const given = Buffer.from(signature);
36
+ if (expected.length !== given.length || !timingSafeEqual(expected, given))
37
+ refuse(401, 'the Slack signature does not match');
38
+ const body = parseBody(request.rawBody);
39
+ const record = body && typeof body === 'object' ? body : {};
40
+ if (record.type === 'url_verification') {
41
+ if (typeof record.challenge !== 'string' || !record.challenge)
42
+ refuse(400, 'url_verification carries no challenge');
43
+ return { kind: 'challenge', challenge: record.challenge };
44
+ }
45
+ return { kind: 'event', body, ...(typeof record.event_id === 'string' && record.event_id ? { eventId: record.event_id } : {}) };
46
+ }
47
+ /** No signature: the hook's URL token is the only check, and no event id means no dedup. */
48
+ export const verifyNone = (request) => ({ kind: 'event', body: parseBody(request.rawBody) });
49
+ export const WEBHOOK_ADAPTERS = { slack: verifySlack, none: verifyNone };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "2.3.1",
3
+ "version": "2.5.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",