@pikku/core 0.12.127 → 0.12.128

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/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## 0.12.128
2
+
3
+ ### Patch Changes
4
+
5
+ - 658f047: A function whose services are all singletons no longer builds wire services, so an addon's credential-bound wire factory does not run for, or fail, a webhook `receive` step that needs no connection. The inspector decides this from the `Services` type and records it as `singletonServicesOnly` on the function's runtime meta; the runner only reads the flag.
6
+ - de63ab2: Store a workflow's graph when a run is created, so a run suspended across a
7
+ deploy that changes the definition resumes on the graph it started on instead
8
+ of failing with `VERSION_NOT_FOUND`. Nothing called `registerWorkflowVersions`,
9
+ so the versions table stayed empty and the replay fallback could never find a
10
+ version.
11
+ - 658f047: A declared webhook source is a registered one. `reconcileTriggerSources` sets up every declared source with its provider (check, then setup where missing or drifted) and `teardownTriggerSources` removes named ones, recording what was registered in a `TriggerSourceStore` (in-memory, or `KyselyTriggerSourceStore` on `pikku_trigger_source`). The admin addon exposes list, reconcile, teardown and forget under the new `admin:triggers` scopes.
12
+ - 658f047: Webhook signing secrets live in the credential store. `WebhookSigningSecret.fromCredential(provider, credentialService, name)` reads the secret per delivery via `load()`, so a `setup` step (or a handshake) that stores a new one with `credentialService.set` takes effect without a deploy.
13
+
14
+ Breaking: `setup` no longer returns `secret`, `wireTriggerWebhookSource` no longer takes `secret`, lifecycle outcomes drop `secretName`/`secret`, and `pikku webhooks setup` drops `--secretsOut`.
15
+
16
+ - 658f047: `WebhookSigningSecret` in `@pikku/core/hmac`: holds a provider's webhook signing secret in a singleton service and checks HMAC, shared-token and public-key signatures for a `receive` step, refusing everything when the secret was never provisioned.
17
+ - 658f047: A webhook source's `method` may be a list, mounting one route per method, for providers that verify the URL with a GET and deliver events with a POST (WhatsApp, Strava, Onfleet, Mailchimp), or a HEAD (Trello, Mandrill, SurveyMonkey).
18
+
1
19
  ## 0.12.127
2
20
 
3
21
  ### Patch Changes
