@pikku/core 0.12.129 → 0.12.133

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/dist/function/function-meta.types.d.ts +14 -0
  3. package/dist/middleware-runner.d.ts +4 -0
  4. package/dist/middleware-runner.js +52 -16
  5. package/dist/services/in-memory-lease-service.d.ts +13 -0
  6. package/dist/services/in-memory-lease-service.js +49 -0
  7. package/dist/services/in-memory-workflow-service.d.ts +11 -3
  8. package/dist/services/in-memory-workflow-service.js +53 -23
  9. package/dist/services/index.d.ts +2 -0
  10. package/dist/services/index.js +2 -0
  11. package/dist/services/lease-service.d.ts +61 -0
  12. package/dist/services/lease-service.js +110 -0
  13. package/dist/services/trigger-source-store.d.ts +19 -2
  14. package/dist/services/trigger-source-store.js +14 -1
  15. package/dist/services/workflow-service.d.ts +1 -1
  16. package/dist/testing/index.d.ts +1 -0
  17. package/dist/testing/service-tests/lease-service-tests.d.ts +3 -0
  18. package/dist/testing/service-tests/lease-service-tests.js +138 -0
  19. package/dist/testing/service-tests/workflow-fencing-tests.d.ts +18 -0
  20. package/dist/testing/service-tests/workflow-fencing-tests.js +216 -0
  21. package/dist/testing/service-tests.d.ts +5 -0
  22. package/dist/testing/service-tests.js +8 -0
  23. package/dist/types/core.types.d.ts +12 -8
  24. package/dist/utils/hmac.d.ts +14 -10
  25. package/dist/utils/hmac.js +29 -25
  26. package/dist/wirings/http/pikku-fetch-http-response.js +13 -0
  27. package/dist/wirings/secret/derive-oauth2-app-secrets.js +1 -0
  28. package/dist/wirings/secret/secret.types.d.ts +6 -0
  29. package/dist/wirings/trigger/index.d.ts +4 -4
  30. package/dist/wirings/trigger/index.js +2 -2
  31. package/dist/wirings/trigger/webhook-source-runner.d.ts +51 -4
  32. package/dist/wirings/trigger/webhook-source-runner.js +201 -15
  33. package/dist/wirings/trigger/webhook-source.types.d.ts +50 -0
  34. package/dist/wirings/trigger/webhook-source.types.js +2 -0
  35. package/dist/wirings/workflow/graph/graph-runner.js +33 -14
  36. package/dist/wirings/workflow/index.d.ts +4 -3
  37. package/dist/wirings/workflow/index.js +3 -2
  38. package/dist/wirings/workflow/pikku-workflow-service.d.ts +57 -18
  39. package/dist/wirings/workflow/pikku-workflow-service.js +148 -145
  40. package/dist/wirings/workflow/workflow-constants.d.ts +28 -1
  41. package/dist/wirings/workflow/workflow-constants.js +28 -1
  42. package/dist/wirings/workflow/workflow-errors.d.ts +21 -0
  43. package/dist/wirings/workflow/workflow-errors.js +38 -0
  44. package/dist/wirings/workflow/workflow-queue-routing.d.ts +4 -2
  45. package/dist/wirings/workflow/workflow-queue-routing.js +9 -1
  46. package/dist/wirings/workflow/workflow-run-lease.d.ts +10 -0
  47. package/dist/wirings/workflow/workflow-run-lease.js +25 -0
  48. package/dist/wirings/workflow/workflow-run-status.d.ts +4 -0
  49. package/dist/wirings/workflow/workflow-run-status.js +37 -0
  50. package/dist/wirings/workflow/workflow-status-stream.d.ts +2 -1
  51. package/dist/wirings/workflow/workflow-status-stream.js +18 -1
  52. package/dist/wirings/workflow/workflow-step-claim.d.ts +13 -1
  53. package/dist/wirings/workflow/workflow-step-claim.js +29 -5
  54. package/dist/wirings/workflow/workflow-step-lease.d.ts +25 -0
  55. package/dist/wirings/workflow/workflow-step-lease.js +53 -0
  56. package/dist/wirings/workflow/workflow-step-retry.d.ts +14 -0
  57. package/dist/wirings/workflow/workflow-step-retry.js +33 -0
  58. package/dist/wirings/workflow/workflow-version-fallback.d.ts +11 -0
  59. package/dist/wirings/workflow/workflow-version-fallback.js +30 -0
  60. package/dist/wirings/workflow/workflow.types.d.ts +17 -0
  61. package/knowledge/decisions/internals/a-held-run-is-woken-later-not-retried.md +28 -0
  62. package/knowledge/decisions/internals/index.md +2 -1
  63. package/knowledge/decisions/internals/the-in-memory-workflow-service-is-inline-only-and-single-process.md +2 -2
  64. package/knowledge/decisions/internals/workflow-step-lock-is-held-only-to-claim-the-step.md +12 -5
  65. package/knowledge/decisions/security/an-actor-credential-is-derived-per-persona.md +4 -3
  66. package/package.json +1 -1
  67. package/src/public-surface.json +21 -1
@@ -1,5 +1,6 @@
1
1
  import type { IncomingWebhookService } from '../services/incoming-webhook-service.js';
