@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 +1 -1
- package/dist/computer-use-api.js +22 -8
- package/dist/index.d.ts +1 -0
- package/dist/jobs.d.ts +21 -1
- package/dist/jobs.js +12 -1
- package/dist/runtime.d.ts +13 -0
- package/dist/runtime.js +57 -3
- package/dist/webhook-adapters.d.ts +33 -0
- package/dist/webhook-adapters.js +49 -0
- package/package.json +1 -1
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
|
-
-
|
|
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`.
|
package/dist/computer-use-api.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
// Harbour's computer use API (CUA)
|
|
2
|
-
//
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
//
|
|
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
|
|
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
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
|
|
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
|
|
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
|
-
|
|
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 };
|