@@ -109,6 +109,13 @@ export type FunctionRuntimeMeta = {
109
109
  audit?: {
110
110
  durability: AuditDurability;
111
111
  };
112
+ /**
113
+ * Set by the inspector when every service the function destructures is a
114
+ * singleton, so the runner skips `createWireServices` for it. Absent means
115
+ * the function reads a wire service, takes `services` whole, or the app's
116
+ * `Services` type did not resolve — in each case the wire services are built.
117
+ */
118
+ singletonServicesOnly?: boolean;
112
119
  version?: number;
113
120
  approvalRequired?: boolean;
114
121
  approvalDescription?: string;
@@ -256,7 +256,9 @@ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServic
256
256
  let invocationAuditLog;
257
257
  let invocationAnalytics;
258
258
  try {
259
- wireServices = (await resolvedCreateWireServices?.(resolvedSingletonServices, invocationWire));
259
+ wireServices = funcMeta.singletonServicesOnly
260
+ ? undefined
261
+ : (await resolvedCreateWireServices?.(resolvedSingletonServices, invocationWire));
260
262
  let services = wireServices && Object.keys(wireServices).length > 0
261
263
  ? { ...resolvedSingletonServices, ...wireServices }
262
264
  : resolvedSingletonServices;
@@ -16,6 +16,7 @@ export { IncomingWebhookService, type IncomingWebhookAttempt, type IncomingWebho
16
16
  export { REMOTE_JOBS_SECRET_HEADER, REMOTE_JOBS_SECRET_VARIABLE, REMOTE_QUEUE_JOB_PATH, REMOTE_SCHEDULER_JOB_PATH, pikkuRemoteQueueJobFunc, pikkuRemoteScheduledJobFunc, type RemoteQueueJobData, type RemoteScheduledJobData, } from './remote-jobs.js';
17
17
  export { InMemoryQueueService } from './in-memory-queue-service.js';
18
18
  export { InMemoryTriggerService } from './in-memory-trigger-service.js';
19
+ export { InMemoryTriggerSourceStore, type DeclaredTriggerSource, type TriggerSourceKind, type TriggerSourceResult, type TriggerSourceRow, type TriggerSourceStore, } from './trigger-source-store.js';
19
20
  export { InMemoryAgentRunStateService } from './in-memory-agent-run-state-service.js';
20
21
  export { LocalGatewayService } from './local-gateway-service.js';
21
22
  export { FileScenarioRunStore, scenarioArtifactContentType, scenarioRunSummary, type FileScenarioRunStoreOptions, } from './file-scenario-run-store.js';
@@ -16,6 +16,7 @@ export { IncomingWebhookService, } from './incoming-webhook-service.js';
16
16
  export { REMOTE_JOBS_SECRET_HEADER, REMOTE_JOBS_SECRET_VARIABLE, REMOTE_QUEUE_JOB_PATH, REMOTE_SCHEDULER_JOB_PATH, pikkuRemoteQueueJobFunc, pikkuRemoteScheduledJobFunc, } from './remote-jobs.js';
17
17
  export { InMemoryQueueService } from './in-memory-queue-service.js';
18
18
  export { InMemoryTriggerService } from './in-memory-trigger-service.js';
19
+ export { InMemoryTriggerSourceStore, } from './trigger-source-store.js';
19
20
  export { InMemoryAgentRunStateService } from './in-memory-agent-run-state-service.js';
20
21
  export { LocalGatewayService } from './local-gateway-service.js';
21
22
  export { FileScenarioRunStore, scenarioArtifactContentType, scenarioRunSummary, } from './file-scenario-run-store.js';
@@ -0,0 +1,62 @@
1
+ import type { WebhookSourceState } from '../wirings/trigger/webhook-source.types.js';
2
+ export type TriggerSourceKind = 'webhook';
3
+ export type DeclaredTriggerSource = {
4
+ name: string;
5
+ kind: TriggerSourceKind;
6
+ };
7
+ /**
8
+ * What was registered with the provider for one declared trigger source.
9
+ * `state` is what the last `setup` returned — provider identifiers only; any
10
+ * secret it issued lives in the credential service.
11
+ */
12
+ export type TriggerSourceRow = DeclaredTriggerSource & {
13
+ /** False once the declaration has gone from code without a teardown: an orphan. */
14
+ declared: boolean;
15
+ status: string | null;
16
+ state: WebhookSourceState | null;
17
+ /** The last step's instructions or error. */
18
+ detail: string | null;
19
+ updatedAt: string | null;
20
+ };
21
+ export type TriggerSourceResult = {
22
+ status: string;
23
+ state?: WebhookSourceState | null;
24
+ detail?: string | null;
25
+ };
26
+ /** What each declared trigger source registered with its provider. */
27
+ export interface TriggerSourceStore {
28
+ /** Upserts the declared sources and marks the rest undeclared. */
29
+ syncTriggerSources(sources: DeclaredTriggerSource[]): Promise<void>;
30
+ listTriggerSources(): Promise<TriggerSourceRow[]>;
31
+ getTriggerSource(name: string): Promise<TriggerSourceRow | null>;
32
+ recordTriggerSource(name: string, result: TriggerSourceResult): Promise<void>;
33
+ deleteTriggerSource(name: string): Promise<void>;
34
+ }
35
+ export declare class InMemoryTriggerSourceStore implements TriggerSourceStore {
36
+ private rows;
37
+ syncTriggerSources(sources: DeclaredTriggerSource[]): Promise<void>;
38
+ listTriggerSources(): Promise<{
39
+ name: string;
40
+ kind: TriggerSourceKind;
41
+ /** False once the declaration has gone from code without a teardown: an orphan. */
42
+ declared: boolean;
43
+ status: string | null;
44
+ state: WebhookSourceState | null;
45
+ /** The last step's instructions or error. */
46
+ detail: string | null;
47
+ updatedAt: string | null;
48
+ }[]>;
49
+ getTriggerSource(name: string): Promise<{
50
+ name: string;
51
+ kind: TriggerSourceKind;
52
+ /** False once the declaration has gone from code without a teardown: an orphan. */
53
+ declared: boolean;
54
+ status: string | null;
55
+ state: WebhookSourceState | null;
56
+ /** The last step's instructions or error. */
57
+ detail: string | null;
58
+ updatedAt: string | null;
59
+ } | null>;
60
+ recordTriggerSource(name: string, result: TriggerSourceResult): Promise<void>;
61
+ deleteTriggerSource(name: string): Promise<void>;
62
+ }
@@ -0,0 +1,47 @@
1
+ export class InMemoryTriggerSourceStore {
2
+ rows = new Map();
3
+ async syncTriggerSources(sources) {
4
+ const names = new Set(sources.map((s) => s.name));
5
+ for (const row of this.rows.values()) {
6
+ row.declared = names.has(row.name);
7
+ }
8
+ for (const { name, kind } of sources) {
9
+ const row = this.rows.get(name);
10
+ if (row) {
11
+ row.kind = kind;
12
+ continue;
13
+ }
14
+ this.rows.set(name, {
15
+ name,
16
+ kind,
17
+ declared: true,
18
+ status: null,
19
+ state: null,
20
+ detail: null,
21
+ updatedAt: null,
22
+ });
23
+ }
24
+ }
25
+ async listTriggerSources() {
26
+ return [...this.rows.values()]
27
+ .sort((a, b) => a.name.localeCompare(b.name))
28
+ .map((row) => ({ ...row }));
29
+ }
30
+ async getTriggerSource(name) {
31
+ const row = this.rows.get(name);
32
+ return row ? { ...row } : null;
33
+ }
34
+ async recordTriggerSource(name, result) {
35
+ const row = this.rows.get(name);
36
+ if (!row)
37
+ throw new Error(`Unknown trigger source: ${name}`);
38
+ row.status = result.status;
39
+ if (result.state !== undefined)
40
+ row.state = result.state;
41
+ row.detail = result.detail ?? null;
42
+ row.updatedAt = new Date().toISOString();
43
+ }
44
+ async deleteTriggerSource(name) {
45
+ this.rows.delete(name);
46
+ }
47
+ }
@@ -1,4 +1,5 @@
1
1
  import type { IncomingWebhookService } from '../services/incoming-webhook-service.js';
2
+ import type { TriggerSourceStore } from '../services/trigger-source-store.js';
2
3
  import type { Logger, LogLevel } from '../services/logger.js';
3
4
  import type { VariablesService } from '../services/variables-service.js';
4
5
  import type { SecretService } from '../services/secret-service.js';
@@ -136,6 +137,8 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
136
137
  webhookService?: WebhookService;
137
138
  /** Queues what webhook trigger sources receive. Required once any `wireTriggerWebhookSource` is wired. */
138
139
  incomingWebhookService?: IncomingWebhookService;
140
+ /** Which trigger sources an operator enabled, and what enabling registered. */
141
+ triggerSourceStore?: TriggerSourceStore;
139
142
  metaService?: MetaService;
140
143
  /**
141
144
  * Where virtual-user runs are recorded. A run is dispatched and answered for
@@ -1,3 +1,4 @@
1
+ import type { CredentialService } from '../services/credential-service.js';
1
2
  /**
2
3
  * HMAC-SHA256 of a payload, hex-encoded. Senders wrap this in their own scheme
3
4
  * prefix (`sha256=`, `v0=`, …).
@@ -8,3 +9,43 @@ export declare function hmacSha256Hex(secret: string, payload: string): string;
8
9
  * where `timingSafeEqual` itself throws.
9
10
  */
10
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';
15
+ type SecretSource = string | null | (() => Promise<string | null>);
16
+ /**
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.
24
+ */
25
+ export declare class WebhookSigningSecret {
26
+ private readonly provider;
27
+ private readonly secret;
28
+ constructor(provider: string, secret: SecretSource);
29
+ /**
30
+ * The secret a `setup` step or a handshake stored in the credential store,
31
+ * so a new one takes effect without a deploy.
32
+ */
33
+ static fromCredential(provider: string, credentials: CredentialService | undefined, name: string): WebhookSigningSecret;
34
+ get configured(): boolean;
35
+ /** The secret as it is now, to check one delivery against. */
36
+ load(): Promise<WebhookSigningSecret>;
37
+ /** For handshakes that answer with a digest, such as Zoom's URL validation. */
38
+ hmac(algorithm: HmacAlgorithm, payload: WebhookPayload, encoding: 'hex' | 'base64', secretEncoding?: SecretEncoding): string;
39
+ /** Throws unless `signature` is the HMAC of `payload` under the secret. */
40
+ verifyHmac(signature: string | undefined, algorithm: HmacAlgorithm, payload: WebhookPayload, encoding: 'hex' | 'base64', secretEncoding?: SecretEncoding): void;
41
+ /** For providers that send the shared secret itself rather than a signature. */
42
+ verifyToken(token: string | undefined): void;
43
+ /** For providers that sign with a private key: the secret is their public key, as PEM. */
44
+ verifyPublicKey(signature: string | undefined, payload: WebhookPayload, options?: {
45
+ algorithm?: string;
46
+ dsaEncoding?: 'der' | 'ieee-p1363';
47
+ }): void;
48
+ private require;
49
+ private rejected;
50
+ }
51
+ export {};
@@ -1,4 +1,5 @@
1
- import { createHmac, timingSafeEqual } from 'node:crypto';
1
+ import { createHmac, createVerify, timingSafeEqual } from 'node:crypto';
2
+ import { UnauthorizedError } from '../errors/errors.js';
2
3
  /**
3
4
  * HMAC-SHA256 of a payload, hex-encoded. Senders wrap this in their own scheme
4
5
  * prefix (`sha256=`, `v0=`, …).
@@ -18,3 +19,83 @@ export function timingSafeStringEqual(a, b) {
18
19
  return false;
19
20
  }
20
21
  }
22
+ /**
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.
30
+ */
31
+ export class WebhookSigningSecret {
32
+ provider;
33
+ secret;
34
+ constructor(provider, secret) {
35
+ this.provider = provider;
36
+ this.secret = secret;
37
+ }
38
+ /**
39
+ * The secret a `setup` step or a handshake stored in the credential store,
40
+ * so a new one takes effect without a deploy.
41
+ */
42
+ static fromCredential(provider, credentials, name) {
43
+ return new WebhookSigningSecret(provider, async () => credentials ? credentials.get(name) : null);
44
+ }
45
+ get configured() {
46
+ return typeof this.secret === 'string';
47
+ }
48
+ /** The secret as it is now, to check one delivery against. */
49
+ async load() {
50
+ if (typeof this.secret !== 'function') {
51
+ return this;
52
+ }
53
+ return new WebhookSigningSecret(this.provider, await this.secret());
54
+ }
55
+ /** For handshakes that answer with a digest, such as Zoom's URL validation. */
56
+ hmac(algorithm, payload, encoding, secretEncoding = 'utf8') {
57
+ return createHmac(algorithm, Buffer.from(this.require(), secretEncoding))
58
+ .update(payload)
59
+ .digest(encoding);
60
+ }
61
+ /** Throws unless `signature` is the HMAC of `payload` under the secret. */
62
+ verifyHmac(signature, algorithm, payload, encoding, secretEncoding = 'utf8') {
63
+ const expected = this.hmac(algorithm, payload, encoding, secretEncoding);
64
+ if (!signature || !timingSafeStringEqual(signature, expected)) {
65
+ throw this.rejected();
66
+ }
67
+ }
68
+ /** For providers that send the shared secret itself rather than a signature. */
69
+ verifyToken(token) {
70
+ if (!token || !timingSafeStringEqual(token, this.require())) {
71
+ throw this.rejected();
72
+ }
73
+ }
74
+ /** For providers that sign with a private key: the secret is their public key, as PEM. */
75
+ 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) {
89
+ throw this.rejected();
90
+ }
91
+ }
92
+ require() {
93
+ if (typeof this.secret !== 'string') {
94
+ throw new UnauthorizedError(`The ${this.provider} webhook receiver has no signing secret`);
95
+ }
96
+ return this.secret;
97
+ }
98
+ rejected() {
99
+ return new UnauthorizedError(`Invalid ${this.provider} webhook signature`);
100
+ }
101
+ }
@@ -1,7 +1,7 @@
1
1
  export { wireTrigger, wireTriggerSource } from './trigger-runner.js';
2
- export { wireTriggerWebhookSource, receiveWebhookSourceRequest, dispatchWebhookSourceJob, runWebhookSourceLifecycle, subscribedWebhookEvents, } from './webhook-source-runner.js';
2
+ export { wireTriggerWebhookSource, receiveWebhookSourceRequest, dispatchWebhookSourceJob, runWebhookSourceLifecycle, declaredTriggerSources, reconcileTriggerSources, teardownTriggerSources, subscribedWebhookEvents, } from './webhook-source-runner.js';
3
3
  export type { WebhookSourceOutcome } from './webhook-source-runner.js';
4
4
  export { PIKKU_INCOMING_WEBHOOK_QUEUE_NAME } from './webhook-source.types.js';
5
- export type { CoreTriggerWebhookSource, TriggerEvent, WebhookCheckResult, WebhookLifecycleInput, WebhookReceiveResult, WebhookRequest, WebhookSetupResult, WebhookSourceJob, WebhookSourceMeta, WebhookSourcesMeta, WebhookSourceState, WebhookTeardownInput, WebhookTeardownResult, } 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';
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, subscribedWebhookEvents, } from './webhook-source-runner.js';
2
+ export { wireTriggerWebhookSource, receiveWebhookSourceRequest, dispatchWebhookSourceJob, runWebhookSourceLifecycle, declaredTriggerSources, reconcileTriggerSources, teardownTriggerSources, subscribedWebhookEvents, } from './webhook-source-runner.js';
3
3
  export { PIKKU_INCOMING_WEBHOOK_QUEUE_NAME } from './webhook-source.types.js';