2
2
  import type { TriggerSourceStore } from '../services/trigger-source-store.js';
3
+ import type { LeaseService } from '../services/lease-service.js';
3
4
  import type { Logger, LogLevel } from '../services/logger.js';
4
5
  import type { VariablesService } from '../services/variables-service.js';
5
6
  import type { SecretService } from '../services/secret-service.js';
@@ -139,6 +140,7 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
139
140
  incomingWebhookService?: IncomingWebhookService;
140
141
  /** Which trigger sources an operator enabled, and what enabling registered. */
141
142
  triggerSourceStore?: TriggerSourceStore;
143
+ leaseService?: LeaseService;
142
144
  metaService?: MetaService;
143
145
  /**
144
146
  * Where virtual-user runs are recorded. A run is dispatched and answered for
@@ -199,19 +201,25 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
199
201
  /**
200
202
  * Reads a single credential. The first signature resolves the value type from
201
203
  * the project's generated `CredentialsMap`; the second keeps a name the map
202
- * does not know callable with an explicit type.
204
+ * does not know callable with an explicit type. Without a map, every name is a
205
+ * key, so the first signature would swallow an explicit type argument; only
206
+ * the second is offered then.
203
207
  *
204
208
  * `TCredentials` is unconstrained because the generated map is an interface,
205
209
  * which has no implicit index signature and so cannot satisfy
206
210
  * `Record<string, unknown>`.
207
211
  */
