@notionhq/apps 0.0.35 → 0.0.37

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/src/workflow.ts CHANGED
@@ -23,9 +23,11 @@ import { ExecutionError } from "./error.js";
23
23
  import { writeOutput } from "./output.js";
24
24
  import { resolveRuntimeInput } from "./runtime-input.js";
25
25
  import { readRunMetadata, type RunMetadata } from "./runtime-metadata.js";
26
- import { createWorkflowEvents } from "./events.generated.js";
26
+ import { createWorkflowEvents, validateManualWorkflowInput } from "./events.generated.js";
27
27
  import { createWorkflowStepState, type WorkflowState } from "./workflow-state.js";
28
28
  import type {
29
+ ManualWorkflowTrigger,
30
+ WebhookTrigger,
29
31
  WorkflowEventMap,
30
32
  WorkflowTrigger,
31
33
  WorkflowEventCreators,
@@ -37,6 +39,9 @@ type HandlerOptions = {
37
39
  concreteOutput?: true;
38
40
  };
39
41
 
42
+ /** The only trigger whose deliveries a verify handler can answer. */
43
+ const WEBHOOK_TRIGGER_TYPE: WebhookTrigger["type"] = "webhooks.webhook";
44
+
40
45
  type WorkflowWaitDurationValues = {
41
46
  milliseconds?: number;
42
47
  seconds?: number;
@@ -200,7 +205,15 @@ export type {
200
205
  export { access } from "./workflow-access.js";
201
206
  export type WorkflowEvent = WorkflowEventMap[keyof WorkflowEventMap];
202
207
 
203
- export type WorkflowEventForTrigger<T extends WorkflowTrigger> = WorkflowEventMap[T["type"]];
208
+ export type WorkflowEventForTrigger<T extends WorkflowTrigger> =
209
+ T extends ManualWorkflowTrigger<infer TInput>
210
+ ? {
211
+ type: "workflow.manual";
212
+ input: TInput;
213
+ }
214
+ : T extends { type: keyof WorkflowEventMap }
215
+ ? WorkflowEventMap[T["type"]]
216
+ : never;
204
217
 
205
218
  export type WorkflowEventForTriggers<T extends readonly WorkflowTrigger[]> =
206
219
  WorkflowEventForTrigger<T[number]>;
@@ -215,6 +228,73 @@ export {
215
228
  type OAuthConnection,
216
229
  } from "./connections.js";
217
230
 
231
+ /**
232
+ * The inbound HTTP request passed to a webhook verify handler.
233
+ */
234
+ export type WebhookVerifyRequest = {
235
+ /** Uppercase HTTP method. Deliveries are POSTs; GET/HEAD cover challenge probes. */
236
+ method: "GET" | "HEAD" | "POST";
237
+ /** The full webhook URL as received. */
238
+ url: string;
239
+ /** Query string parameters. */
240
+ query: Record<string, string>;
241
+ /** Request headers with lowercased names. */
242
+ headers: Record<string, string>;
243
+ /** Raw request body. Empty string for bodyless methods like GET and HEAD. */
244
+ rawBody: string;
245
+ };
246
+
247
+ /**
248
+ * The HTTP response a verify handler produces. `deliver`, when present,
249
+ * explicitly controls whether the request is enqueued for the workflow
250
+ * handler. Otherwise delivery is derived from the status: 2xx enqueues and 4xx
251
+ * does not.
252
+ */
253
+ export type WebhookVerifyResponse = {
254
+ /** Response status code. Only 2xx and 4xx statuses are allowed. */
255
+ status: number;
256
+ /** Optional response body, at most 8KB. */
257
+ body?: string;
258
+ /** Response content type. Defaults to "text/plain". */
259
+ contentType?: "application/json" | "text/plain";
260
+ /**
261
+ * Whether to enqueue the request for the workflow handler. Defaults to true
262
+ * for 2xx responses and false for 4xx responses.
263
+ */
264
+ deliver?: boolean;
265
+ };
266
+
267
+ /**
268
+ * A webhook verify handler. Runs synchronously at Notion's webhook ingress,
269
+ * before the HTTP response is sent, in the app's own sandbox — the same
270
+ * runtime as the workflow handler, with the full Node.js standard library.
271
+ * Read signing secrets from `process.env` and use `node:crypto` for
272
+ * signature checks.
273
+ *
274
+ * Keep it fast: the run must fit a small wall-clock budget (a few seconds,
275
+ * including sandbox startup), so avoid network calls — including
276
+ * `context.notion` API calls, which typically won't fit the budget.
277
+ */
278
+ export type WebhookVerifyHandler = (
279
+ request: WebhookVerifyRequest,
280
+ context: CapabilityContext,
281
+ ) => WebhookVerifyResponse | Promise<WebhookVerifyResponse>;
282
+
283
+ /**
284
+ * A verify invocation as dispatched by the platform: a single `request`
285
+ * object rather than the event a workflow invocation carries.
286
+ */
287
+ export type WebhookVerifyInvocation = {
288
+ request: WebhookVerifyRequest;
289
+ };
290
+
291
+ /**
292
+ * A trigger as the manifest records it. A declared `verify` handler is flagged
293
+ * on the webhook trigger itself, which is where the platform reads it to decide
294
+ * whether to run `verify` at webhook ingress.
295
+ */
296
+ export type WorkflowManifestTrigger = WorkflowTrigger | (WebhookTrigger & { hasVerify: true });
297
+
218
298
  /**
219
299
  * Configuration passed to {@link workflow}.
220
300
  */
@@ -252,6 +332,40 @@ export type WorkflowConfiguration<
252
332
  */
253
333
  connections?: TConnections;
254
334
 
335
+ /**
336
+ * Optional synchronous verification handler, run at Notion's webhook
337
+ * ingress before the HTTP response is sent. Use it for providers that
338
+ * require a synchronous verification response: signature checks with
339
+ * specific status codes, challenge echoes, or handshake requests.
340
+ *
341
+ * Only workflows with an `events.webhook()` trigger may declare one.
342
+ *
343
+ * The handler controls the HTTP response. Return `deliver: true` to enqueue
344
+ * the request for the workflow handler, or `deliver: false` to answer the
345
+ * provider without enqueueing it. When omitted, 2xx enqueues and 4xx does
346
+ * not. `verify` runs in the app's own sandbox with full Node.js — read
347
+ * secrets from `process.env` and use `node:crypto` for signature checks. It
348
+ * receives the capability context as its second argument, without the run
349
+ * metadata and `step` a workflow run carries.
350
+ *
351
+ * Without `verify`, the webhook keeps the current behavior: every request
352
+ * is accepted with `202` and processed asynchronously.
353
+ *
354
+ * @example
355
+ * ```ts
356
+ * verify: (request) => {
357
+ * const signature = createHmac("sha256", process.env.WEBHOOK_SECRET ?? "")
358
+ * .update(request.rawBody)
359
+ * .digest("hex");
360
+ * const expected = Buffer.from(`sha256=${signature}`);
361
+ * const actual = Buffer.from(request.headers["x-hub-signature-256"] ?? "");
362
+ * const verified = expected.length === actual.length && timingSafeEqual(expected, actual);
363
+ * return verified ? { status: 200 } : { status: 401, body: "Invalid signature" };
364
+ * },
365
+ * ```
366
+ */
367
+ verify?: WebhookVerifyHandler;
368
+
255
369
  handler: (
256
370
  event: WorkflowEventForTriggers<TTriggers>,
257
371
  context: WorkflowContext<TConnections, TAccess>,
@@ -283,14 +397,14 @@ export type Workflow<
283
397
  config: {
284
398
  name: string;
285
399
  description: string;
286
- triggers: TTriggers;
400
+ triggers: WorkflowManifestTrigger[];
287
401
  connections?: readonly WorkflowConnectionRequirement[];
288
402
  access?: WorkflowAccessRequirements;
289
403
  };
290
404
  handler: (
291
- event: WorkflowEventForTriggers<TTriggers>,
405
+ input: WorkflowEventForTriggers<TTriggers> | WebhookVerifyInvocation,
292
406
  options?: HandlerOptions,
293
- ) => Promise<WorkflowHandlerResult | undefined>;
407
+ ) => Promise<WorkflowHandlerResult | WebhookVerifyResponse | undefined>;
294
408
  };
295
409
 
296
410
  /**
@@ -354,22 +468,61 @@ export function workflow<
354
468
  ? configuration.triggers({ events: createWorkflowEvents<TConnections>() })
355
469
  : configuration.triggers;
356
470
  validateTriggerConnections(triggers, requirements);
471
+ const manualTriggers = triggers.filter(
472
+ (trigger): trigger is ManualWorkflowTrigger => trigger.type === "workflow.manual",
473
+ );
474
+ if (manualTriggers.length > 1) {
475
+ throw new Error("Workflows support at most one manual trigger");
476
+ }
477
+
478
+ if (
479
+ configuration.verify !== undefined &&
480
+ !triggers.some((trigger) => trigger.type === WEBHOOK_TRIGGER_TYPE)
481
+ ) {
482
+ throw new Error(
483
+ `Workflow "${configuration.name}" declares a verify handler but has no webhook trigger. ` +
484
+ `Add events.webhook() or remove verify.`,
485
+ );
486
+ }
357
487
 
358
488
  return {
359
489
  _tag: "workflow",
360
490
  config: {
361
491
  name: configuration.name,
362
492
  description: configuration.description,
363
- triggers,
493
+ triggers: manifestTriggers(triggers, configuration.verify !== undefined),
364
494
  ...(configuration.connections === undefined ? {} : { connections: requirements }),
365
495
  ...(configuration.access === undefined ? {} : { access: accessRequirements }),
366
496
  },
367
497
  async handler(
368
- event: WorkflowEventForTriggers<TTriggers>,
498
+ input: WorkflowEventForTriggers<TTriggers> | WebhookVerifyInvocation,
369
499
  options?: HandlerOptions,
370
- ): Promise<WorkflowHandlerResult | undefined> {
500
+ ): Promise<WorkflowHandlerResult | WebhookVerifyResponse | undefined> {
371
501
  try {
372
- event = await resolveRuntimeInput(event);
502
+ input = await resolveRuntimeInput(input);
503
+
504
+ if (isVerifyInvocation(input)) {
505
+ if (configuration.verify === undefined) {
506
+ throw new Error(
507
+ `Workflow "${configuration.name}" received a verify request but does not ` +
508
+ `declare a verify handler`,
509
+ );
510
+ }
511
+
512
+ const response = await configuration.verify(
513
+ input.request,
514
+ createCapabilityContext(),
515
+ );
516
+
517
+ if (options?.concreteOutput) {
518
+ return response;
519
+ }
520
+
521
+ writeOutput({ _tag: "success", value: response });
522
+ return undefined;
523
+ }
524
+
525
+ const event = input;
373
526
  const runMetadata = readRunMetadata();
374
527
  const baseContext = createCapabilityContext();
375
528
  const step = createStep(runMetadata);
@@ -384,6 +537,15 @@ export function workflow<
384
537
  step,
385
538
  wait: createWait(step),
386
539
  };
540
+ if (event.type === "workflow.manual") {
541
+ const manualTrigger = manualTriggers[0];
542
+ if (manualTrigger === undefined) {
543
+ throw new Error(
544
+ "Received workflow.manual for a workflow without a manual trigger",
545
+ );
546
+ }
547
+ validateManualWorkflowInput(manualTrigger, event.input);
548
+ }
387
549
  await configuration.handler(event, capabilityContext);
388
550
 
389
551
  if (options?.concreteOutput) {
@@ -423,6 +585,31 @@ export function workflow<
423
585
  };
424
586
  }
425
587
 
588
+ /**
589
+ * The triggers as the manifest records them. The manifest only records that a
590
+ * verify handler exists, flagged on the webhook trigger; the handler itself
591
+ * ships in the app bundle and runs via a verify invocation.
592
+ */
593
+ function manifestTriggers(
594
+ triggers: readonly WorkflowTrigger[],
595
+ hasVerify: boolean,
596
+ ): WorkflowManifestTrigger[] {
597
+ return triggers.map((trigger) =>
598
+ hasVerify && trigger.type === WEBHOOK_TRIGGER_TYPE
599
+ ? { ...trigger, hasVerify: true as const }
600
+ : trigger,
601
+ );
602
+ }
603
+
604
+ /**
605
+ * Whether an invocation is the platform's synchronous verify dispatch. The
606
+ * platform sends workflow invocations as a trigger event and verify
607
+ * invocations as a `{ request }` object, so the shape disambiguates.
608
+ */
609
+ function isVerifyInvocation(input: unknown): input is WebhookVerifyInvocation {
610
+ return typeof input === "object" && input !== null && "request" in input;
611
+ }
612
+
426
613
  /** Context passed to a workflow step. */
427
614
  export type StepContext = {
428
615
  /**