4
4
  export { PikkuTriggerService } from './pikku-trigger-service.js';
@@ -1,6 +1,7 @@
1
1
  import type { StandardSchemaV1 } from '@standard-schema/spec';
2
2
  import type { CoreSingletonServices } from '../../types/core.types.js';
3
3
  import type { PikkuHTTP } from '../http/http.types.js';
4
+ import type { DeclaredTriggerSource } from '../../services/trigger-source-store.js';
4
5
  import type { CoreTriggerWebhookSource, WebhookCheckResult, WebhookSetupResult, WebhookSourceJob, WebhookSourceState, WebhookTeardownResult } from './webhook-source.types.js';
5
6
  export declare const wireTriggerWebhookSource: <Events extends Record<string, StandardSchemaV1>>(source: CoreTriggerWebhookSource<Events>) => void;
6
7
  /** The declared events some trigger is wired to: what `setup` registers with the provider. */
@@ -23,23 +24,42 @@ export type WebhookSourceOutcome = {
23
24
  status: WebhookCheckResult['status'] | WebhookSetupResult['status'] | WebhookTeardownResult['status'] | 'skipped' | 'failed';
24
25
  reason?: string;
25
26
  state?: WebhookSourceState;
26
- /** The name the produced signing secret must be stored under. */
27
- secretName?: string;
28
- secret?: string;
29
27
  instructions?: string;
30
28
  error?: string;
31
29
  };