208
- export type GetCredential<TCredentials = Record<string, unknown>> = {
212
+ export type GetCredential<TCredentials = Record<string, unknown>> = string extends keyof TCredentials ? <T = unknown>(name: string) => NoInfer<T> | null | Promise<NoInfer<T> | null> : {
209
213
  <K extends keyof TCredentials & string>(name: K): TCredentials[K] | null | Promise<TCredentials[K] | null>;
210
214
  <T = unknown>(name: string): T | null | Promise<T | null>;
211
215
  };
212
216
  export type PikkuWire<In = unknown, Out = unknown, HasInitialSession extends boolean = false, UserSession extends CoreUserSession = CoreUserSession, TypedRPC extends PikkuRPC = PikkuRPC, IsChannel extends true | null = null, MCPTools extends string | never = never, TypedWorkflow extends PikkuWorkflowWire | never = PikkuWorkflowWire, TriggerOutput = unknown, TypedScenario extends PikkuScenarioWire<any> | never = PikkuScenarioWire<any>, TypedActors extends ScenarioPersonas = ScenarioPersonas, TypedCredentials = Record<string, unknown>> = {
213
217
  /** Always present — lazily initialised on first access for every function invocation */
214
218
  rpc: TypedRPC;
219
+ /** Get a single credential by name — lazy-loads from CredentialService on first call, sync thereafter */
220
+ getCredential: GetCredential<TypedCredentials>;
221
+ /** Get all resolved credentials — lazy-loads from CredentialService on first call, sync thereafter */
222
+ getCredentials: () => Record<string, unknown> | Promise<Record<string, unknown>>;
215
223
  } & Partial<{
216
224
  wireType: PikkuWiringTypes;
217
225
  wireId: string;
@@ -260,10 +268,6 @@ export type PikkuWire<In = unknown, Out = unknown, HasInitialSession extends boo
260
268
  pikkuUserId: string;
261
269
  /** Set a credential value (available in middleware) */
262
270
  setCredential: (name: string, value: unknown) => void;
263
- /** Get a single credential by name — lazy-loads from CredentialService on first call, sync thereafter */
264
- getCredential: GetCredential<TypedCredentials>;
265
- /** Get all resolved credentials — lazy-loads from CredentialService on first call, sync thereafter */
266
- getCredentials: () => Record<string, unknown> | Promise<Record<string, unknown>>;
267
271
  audit: {
268
272
  durability: AuditDurability;
269
273
  };
@@ -282,8 +286,8 @@ export type PikkuWire<In = unknown, Out = unknown, HasInitialSession extends boo
282
286
  */
283
287
  beginChanges: () => Promise<void>;
284
288
  }>;
285
- /** Wire as constructed by runners, before the function runner lazily adds `rpc`. */
286
- export type PikkuRawWire = Omit<PikkuWire, 'rpc'>;
289
+ /** Wire as constructed by runners, before the function runner adds `rpc` and the credential readers. */
290
+ export type PikkuRawWire = Omit<PikkuWire, 'rpc' | 'getCredential' | 'getCredentials'> & Partial<Pick<PikkuWire, 'getCredential' | 'getCredentials'>>;
287
291
  export type CoreServices<SingletonServices = CoreSingletonServices> = SingletonServices;
288
292
  /** Strips `secrets` from a services type. */
289
293
  export type SecretlessServices<Services> = Omit<Services, 'secrets'>;
@@ -9,18 +9,22 @@ export declare function hmacSha256Hex(secret: string, payload: string): string;
9
9
  * where `timingSafeEqual` itself throws.
10
10
  */
11
11
  export declare function timingSafeStringEqual(a: string, b: string): boolean;
12
- type WebhookPayload = string | Uint8Array;
13
- type HmacAlgorithm = 'sha1' | 'sha256' | 'sha512';
14
- type SecretEncoding = 'utf8' | 'hex' | 'base64';
12
+ export type WebhookPayload = string | Uint8Array;
13
+ export type HmacAlgorithm = 'sha1' | 'sha256' | 'sha512';
14
+ export type SecretEncoding = 'utf8' | 'hex' | 'base64';
15
15
  type SecretSource = string | null | (() => Promise<string | null>);
16
+ /** The HMAC of `payload` under `secret`, for handshakes that answer with a digest. */
17
+ export declare function hmacDigest(secret: string, algorithm: HmacAlgorithm, payload: WebhookPayload, encoding: 'hex' | 'base64', secretEncoding?: SecretEncoding): string;
18
+ /** Whether `signature` is the HMAC of `payload` under `secret`, compared in constant time. */
19
+ export declare function verifyHmacSignature(secret: string, signature: string | undefined, algorithm: HmacAlgorithm, payload: WebhookPayload, encoding: 'hex' | 'base64', secretEncoding?: SecretEncoding): boolean;
20
+ /** Whether `signature` (base64) is `payload` signed by the private half of `publicKey` (PEM). */
21
+ export declare function verifyPublicKeySignature(publicKey: string, signature: string | undefined, payload: WebhookPayload, options?: {
22
+ algorithm?: string;
23
+ dsaEncoding?: 'der' | 'ieee-p1363';
24
+ }): boolean;
16
25
  /**
17
- * A provider's webhook signing secret, held by a singleton service so a
18
- * webhook `receive` step can check signatures without reading it. Built with
19
- * null when the app never provisioned the secret: every check then refuses, so
20
- * an unconfigured receiver accepts nothing.
21
- *
22
- * Built with a lookup, as `fromCredential` does, it checks nothing until
23
- * `load()` reads the current value.
26
+ * @deprecated Declare `credential` and `verify` on `wireTriggerWebhookSource`,
27
+ * which checks every request before `receive` runs, or use the functions above.
24
28
  */
25
29
  export declare class WebhookSigningSecret {
26
30
  private readonly provider;
@@ -19,14 +19,33 @@ export function timingSafeStringEqual(a, b) {
19
19
  return false;
20
20
  }
21
21
  }
22
+ /** The HMAC of `payload` under `secret`, for handshakes that answer with a digest. */
23
+ export function hmacDigest(secret, algorithm, payload, encoding, secretEncoding = 'utf8') {
24
+ return createHmac(algorithm, Buffer.from(secret, secretEncoding))
25
+ .update(payload)
26
+ .digest(encoding);
27
+ }
28
+ /** Whether `signature` is the HMAC of `payload` under `secret`, compared in constant time. */
29
+ export function verifyHmacSignature(secret, signature, algorithm, payload, encoding, secretEncoding = 'utf8') {
30
+ return (!!signature &&
31
+ timingSafeStringEqual(signature, hmacDigest(secret, algorithm, payload, encoding, secretEncoding)));
32
+ }
33
+ /** Whether `signature` (base64) is `payload` signed by the private half of `publicKey` (PEM). */
34
+ export function verifyPublicKeySignature(publicKey, signature, payload, options = {}) {
35
+ if (!signature)
36
+ return false;
37
+ try {
38
+ return createVerify(options.algorithm ?? 'sha256')
39
+ .update(payload)
40
+ .verify({ key: publicKey, dsaEncoding: options.dsaEncoding }, signature, 'base64');
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
22
46
  /**
23
- * A provider's webhook signing secret, held by a singleton service so a
24
- * webhook `receive` step can check signatures without reading it. Built with
25
- * null when the app never provisioned the secret: every check then refuses, so
26
- * an unconfigured receiver accepts nothing.
27
- *
28
- * Built with a lookup, as `fromCredential` does, it checks nothing until
29
- * `load()` reads the current value.
47
+ * @deprecated Declare `credential` and `verify` on `wireTriggerWebhookSource`,
48
+ * which checks every request before `receive` runs, or use the functions above.
30
49
  */
31
50
  export class WebhookSigningSecret {
32
51
  provider;
@@ -54,14 +73,11 @@ export class WebhookSigningSecret {
54
73
  }
55
74
  /** For handshakes that answer with a digest, such as Zoom's URL validation. */
56
75
  hmac(algorithm, payload, encoding, secretEncoding = 'utf8') {
57
- return createHmac(algorithm, Buffer.from(this.require(), secretEncoding))
58
- .update(payload)
59
- .digest(encoding);
76
+ return hmacDigest(this.require(), algorithm, payload, encoding, secretEncoding);
60
77
  }
61
78
  /** Throws unless `signature` is the HMAC of `payload` under the secret. */
62
79
  verifyHmac(signature, algorithm, payload, encoding, secretEncoding = 'utf8') {
63
- const expected = this.hmac(algorithm, payload, encoding, secretEncoding);
64
- if (!signature || !timingSafeStringEqual(signature, expected)) {
80
+ if (!verifyHmacSignature(this.require(), signature, algorithm, payload, encoding, secretEncoding)) {
65
81
  throw this.rejected();
66
82
  }
67
83
  }
@@ -73,19 +89,7 @@ export class WebhookSigningSecret {
73
89
  }
74
90
  /** For providers that sign with a private key: the secret is their public key, as PEM. */
75
91
  verifyPublicKey(signature, payload, options = {}) {
76
- const key = this.require();
77
- let valid = false;
78
- try {
79
- valid =
80
- !!signature &&
81
- createVerify(options.algorithm ?? 'sha256')
82
- .update(payload)
83
- .verify({ key, dsaEncoding: options.dsaEncoding }, signature, 'base64');
84
- }
85
- catch {
86
- valid = false;
87
- }
88
- if (!valid) {
92
+ if (!verifyPublicKeySignature(this.require(), signature, payload, options)) {
89
93
  throw this.rejected();
90
94
  }
91
95
  }
@@ -7,6 +7,7 @@ export class PikkuFetchHTTPResponse {
7
7
  #responseMode = null;
8
8
  #send = null;
9
9
  #close = null;
10
+ #cancelStream = null;
10
11
  setMode(mode) {
11
12
  this.#responseMode = 'stream';
12
13
  if (mode === 'stream') {
@@ -107,16 +108,28 @@ export class PikkuFetchHTTPResponse {
107
108
  const encoder = new TextEncoder();
108
109
  return new ReadableStream({
109
110
  start: (controller) => {
111
+ let closed = false;
110
112
  const send = (data) => {
113
+ if (closed)
114
+ return;
111
115
  controller.enqueue(encoder.encode(`data: ${data}\n\n`));
112
116
  };
113
117
  const close = () => {
118
+ if (closed)
119
+ return;
120
+ closed = true;
114
121
  controller.close();
115
122
  };
123
+ this.#cancelStream = () => {
124
+ closed = true;
125
+ };
116
126
  this.#send = send;
117
127
  this.#close = close;
118
128
  controller.enqueue(encoder.encode(':\n\n'));
119
129
  },
130
+ cancel: () => {
131
+ this.#cancelStream?.();
132
+ },
120
133
  });
121
134
  }
122
135
  }
@@ -37,6 +37,7 @@ export const deriveOAuth2AppSecrets = (credentials, declared) => {
37
37
  authorizationUrl: oauth2.authorizationUrl,
38
38
  tokenUrl: oauth2.tokenUrl,
39
39
  scopes: oauth2.scopes,
40
+ scopeSeparator: oauth2.scopeSeparator,
40
41
  pkce: oauth2.pkce,
41
42
  additionalParams: oauth2.additionalParams,
42
43
  },
@@ -31,6 +31,12 @@ export type OAuth2CredentialConfig = {
31
31
  authorizationUrl: string;
32
32
  tokenUrl: string;
33
33
  scopes: string[];
34
+ /**
35
+ * What joins `scopes` in the authorization URL. RFC 6749 says a space, which
36
+ * is the default; a few providers (Twist) document a comma. An empty `scopes`
37
+ * omits the parameter altogether.
38
+ */
39
+ scopeSeparator?: string;
34
40
  pkce?: boolean;
35
41
  /** Appended to the authorization URL's query string. */
36
42
  additionalParams?: Record<string, string>;
@@ -1,7 +1,7 @@
1
1
  export { wireTrigger, wireTriggerSource } from './trigger-runner.js';
2
- export { wireTriggerWebhookSource, receiveWebhookSourceRequest, dispatchWebhookSourceJob, runWebhookSourceLifecycle, declaredTriggerSources, reconcileTriggerSources, teardownTriggerSources, subscribedWebhookEvents, } from './webhook-source-runner.js';
3
- export type { WebhookSourceOutcome } from './webhook-source-runner.js';
4
- export { PIKKU_INCOMING_WEBHOOK_QUEUE_NAME } from './webhook-source.types.js';
5
- export type { CoreTriggerWebhookSource, TriggerEvent, WebhookCheckResult, WebhookLifecycleInput, WebhookReceiveResult, WebhookRequest, WebhookSetupResult, WebhookSourceJob, WebhookSourceMeta, WebhookSourceMethod, WebhookSourcesMeta, WebhookSourceState, WebhookTeardownInput, WebhookTeardownResult, } from './webhook-source.types.js';
2
+ export { wireTriggerWebhookSource, receiveWebhookSourceRequest, dispatchWebhookSourceJob, runWebhookSourceLifecycle, declaredTriggerSources, reconcileTriggerSources, enableTriggerSource, disableTriggerSource, reconcileWebhookRegistrations, teardownTriggerSources, subscribedWebhookEvents, } from './webhook-source-runner.js';
3
+ export type { OrphanedWebhookRegistration, WebhookRegistration, WebhookRegistrations, WebhookSourceOutcome, } from './webhook-source-runner.js';
4
+ export { PIKKU_INCOMING_WEBHOOK_QUEUE_NAME, webhookSecretCredentialName, } from './webhook-source.types.js';
5
+ export type { CoreTriggerWebhookSource, TriggerEvent, WebhookCheckResult, WebhookLifecycleInput, WebhookReceiveResult, WebhookRequest, WebhookSetupResult, WebhookSourceJob, WebhookSourceMeta, WebhookSourceMethod, WebhookSourcesMeta, WebhookSourceState, WebhookTeardownInput, WebhookTeardownResult, WebhookVerify, } from './webhook-source.types.js';
6
6
  export { PikkuTriggerService } from './pikku-trigger-service.js';
7
7
  export type { TriggerMeta, TriggerSourceMeta, CorePikkuTriggerFunction, CorePikkuTriggerFunctionConfig, CoreTrigger, } from './trigger.types.js';
@@ -1,4 +1,4 @@
1
1
  export { wireTrigger, wireTriggerSource } from './trigger-runner.js';
2
- export { wireTriggerWebhookSource, receiveWebhookSourceRequest, dispatchWebhookSourceJob, runWebhookSourceLifecycle, declaredTriggerSources, reconcileTriggerSources, teardownTriggerSources, subscribedWebhookEvents, } from './webhook-source-runner.js';
3
- export { PIKKU_INCOMING_WEBHOOK_QUEUE_NAME } from './webhook-source.types.js';
2
+ export { wireTriggerWebhookSource, receiveWebhookSourceRequest, dispatchWebhookSourceJob, runWebhookSourceLifecycle, declaredTriggerSources, reconcileTriggerSources, enableTriggerSource, disableTriggerSource, reconcileWebhookRegistrations, teardownTriggerSources, subscribedWebhookEvents, } from './webhook-source-runner.js';
3
+ export { PIKKU_INCOMING_WEBHOOK_QUEUE_NAME, webhookSecretCredentialName, } from './webhook-source.types.js';
4
4
  export { PikkuTriggerService } from './pikku-trigger-service.js';
@@ -9,7 +9,8 @@ export declare const subscribedWebhookEvents: (source: string) => string[];
9
9
  /**
10
10
  * The body of a webhook source's route: `receive`, validate, and queue every
11
11
  * event some trigger listens for. Events nobody listens for are answered and
12
- * dropped, so the provider does not retry them forever.
12
+ * dropped, so the provider does not retry them forever. With a
13
+ * `triggerSourceStore`, a source nobody enabled answers 404.
13
14
  */
14
15
  export declare const receiveWebhookSourceRequest: (sourceName: string, wire: {
15
16
  http?: PikkuHTTP;
@@ -44,15 +45,61 @@ export declare const runWebhookSourceLifecycle: ({ action, previous, singletonSe
44
45
  singletonServices?: CoreSingletonServices;
45
46
  }) => Promise<WebhookSourceOutcome[]>;
46
47
  /** The trigger sources this app declares, as the store is synced to. */
47
- export declare const declaredTriggerSources: () => DeclaredTriggerSource[];
48
+ export declare const declaredTriggerSources: (address?: LifecycleInput) => DeclaredTriggerSource[];
48
49
  /**
49
- * Registers every declared webhook source with its provider: `check`, then
50
+ * Registers every enabled webhook source with its provider: `check`, then
50
51
  * `setup` where it is missing or drifted, recording what was registered.
51
- * Run after a deployment goes live.
52
+ * A source nobody enabled is skipped. Run after a deployment goes live.
52
53
  */
53
54
  export declare const reconcileTriggerSources: ({ singletonServices, ...input }: LifecycleInput & {
54
55
  singletonServices?: CoreSingletonServices;
55
56
  }) => Promise<WebhookSourceOutcome[]>;
57
+ type SwitchInput = Partial<LifecycleInput> & {
58
+ name: string;
59
+ singletonServices?: CoreSingletonServices;
60
+ };
61
+ /**
62
+ * Turns a declared webhook source on and registers it with its provider, at
63
+ * the address given or the one the last deployment recorded.
64
+ */
65
+ export declare const enableTriggerSource: ({ singletonServices, ...input }: SwitchInput) => Promise<WebhookSourceOutcome>;
66
+ /**
67
+ * Turns a webhook source off: it stops receiving at once, then is removed
68
+ * from its provider. A failed teardown leaves it off and records the error.
69
+ */
70
+ export declare const disableTriggerSource: ({ singletonServices, ...input }: SwitchInput) => Promise<WebhookSourceOutcome>;
71
+ /** What one source registered with its provider, as a caller keeps it between runs. */
72
+ export type WebhookRegistration = {
73
+ url: string;
74
+ events: string[];
75
+ status: WebhookSourceOutcome['status'];
76
+ state?: WebhookSourceState;
77
+ /** The signing secrets `setup` stored, so a wiped credential store can be refilled without registering again. */
78
+ credentials?: Record<string, unknown>;
79
+ };
80
+ export type WebhookRegistrations = Record<string, WebhookRegistration>;
81
+ export type OrphanedWebhookRegistration = {
82
+ source: string;
83
+ url: string;
84
+ label: string;
85
+ };
86
+ /**
87
+ * Registers every declared webhook source against registrations the caller
88
+ * keeps rather than a `triggerSourceStore`, as `pikku dev` does in a file that
89
+ * outlives its database. A source whose url and events match what was
90
+ * registered is left alone without calling the provider, and its signing
91
+ * secrets are put back if the credential store lost them. Registrations no
92
+ * source declares any more are returned as orphans and kept, since only the
93
+ * code that set one up could tear it down.
94
+ */
95
+ export declare const reconcileWebhookRegistrations: ({ registrations, singletonServices, ...input }: LifecycleInput & {
96
+ registrations: WebhookRegistrations;
97
+ singletonServices?: CoreSingletonServices;
98
+ }) => Promise<{
99
+ registrations: WebhookRegistrations;
100
+ outcomes: WebhookSourceOutcome[];
101
+ orphans: OrphanedWebhookRegistration[];
102
+ }>;
56
103
  /**
57
104
  * Removes the named webhook sources from their providers and forgets them.
58
105
  * Run on the outgoing deployment for the sources the next release drops,
@@ -1,6 +1,8 @@
1
1
  import { getSingletonServices, pikkuState } from '../../pikku-state.js';
2
2
  import { addFunction, runPikkuFunc } from '../../function/function-runner.js';
3
- import { PikkuMissingMetaError } from '../../errors/errors.js';
3
+ import { PikkuMissingMetaError, UnauthorizedError, } from '../../errors/errors.js';
4
+ import { timingSafeStringEqual, verifyHmacSignature, verifyPublicKeySignature, } from '../../utils/hmac.js';
5
+ import { webhookSecretCredentialName } from './webhook-source.types.js';
4
6
  const LIFECYCLE = ['receive', 'check', 'setup', 'teardown'];
5
7
  export const wireTriggerWebhookSource = (source) => {
6
8
  const meta = pikkuState(null, 'trigger', 'webhookSourceMeta')[source.name];
@@ -59,6 +61,44 @@ const readRequest = async (http) => {
59
61
  query,
60
62
  };
61
63
  };
64
+ const signedWith = async (verify, request, secret, services) => {
65
+ if (typeof verify === 'function') {
66
+ return await verify(request, secret, services);
67
+ }
68
+ const header = (name, prefix = '') => {
69
+ const value = request.headers[name.toLowerCase()];
70
+ return value?.startsWith(prefix) ? value.slice(prefix.length) : undefined;
71
+ };
72
+ if ('hmac' in verify) {
73
+ const { header: name, prefix, algorithm, encoding, secretEncoding } = verify.hmac;
74
+ return verifyHmacSignature(secret, header(name, prefix), algorithm, request.body, encoding, secretEncoding);
75
+ }
76
+ if ('token' in verify) {
77
+ const token = header(verify.token.header, verify.token.prefix);
78
+ return !!token && timingSafeStringEqual(token, secret);
79
+ }
80
+ const { header: name, ...options } = verify.publicKey;
81
+ return verifyPublicKeySignature(secret, header(name), request.body, options);
82
+ };
83
+ /**
84
+ * Whether the request was checked against the source's signing secret. Throws
85
+ * when it was checked and failed. A source without `verify` checks in its own
86
+ * `receive`, and a request without a body has nothing signed to check.
87
+ */
88
+ const verifyRequest = async (source, request, services) => {
89
+ if (!source?.verify)
90
+ return true;
91
+ if (request.body.length === 0)
92
+ return false;
93
+ const secret = await services.credentialService?.get(source.credential ?? webhookSecretCredentialName(source.name));
94
+ if (typeof secret !== 'string' || !secret) {
95
+ throw new UnauthorizedError(`The ${source.name} webhook source has no signing secret`);
96
+ }
97
+ if (!(await signedWith(source.verify, request, secret, services))) {
98
+ throw new UnauthorizedError(`Invalid ${source.name} webhook signature`);
99
+ }
100
+ return true;
101
+ };
62
102
  const validateEvents = async (source, events, logger) => {
63
103
  const valid = [];
64
104
  for (const event of events) {
@@ -79,13 +119,19 @@ const validateEvents = async (source, events, logger) => {
79
119
  /**
80
120
  * The body of a webhook source's route: `receive`, validate, and queue every
81
121
  * event some trigger listens for. Events nobody listens for are answered and
82
- * dropped, so the provider does not retry them forever.
122
+ * dropped, so the provider does not retry them forever. With a
123
+ * `triggerSourceStore`, a source nobody enabled answers 404.
83
124
  */
84
125
  export const receiveWebhookSourceRequest = async (sourceName, wire) => {
85
126
  const singletonServices = getSingletonServices();
86
127
  const meta = getSourceMeta(sourceName);
128
+ const store = singletonServices.triggerSourceStore;
129
+ if (store && !(await store.getTriggerSource(sourceName))?.enabled) {
130
+ return new Response(null, { status: 404 });
131
+ }
87
132
  const source = pikkuState(null, 'trigger', 'webhookSources').get(sourceName);
88
133
  const request = await readRequest(wire.http);
134
+ const verified = await verifyRequest(source, request, singletonServices);
89
135
  const result = meta.receive
90
136
  ? await runSourceStep(singletonServices, sourceName, meta.receive, request)
91
137
  : {
@@ -107,6 +153,9 @@ export const receiveWebhookSourceRequest = async (sourceName, wire) => {
107
153
  : JSON.stringify(body);
108
154
  return new Response(text, { status, headers });
109
155
  }
156
+ if (!verified && result.events.length > 0) {
157
+ throw new UnauthorizedError(`The ${sourceName} webhook source received events in an unsigned request`);
158
+ }
110
159
  const triggers = pikkuState(null, 'trigger', 'meta');
111
160
  const listened = result.events.filter((event) => triggers[triggerName(sourceName, event.name)]);
112
161
  const events = await validateEvents(source, listened, singletonServices.logger);
@@ -152,9 +201,12 @@ export const dispatchWebhookSourceJob = async (job) => {
152
201
  const recordAttempt = (singletonServices, receiptId, trigger, error) => singletonServices.incomingWebhookService
153
202
  ?.recordAttempt(receiptId, { trigger, ...(error ? { error } : {}) })
154
203
  .catch((storeError) => singletonServices.logger.error(`Failed to record the attempt for webhook receipt ${receiptId}`, storeError));
155
- const runSourceLifecycleStep = async (meta, action, { baseUrl, labelPrefix }, previous, singletonServices) => {
156
- const url = `${baseUrl.replace(/\/+$/, '')}${meta.route}`;
157
- const label = `${labelPrefix}:${meta.name}`;
204
+ const sourceAddress = (meta, { baseUrl, labelPrefix }) => ({
205
+ url: `${baseUrl.replace(/\/+$/, '')}${meta.route}`,
206
+ label: `${labelPrefix}:${meta.name}`,
207
+ });
208
+ const runSourceLifecycleStep = async (meta, action, lifecycleInput, previous, singletonServices) => {
209
+ const { url, label } = sourceAddress(meta, lifecycleInput);
158
210
  const input = {
159
211
  url,
160
212
  label,
@@ -217,31 +269,165 @@ const requireTriggerSourceStore = (singletonServices) => {
217
269
  return store;
218
270
  };
219
271
  /** The trigger sources this app declares, as the store is synced to. */
220
- export const declaredTriggerSources = () => Object.keys(pikkuState(null, 'trigger', 'webhookSourceMeta')).map((name) => ({
272
+ export const declaredTriggerSources = (address) => Object.keys(pikkuState(null, 'trigger', 'webhookSourceMeta')).map((name) => ({
221
273
  name,
222
274
  kind: 'webhook',
275
+ ...address,
223
276
  }));
224
277
  const detailOf = (outcome) => outcome.instructions ?? outcome.error ?? outcome.reason ?? null;
278
+ const registerTriggerSource = async (store, meta, input, previous, singletonServices) => {
279
+ const outcome = await runSourceLifecycleStep(meta, 'setup', input, previous, singletonServices);
280
+ await store.recordTriggerSource(meta.name, {
281
+ status: outcome.status,
282
+ ...(outcome.state ? { state: outcome.state } : {}),
283
+ detail: detailOf(outcome),
284
+ });
285
+ return outcome;
286
+ };
225
287
  /**
226
- * Registers every declared webhook source with its provider: `check`, then
288
+ * Registers every enabled webhook source with its provider: `check`, then
227
289
  * `setup` where it is missing or drifted, recording what was registered.
228
- * Run after a deployment goes live.
290
+ * A source nobody enabled is skipped. Run after a deployment goes live.
229
291
  */
230
292
  export const reconcileTriggerSources = async ({ singletonServices = getSingletonServices(), ...input }) => {
231
293
  const store = requireTriggerSourceStore(singletonServices);
232
- await store.syncTriggerSources(declaredTriggerSources());
294
+ await store.syncTriggerSources(declaredTriggerSources({
295
+ baseUrl: input.baseUrl,
296
+ labelPrefix: input.labelPrefix,
297
+ }));
233
298
  const outcomes = [];
234
299
  for (const meta of Object.values(pikkuState(null, 'trigger', 'webhookSourceMeta'))) {
235
300
  const row = await store.getTriggerSource(meta.name);
236
- const outcome = await runSourceLifecycleStep(meta, 'setup', input, row?.state ?? undefined, singletonServices);
237
- await store.recordTriggerSource(meta.name, {
301
+ if (!row?.enabled) {
302
+ outcomes.push({
303
+ source: meta.name,
304
+ url: sourceAddress(meta, input).url,
305
+ status: 'skipped',
306
+ reason: 'disabled',
307
+ });
308
+ continue;
309
+ }
310
+ outcomes.push(await registerTriggerSource(store, meta, input, row.state ?? undefined, singletonServices));
311
+ }
312
+ return outcomes;
313
+ };
314
+ /** The address given, or the one the last deployment synced onto the row. */
315
+ const switchAddress = (row, { baseUrl, labelPrefix, name }) => {
316
+ const address = {
317
+ baseUrl: baseUrl ?? row?.baseUrl,
318
+ labelPrefix: labelPrefix ?? row?.labelPrefix,
319
+ };
320
+ if (!address.baseUrl || !address.labelPrefix) {
321
+ throw new Error(`Trigger source '${name}' has no address yet: pass baseUrl and labelPrefix, or deploy so reconcile records them.`);
322
+ }
323
+ return address;
324
+ };
325
+ /**
326
+ * Turns a declared webhook source on and registers it with its provider, at
327
+ * the address given or the one the last deployment recorded.
328
+ */
329
+ export const enableTriggerSource = async ({ singletonServices = getSingletonServices(), ...input }) => {
330
+ const store = requireTriggerSourceStore(singletonServices);
331
+ const meta = getSourceMeta(input.name);
332
+ await store.syncTriggerSources(declaredTriggerSources());
333
+ const row = await store.getTriggerSource(input.name);
334
+ const address = switchAddress(row, input);
335
+ await store.setTriggerSourceEnabled(input.name, true);
336
+ return registerTriggerSource(store, meta, address, row?.state ?? undefined, singletonServices);
337
+ };
338
+ /**
339
+ * Turns a webhook source off: it stops receiving at once, then is removed
340
+ * from its provider. A failed teardown leaves it off and records the error.
341
+ */
342
+ export const disableTriggerSource = async ({ singletonServices = getSingletonServices(), ...input }) => {
343
+ const store = requireTriggerSourceStore(singletonServices);
344
+ const meta = getSourceMeta(input.name);
345
+ const row = await store.getTriggerSource(input.name);
346
+ const address = switchAddress(row, input);
347
+ await store.setTriggerSourceEnabled(input.name, false);
348
+ const outcome = await runSourceLifecycleStep(meta, 'teardown', address, row?.state ?? undefined, singletonServices);
349
+ await store.recordTriggerSource(input.name, {
350
+ status: outcome.status,
351
+ ...(outcome.status === 'failed' ? {} : { state: null }),
352
+ detail: detailOf(outcome),
353
+ });
354
+ return outcome;
355
+ };
356
+ /** The singleton credentials the package owning a source's `setup` declares: where its signing secret goes. */
357
+ const sourceCredentialNames = (meta) => {
358
+ if (!meta.setup)
359
+ return [];
360
+ const namespace = meta.setup.includes(':') ? meta.setup.split(':')[0] : '';
361
+ const packageName = pikkuState(null, 'addons', 'packages').get(namespace)?.package ?? null;
362
+ const declared = pikkuState(packageName, 'package', 'credentialsMeta') ?? {};
363
+ return Object.entries(declared)
364
+ .filter(([, credential]) => credential.type === 'singleton' && !credential.oauth2)
365
+ .map(([name]) => name);
366
+ };
367
+ const sameEvents = (a, b) => a.length === b.length && a.every((event) => b.includes(event));
368
+ /**
369
+ * Registers every declared webhook source against registrations the caller
370
+ * keeps rather than a `triggerSourceStore`, as `pikku dev` does in a file that
371
+ * outlives its database. A source whose url and events match what was
372
+ * registered is left alone without calling the provider, and its signing
373
+ * secrets are put back if the credential store lost them. Registrations no
374
+ * source declares any more are returned as orphans and kept, since only the
375
+ * code that set one up could tear it down.
376
+ */
377
+ export const reconcileWebhookRegistrations = async ({ registrations, singletonServices = getSingletonServices(), ...input }) => {
378
+ const credentialService = singletonServices.credentialService;
379
+ const sources = pikkuState(null, 'trigger', 'webhookSourceMeta');
380
+ const next = {};
381
+ const outcomes = [];
382
+ for (const meta of Object.values(sources)) {
383
+ const { url } = sourceAddress(meta, input);
384
+ const events = subscribedWebhookEvents(meta.name);
385
+ const previous = registrations[meta.name];
386
+ const names = sourceCredentialNames(meta);
387
+ if (credentialService) {
388
+ for (const [name, value] of Object.entries(previous?.credentials ?? {})) {
389
+ if (!(await credentialService.has(name))) {
390
+ await credentialService.set(name, value);
391
+ }
392
+ }
393
+ }
394
+ if (previous &&
395
+ previous.status !== 'failed' &&
396
+ previous.url === url &&
397
+ sameEvents(previous.events, events)) {
398
+ next[meta.name] = previous;
399
+ outcomes.push({ source: meta.name, url, status: 'unchanged' });
400
+ continue;
401
+ }
402
+ const outcome = await runSourceLifecycleStep(meta, 'setup', input, previous?.state, singletonServices);
403
+ const credentials = {};
404
+ for (const name of names) {
405
+ const value = await credentialService?.get(name);
406
+ if (value != null)
407
+ credentials[name] = value;
408
+ }
409
+ const state = outcome.state ?? previous?.state;
410
+ next[meta.name] = {
411
+ url,
412
+ events,
238
413
  status: outcome.status,
239
- ...(outcome.state ? { state: outcome.state } : {}),
240
- detail: detailOf(outcome),
241
- });
414
+ ...(state ? { state } : {}),
415
+ ...(Object.keys(credentials).length ? { credentials } : {}),
416
+ };
242
417
  outcomes.push(outcome);
243
418
  }
244
- return outcomes;
419
+ const orphans = [];
420
+ for (const [source, registration] of Object.entries(registrations)) {
421
+ if (sources[source])
422
+ continue;
423
+ next[source] = registration;
424
+ orphans.push({
425
+ source,
426
+ url: registration.url,
427
+ label: `${input.labelPrefix}:${source}`,
428
+ });
429
+ }
430
+ return { registrations: next, outcomes, orphans };
245
431
  };
246
432
  /**
247
433
  * Removes the named webhook sources from their providers and forgets them.