@isomorph.ai/app-sdk 2.3.0 → 2.4.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/dist/aws.js +5 -1
- package/dist/computer-use-api.js +22 -6
- 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 +60 -3
- package/dist/webhook-adapters.d.ts +33 -0
- package/dist/webhook-adapters.js +49 -0
- package/package.json +1 -1
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
|
-
|
|
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) });
|
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,12 +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
|
-
|
|
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.
|
|
13
19
|
const region = env.ISOMORPH_COMPUTER_USE_REGION || 'us-east-1';
|
|
14
20
|
const transport = fetch ? { fetch } : {};
|
|
15
|
-
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);
|
|
16
32
|
return {
|
|
17
33
|
call,
|
|
18
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);
|
|
@@ -685,8 +723,13 @@ export class Runtime {
|
|
|
685
723
|
const abort = () => controller.abort();
|
|
686
724
|
response.once('close', abort);
|
|
687
725
|
const directory = header(request, 'X-Harbour-Verified-Directory');
|
|
726
|
+
const userId = header(request, 'X-Harbour-Verified-User-ID');
|
|
688
727
|
const user = {
|
|
689
|
-
id:
|
|
728
|
+
id: userId,
|
|
729
|
+
// A service credential's call arrives with the credential as the verified user
|
|
730
|
+
// (`service:<clientId>`, ADR 0101); the action sees it as `kind: 'service'`, the
|
|
731
|
+
// shape `/_harbour/identity/current` already answers. Anything else is a person.
|
|
732
|
+
...(userId.startsWith('service:') ? { kind: 'service' } : {}),
|
|
690
733
|
email: header(request, 'X-Harbour-Verified-User-Email'),
|
|
691
734
|
groups: userGroupsFromHeader(header(request, 'X-Harbour-Verified-User-Groups')),
|
|
692
735
|
role: userRole(header(request, 'X-Harbour-Verified-User-Role')),
|
|
@@ -721,6 +764,20 @@ export class Runtime {
|
|
|
721
764
|
const raw = await readBody(request);
|
|
722
765
|
if (raw === undefined)
|
|
723
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
|
+
}
|
|
724
781
|
let input = undefined;
|
|
725
782
|
if (raw.length > 0) {
|
|
726
783
|
try {
|
|
@@ -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 };
|