30
+ type LifecycleInput = {
31
+ /** Where the app's routes are served, e.g. `https://shop.example.com/api`. */
32
+ baseUrl: string;
33
+ labelPrefix: string;
34
+ };
32
35
  /**
33
36
  * Runs one lifecycle step for every webhook source, as `pikku webhooks
34
- * status | setup | teardown` does at deploy. `setup` runs only where `check`
35
- * does not report `ok`. One failing source does not stop the rest.
37
+ * status | setup | teardown` does. `setup` runs only where `check` does not
38
+ * report `ok`. One failing source does not stop the rest.
36
39
  */
37
- export declare const runWebhookSourceLifecycle: ({ action, baseUrl, labelPrefix, previous, singletonServices, }: {
40
+ export declare const runWebhookSourceLifecycle: ({ action, previous, singletonServices, ...input }: LifecycleInput & {
38
41
  action: 'check' | 'setup' | 'teardown';
39
- /** Where the app's routes are served, e.g. `https://shop.example.com/api`. */
40
- baseUrl: string;
41
- labelPrefix: string;
42
42
  /** What the last `setup` returned as `state`, by source name. */
43
43
  previous?: Record<string, WebhookSourceState>;
44
44
  singletonServices?: CoreSingletonServices;
45
45
  }) => Promise<WebhookSourceOutcome[]>;
46
+ /** The trigger sources this app declares, as the store is synced to. */
47
+ export declare const declaredTriggerSources: () => DeclaredTriggerSource[];
48
+ /**
49
+ * Registers every declared webhook source with its provider: `check`, then
50
+ * `setup` where it is missing or drifted, recording what was registered.
51
+ * Run after a deployment goes live.
52
+ */
53
+ export declare const reconcileTriggerSources: ({ singletonServices, ...input }: LifecycleInput & {
54
+ singletonServices?: CoreSingletonServices;
55
+ }) => Promise<WebhookSourceOutcome[]>;
56
+ /**
57
+ * Removes the named webhook sources from their providers and forgets them.
58
+ * Run on the outgoing deployment for the sources the next release drops,
59
+ * since only the code that set a source up can tear it down.
60
+ */
61
+ export declare const teardownTriggerSources: ({ names, singletonServices, ...input }: LifecycleInput & {
62
+ names: string[];
63
+ singletonServices?: CoreSingletonServices;
64
+ }) => Promise<WebhookSourceOutcome[]>;
65
+ export {};
@@ -152,70 +152,121 @@ export const dispatchWebhookSourceJob = async (job) => {
152
152
  const recordAttempt = (singletonServices, receiptId, trigger, error) => singletonServices.incomingWebhookService
153
153
  ?.recordAttempt(receiptId, { trigger, ...(error ? { error } : {}) })
154
154
  .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}`;
158
+ const input = {
159
+ url,
160
+ label,
161
+ events: subscribedWebhookEvents(meta.name),
162
+ ...(previous ? { previous } : {}),
163
+ };
164
+ const outcome = (status, extra = {}) => ({ source: meta.name, url, status, ...extra });
165
+ try {
166
+ if (action === 'teardown') {
167
+ if (!meta.teardown)
168
+ return outcome('skipped');
169
+ const result = await runSourceStep(singletonServices, meta.name, meta.teardown, { label, ...(previous ? { previous } : {}) });
170
+ return outcome(result.status);
171
+ }
172
+ if (meta.check) {
173
+ const checked = await runSourceStep(singletonServices, meta.name, meta.check, input);
174
+ if (action === 'check' || checked.status === 'ok') {
175
+ return outcome(checked.status === 'ok' && action === 'setup'
176
+ ? 'unchanged'
177
+ : checked.status, checked.status === 'drifted' ? { reason: checked.reason } : {});
178
+ }
179
+ }
180
+ else if (action === 'check') {
181
+ return outcome('skipped');
182
+ }
183
+ if (!meta.setup) {
184
+ return outcome('manual', {
185
+ instructions: `Register ${url} with the provider for: ${input.events.join(', ') || 'every event'}.`,
186
+ });
187
+ }
188
+ const result = await runSourceStep(singletonServices, meta.name, meta.setup, input);
189
+ if (result.status === 'manual') {
190
+ return outcome('manual', { instructions: result.instructions });
191
+ }
192
+ return outcome(result.status, result.state ? { state: result.state } : {});
193
+ }
194
+ catch (error) {
195
+ return outcome('failed', {
196
+ error: error instanceof Error ? error.message : String(error),
197
+ });
198
+ }
199
+ };
155
200
  /**
156
201
  * Runs one lifecycle step for every webhook source, as `pikku webhooks
157
- * status | setup | teardown` does at deploy. `setup` runs only where `check`
158
- * does not report `ok`. One failing source does not stop the rest.
202
+ * status | setup | teardown` does. `setup` runs only where `check` does not
203
+ * report `ok`. One failing source does not stop the rest.
159
204
  */
160
- export const runWebhookSourceLifecycle = async ({ action, baseUrl, labelPrefix, previous = {}, singletonServices = getSingletonServices(), }) => {
161
- const base = baseUrl.replace(/\/+$/, '');
205
+ export const runWebhookSourceLifecycle = async ({ action, previous = {}, singletonServices = getSingletonServices(), ...input }) => {
162
206
  const outcomes = [];
163
207
  for (const meta of Object.values(pikkuState(null, 'trigger', 'webhookSourceMeta'))) {
164
- const url = `${base}${meta.route}`;
165
- const label = `${labelPrefix}:${meta.name}`;
166
- const input = {
167
- url,
168
- label,
169
- events: subscribedWebhookEvents(meta.name),
170
- ...(previous[meta.name] ? { previous: previous[meta.name] } : {}),
171
- };
172
- const outcome = (status, extra = {}) => outcomes.push({ source: meta.name, url, status, ...extra });
173
- try {
174
- if (action === 'teardown') {
175
- if (!meta.teardown) {
176
- outcome('skipped');
177
- continue;
178
- }
179
- const result = await runSourceStep(singletonServices, meta.name, meta.teardown, { label, ...(input.previous ? { previous: input.previous } : {}) });
180
- outcome(result.status);
181
- continue;
182
- }
183
- if (meta.check) {
184
- const checked = await runSourceStep(singletonServices, meta.name, meta.check, input);
185
- if (action === 'check' || checked.status === 'ok') {
186
- outcome(checked.status === 'ok' && action === 'setup'
187
- ? 'unchanged'
188
- : checked.status, checked.status === 'drifted' ? { reason: checked.reason } : {});
189
- continue;
190
- }
191
- }
192
- else if (action === 'check') {
193
- outcome('skipped');
194
- continue;
195
- }
196
- if (!meta.setup) {
197
- outcome('manual', {
198
- instructions: `Register ${url} with the provider for: ${input.events.join(', ') || 'every event'}.`,
208
+ outcomes.push(await runSourceLifecycleStep(meta, action, input, previous[meta.name], singletonServices));
209
+ }
210
+ return outcomes;
211
+ };
212
+ const requireTriggerSourceStore = (singletonServices) => {
213
+ const store = singletonServices.triggerSourceStore;
214
+ if (!store) {
215
+ throw new Error('No triggerSourceStore is configured');
216
+ }
217
+ return store;
218
+ };
219
+ /** The trigger sources this app declares, as the store is synced to. */
220
+ export const declaredTriggerSources = () => Object.keys(pikkuState(null, 'trigger', 'webhookSourceMeta')).map((name) => ({
221
+ name,
222
+ kind: 'webhook',
223
+ }));
224
+ const detailOf = (outcome) => outcome.instructions ?? outcome.error ?? outcome.reason ?? null;
225
+ /**
226
+ * Registers every declared webhook source with its provider: `check`, then
227
+ * `setup` where it is missing or drifted, recording what was registered.
228
+ * Run after a deployment goes live.
229
+ */
230
+ export const reconcileTriggerSources = async ({ singletonServices = getSingletonServices(), ...input }) => {
231
+ const store = requireTriggerSourceStore(singletonServices);
232
+ await store.syncTriggerSources(declaredTriggerSources());
233
+ const outcomes = [];
234
+ for (const meta of Object.values(pikkuState(null, 'trigger', 'webhookSourceMeta'))) {
235
+ 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, {
238
+ status: outcome.status,
239
+ ...(outcome.state ? { state: outcome.state } : {}),
240
+ detail: detailOf(outcome),
241
+ });
242
+ outcomes.push(outcome);
243
+ }
244
+ return outcomes;
245
+ };
246
+ /**
247
+ * Removes the named webhook sources from their providers and forgets them.
248
+ * Run on the outgoing deployment for the sources the next release drops,
249
+ * since only the code that set a source up can tear it down.
250
+ */
251
+ export const teardownTriggerSources = async ({ names, singletonServices = getSingletonServices(), ...input }) => {
252
+ const store = requireTriggerSourceStore(singletonServices);
253
+ const outcomes = [];
254
+ for (const name of names) {
255
+ const meta = getSourceMeta(name);
256
+ const row = await store.getTriggerSource(name);
257
+ const outcome = await runSourceLifecycleStep(meta, 'teardown', input, row?.state ?? undefined, singletonServices);
258
+ if (outcome.status === 'failed') {
259
+ if (row) {
260
+ await store.recordTriggerSource(name, {
261
+ status: 'failed',
262
+ detail: detailOf(outcome),
199
263
  });
200
- continue;
201
- }
202
- const result = await runSourceStep(singletonServices, meta.name, meta.setup, input);
203
- if (result.status === 'manual') {
204
- outcome('manual', { instructions: result.instructions });
205
- continue;
206
264
  }
207
- outcome(result.status, {
208
- ...(result.state ? { state: result.state } : {}),
209
- ...(result.secret && meta.secret
210
- ? { secretName: meta.secret, secret: result.secret }
211
- : {}),
212
- });
213
265
  }
214
- catch (error) {
215
- outcome('failed', {
216
- error: error instanceof Error ? error.message : String(error),
217
- });
266
+ else {
267
+ await store.deleteTriggerSource(name);
218
268
  }
269
+ outcomes.push(outcome);
219
270
  }
220
271
  return outcomes;
221
272
  };
@@ -28,6 +28,7 @@ export type WebhookReceiveResult = {
28
28
  headers?: Record<string, string>;
29
29
  };
30
30
  };
31
+ export type WebhookSourceMethod = 'post' | 'put' | 'get' | 'head';
31
32
  /** Whatever `setup` wants back on the next deploy, for providers whose endpoints cannot be found by label. */
32
33
  export type WebhookSourceState = Record<string, unknown>;
33
34
  export type WebhookLifecycleInput = {
@@ -50,8 +51,6 @@ export type WebhookCheckResult = {
50
51
  export type WebhookSetupResult = {
51
52
  status: 'created' | 'updated' | 'unchanged';
52
53
  state?: WebhookSourceState;
53
- /** Only when the provider issued a new signing secret. */
54
- secret?: string;
55
54
  }
56
55
  /** The provider has no API for this: `instructions` tell a person what to set by hand. */
57
56
  | {
@@ -71,11 +70,10 @@ type SourceFunction<In, Out> = CorePikkuFunctionConfig<any, any> & {
71
70
  export type CoreTriggerWebhookSource<Events extends Record<string, StandardSchemaV1> = Record<string, StandardSchemaV1>> = {
72
71
  /** Triggers subscribe to `<name>:<event>`. Unique across every kind of trigger source. */
73
72
  name: string;
74
- method?: 'post' | 'put' | 'get';
73
+ /** Several for providers that verify the URL with a GET and deliver with a POST. */
74
+ method?: WebhookSourceMethod | WebhookSourceMethod[];
75
75
  /** Defaults to `/webhooks/<name>`. */
76
76
  route?: string;
77
- /** The secret `receive` verifies with, which `setup` produces. */
78
- secret?: string;
79
77
  /** What the source can produce. Each event's data is validated against its schema before it is queued. */
80
78
  events?: Events;
81
79
  /** Omitted: the JSON body is one event dispatched to the trigger named `<name>`. */
@@ -86,9 +84,8 @@ export type CoreTriggerWebhookSource<Events extends Record<string, StandardSchem
86
84
  };
87
85
  export type WebhookSourceMeta = {
88
86
  name: string;
89
- method: 'post' | 'put' | 'get';
87
+ method: WebhookSourceMethod | WebhookSourceMethod[];
90
88
  route: string;
91
- secret?: string;
92
89
  events: string[];
93
90
  receive?: string;
94
91
  check?: string;
@@ -11,6 +11,7 @@ import { buildRunTimeline, reconstructStateAt, } from './run-timeline.js';
11
11
  import { DEFAULT_STEP_RETRIES, WORKFLOW_CHILD_POLL_MAX_MS, WORKFLOW_END_STATES, WORKFLOW_POLL_FACTOR, WORKFLOW_POLL_MIN_MS, WORKFLOW_TERMINAL_STATES, isRunSettled, } from './workflow-constants.js';
12
12
  import { WorkflowAsyncException, WorkflowCancelledException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowRunNotFoundError, WorkflowStepNameNotString, WorkflowSuspendedException, } from './workflow-errors.js';
13
13
  import { resolveWorkflowMeta } from './workflow-meta-resolver.js';
14
+ import { storeWorkflowVersion } from './workflow-version-store.js';
14
15
  import { jobGroupFor, orchestratorQueueName, resolveWorkflowConfig, stepDispatchTarget, stepJobOptions, stepWorkerQueueName, } from './workflow-queue-routing.js';
15
16
  import { wireWorkflowQueueWorkers } from './workflow-queue-wiring.js';
16
17
  import { approvalStepNameFor, evaluateApprovalStep, recordApprovalDecision, } from './workflow-approval.js';
@@ -128,6 +129,7 @@ export class PikkuWorkflowService {
128
129
  }
129
130
  }
130
131
  async createRun(workflowName, input, inline, graphHash, wire, options) {
132
+ await storeWorkflowVersion(this, workflowName, graphHash);
131
133
  return this.mirrored(() => this.createRunImpl(workflowName, input, inline, graphHash, wire, options), (mirror, runId) => mirror.createRun(runId, workflowName, input, inline, graphHash, wire, options));
132
134
  }
133
135
  async getRunStatus(id) {
@@ -0,0 +1,8 @@
1
+ import type { WorkflowService } from '../../services/workflow-service.js';
2
+ type VersionStore = Pick<WorkflowService, 'upsertWorkflowVersion'>;
3
+ /**
4
+ * Persists the graph a run starts on, so a run suspended across a deploy that
5
+ * changes the definition can still resume against the graph it began with.
6
+ */
7
+ export declare const storeWorkflowVersion: (service: VersionStore, name: string, graphHash: string) => Promise<void>;
8
+ export {};
@@ -0,0 +1,21 @@
1
+ import { resolveWorkflowMeta } from './workflow-meta-resolver.js';
2
+ const storedVersions = new WeakMap();
3
+ /**
4
+ * Persists the graph a run starts on, so a run suspended across a deploy that
5
+ * changes the definition can still resume against the graph it began with.
6
+ */
7
+ export const storeWorkflowVersion = async (service, name, graphHash) => {
8
+ const key = `${name}:${graphHash}`;
9
+ let stored = storedVersions.get(service);
10
+ if (!stored) {
11
+ stored = new Set();
12
+ storedVersions.set(service, stored);
13
+ }
14
+ if (stored.has(key))
15
+ return;
16
+ const meta = resolveWorkflowMeta(name)?.meta;
17
+ if (!meta || meta.graphHash !== graphHash)
18
+ return;
19
+ await service.upsertWorkflowVersion(name, graphHash, meta, meta.source);
20
+ stored.add(key);
21
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.127",
3
+ "version": "0.12.128",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -182,10 +182,13 @@
182
182
  "./trigger": [
183
183
  "PIKKU_INCOMING_WEBHOOK_QUEUE_NAME",
184
184
  "PikkuTriggerService",
185
+ "declaredTriggerSources",
185
186
  "dispatchWebhookSourceJob",
186
187
  "receiveWebhookSourceRequest",
188
+ "reconcileTriggerSources",
187
189
  "runWebhookSourceLifecycle",
188
190
  "subscribedWebhookEvents",
191
+ "teardownTriggerSources",
189
192
  "wireTrigger",
190
193
  "wireTriggerSource",
191
194
  "wireTriggerWebhookSource"
@@ -421,6 +424,7 @@
421
424
  "InMemoryQueueService",
422
425
  "InMemorySessionStore",
423
426
  "InMemoryTriggerService",
427
+ "InMemoryTriggerSourceStore",
424
428
  "InMemoryWorkflowService",
425
429
  "IncomingWebhookService",
426
430
  "JsonConsoleLogger",
@@ -514,7 +518,7 @@
514
518
  "verifyWithKeyMaterial",
515
519
  "wrapDEK"
516
520
  ],
517
- "./hmac": ["hmacSha256Hex", "timingSafeStringEqual"],
521
+ "./hmac": ["WebhookSigningSecret", "hmacSha256Hex", "timingSafeStringEqual"],
518
522
  "./state": [
519
523
  "addPackageServiceFactories",
520
524
  "getAllPackageStates",