@pikku/core 0.12.127 → 0.12.129

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 (29) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/function/function-meta.types.d.ts +7 -0
  3. package/dist/function/function-runner.d.ts +6 -1
  4. package/dist/function/function-runner.js +19 -13
  5. package/dist/services/index.d.ts +1 -0
  6. package/dist/services/index.js +1 -0
  7. package/dist/services/trigger-source-store.d.ts +62 -0
  8. package/dist/services/trigger-source-store.js +47 -0
  9. package/dist/types/core.types.d.ts +3 -0
  10. package/dist/utils/hmac.d.ts +41 -0
  11. package/dist/utils/hmac.js +82 -1
  12. package/dist/wirings/channel/channel-common.d.ts +3 -1
  13. package/dist/wirings/channel/channel-common.js +2 -1
  14. package/dist/wirings/channel/channel-handler.d.ts +1 -1
  15. package/dist/wirings/channel/channel-handler.js +2 -1
  16. package/dist/wirings/channel/local/local-channel-runner.js +3 -1
  17. package/dist/wirings/channel/serverless/serverless-channel-runner.js +3 -1
  18. package/dist/wirings/trigger/index.d.ts +2 -2
  19. package/dist/wirings/trigger/index.js +1 -1
  20. package/dist/wirings/trigger/webhook-source-runner.d.ts +29 -9
  21. package/dist/wirings/trigger/webhook-source-runner.js +106 -55
  22. package/dist/wirings/trigger/webhook-source.types.d.ts +4 -7
  23. package/dist/wirings/workflow/pikku-workflow-service.js +2 -0
  24. package/dist/wirings/workflow/workflow-version-store.d.ts +8 -0
  25. package/dist/wirings/workflow/workflow-version-store.js +21 -0
  26. package/knowledge/decisions/internals/index.md +1 -0
  27. package/knowledge/decisions/internals/the-runner-resolves-wire-services-itself.md +31 -0
  28. package/package.json +1 -1
  29. package/src/public-surface.json +5 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,27 @@
1
+ ## 0.12.129
2
+
3
+ ### Patch Changes
4
+
5
+ - 1ab6eb4: Fix app functions reached through `rpc.invoke`/`rpc.exposed` (including the generated `/rpc/:rpcName` endpoint) running without their wire services. Since `singletonServicesOnly`, the forwarding `rpcCaller` built none and the callee inherited that, so any service from `pikkuWireServices` was `undefined`. The runner now resolves the callee's factory itself. Channels pass their per-connection set as the new `wireServices` option, which is reused and left open.
6
+
7
+ ## 0.12.128
8
+
9
+ ### Patch Changes
10
+
11
+ - 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.
12
+ - de63ab2: Store a workflow's graph when a run is created, so a run suspended across a
13
+ deploy that changes the definition resumes on the graph it started on instead
14
+ of failing with `VERSION_NOT_FOUND`. Nothing called `registerWorkflowVersions`,
15
+ so the versions table stayed empty and the replay fallback could never find a
16
+ version.
17
+ - 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.
18
+ - 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.
19
+
20
+ Breaking: `setup` no longer returns `secret`, `wireTriggerWebhookSource` no longer takes `secret`, lifecycle outcomes drop `secretName`/`secret`, and `pikku webhooks setup` drops `--secretsOut`.
21
+
22
+ - 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.
23
+ - 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).
24
+
1
25
  ## 0.12.127
2
26
 
3
27
  ### 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;
@@ -8,9 +8,14 @@ import type { AddonInstance } from '../wirings/addon/addon-runner.js';
8
8
  export declare const addFunction: (funcName: string, funcConfig: CorePikkuFunctionConfig<any, any, any, any, any>, packageName?: string | null) => void;
9
9
  export declare const getFunctionNames: (packageName?: string | null) => string[];
10
10
  export declare const getAllFunctionNames: () => string[];
11
- export declare const runPikkuFunc: <In = any, Out = any>(wireType: PikkuWiringTypes, wireId: string, funcName: string, { singletonServices, createWireServices, data, auth: wiringAuth, inheritedMiddleware, wireMiddleware, inheritedChannelMiddleware, wireChannelMiddleware, coerceDataFromSchema, wire, sessionService, credentialWireService, packageName, addonInstance, }: {
11
+ export declare const runPikkuFunc: <In = any, Out = any>(wireType: PikkuWiringTypes, wireId: string, funcName: string, { singletonServices, createWireServices, wireServices: providedWireServices, data, auth: wiringAuth, inheritedMiddleware, wireMiddleware, inheritedChannelMiddleware, wireChannelMiddleware, coerceDataFromSchema, wire, sessionService, credentialWireService, packageName, addonInstance, }: {
12
12
  singletonServices: CoreSingletonServices;
13
13
  createWireServices?: CreateWireServices;
14
+ /**
15
+ * Wire services the caller already built and still owns, such as a
16
+ * channel's per-connection set. Used as-is and never closed here.
17
+ */
18
+ wireServices?: Record<string, unknown>;
14
19
  data: () => Promise<In> | In;
15
20
  auth?: boolean;
16
21
  inheritedMiddleware?: MiddlewareMetadata[];
@@ -3,7 +3,7 @@ import { runMiddleware, combineMiddleware } from '../middleware-runner.js';
3
3
  import { combineChannelMiddleware, wrapChannelWithMiddleware, } from '../wirings/channel/channel-middleware-runner.js';
4
4
  import { runPermissions } from '../permissions.js';
5
5
  import { withoutSecrets } from '../services/secretless.js';
6
- import { pikkuState } from '../pikku-state.js';
6
+ import { getCreateWireServices, pikkuState } from '../pikku-state.js';
7
7
  import { applyDefaultsFromSchema, coerceTopLevelDataFromSchema, validateSchema, } from '../schema.js';
8
8
  import { parseVersionedId } from '../version.js';
9
9
  import { PikkuSessionService } from '../services/user-session-service.js';
@@ -64,7 +64,7 @@ export const getAllFunctionNames = () => {
64
64
  }
65
65
  return functions;
66
66
  };
67
- export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServices, createWireServices, data, auth: wiringAuth, inheritedMiddleware, wireMiddleware, inheritedChannelMiddleware, wireChannelMiddleware, coerceDataFromSchema, wire, sessionService, credentialWireService, packageName = null, addonInstance, }) => {
67
+ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServices, createWireServices, wireServices: providedWireServices, data, auth: wiringAuth, inheritedMiddleware, wireMiddleware, inheritedChannelMiddleware, wireChannelMiddleware, coerceDataFromSchema, wire, sessionService, credentialWireService, packageName = null, addonInstance, }) => {
68
68
  wire.wireType ??= wireType;
69
69
  wire.wireId ??= wireId;
70
70
  const funcMap = pikkuState(packageName, 'function', 'functions');
@@ -118,13 +118,14 @@ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServic
118
118
  const resolvedSingletonServices = funcPackageName
119
119
  ? await getOrCreatePackageSingletonServices(funcPackageName, singletonServices, addonInstance)
120
120
  : singletonServices;
121
- let resolvedCreateWireServices = createWireServices;
122
- if (funcPackageName) {
123
- const factories = pikkuState(funcPackageName, 'package', 'factories');
124
- if (factories?.createWireServices) {
125
- resolvedCreateWireServices = factories.createWireServices;
126
- }
127
- }
121
+ // knowledge: decisions/internals/the-runner-resolves-wire-services-itself.md
122
+ const packageCreateWireServices = funcPackageName
123
+ ? pikkuState(funcPackageName, 'package', 'factories')?.createWireServices
124
+ : undefined;
125
+ const reusedWireServices = packageCreateWireServices
126
+ ? undefined
127
+ : providedWireServices;
128
+ const resolvedCreateWireServices = packageCreateWireServices ?? createWireServices ?? getCreateWireServices();
128
129
  const allChannelMiddleware = combineChannelMiddleware(wireType, wireId, {
129
130
  wireInheritedChannelMiddleware: inheritedChannelMiddleware,
130
131
  wireChannelMiddleware,
@@ -256,9 +257,14 @@ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServic
256
257
  let invocationAuditLog;
257
258
  let invocationAnalytics;
258
259
  try {
259
- wireServices = (await resolvedCreateWireServices?.(resolvedSingletonServices, invocationWire));
260
- let services = wireServices && Object.keys(wireServices).length > 0
261
- ? { ...resolvedSingletonServices, ...wireServices }
260
+ if (!funcMeta.singletonServicesOnly && !reusedWireServices) {
261
+ wireServices = (await resolvedCreateWireServices?.(resolvedSingletonServices, invocationWire));
262
+ }
263
+ const invocationWireServices = funcMeta.singletonServicesOnly
264
+ ? undefined
265
+ : (reusedWireServices ?? wireServices);
266
+ let services = invocationWireServices && Object.keys(invocationWireServices).length > 0
267
+ ? { ...resolvedSingletonServices, ...invocationWireServices }
262
268
  : resolvedSingletonServices;
263
269
  // knowledge: decisions/internals/core-function-runner-restores-the-wire-fields-it-overwrites.md
264
270
  if (resolvedAuditConfig &&
@@ -284,7 +290,7 @@ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServic
284
290
  const callerPackageName = funcPackageName;
285
291
  Object.defineProperty(invocationWire, 'rpc', {
286
292
  get() {
287
- const rpc = rpcService.getContextRPCService(services, invocationWire, { sessionService }, 0, callerPackageName);
293
+ const rpc = rpcService.getContextRPCService(resolvedSingletonServices, invocationWire, { sessionService }, 0, callerPackageName);
288
294
  Object.defineProperty(invocationWire, 'rpc', {
289
295
  value: rpc,
290
296
  writable: true,
@@ -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
+ }
@@ -2,7 +2,7 @@ import type { CoreSingletonServices } from '../../types/core.types.js';
2
2
  import type { MiddlewareMetadata } from '../../middleware/middleware.types.js';
3
3
  import type { CoreChannel, ChannelMessageMeta } from './channel.types.js';
4
4
  import { type PikkuSessionService } from '../../services/user-session-service.js';
5
- export declare const runChannelLifecycleWithMiddleware: ({ channelConfig, meta, lifecycleConfig, lifecycleType, services, channel, data, channelMiddlewareMeta, userSession, }: {
5
+ export declare const runChannelLifecycleWithMiddleware: ({ channelConfig, meta, lifecycleConfig, lifecycleType, services, channel, data, channelMiddlewareMeta, userSession, wireServices, }: {
6
6
  channelConfig: CoreChannel<unknown, any>;
7
7
  meta: ChannelMessageMeta;
8
8
  lifecycleConfig: any;
@@ -16,4 +16,6 @@ export declare const runChannelLifecycleWithMiddleware: ({ channelConfig, meta,
16
16
  * just connected.
17
17
  */
18
18
  userSession?: PikkuSessionService<any>;
19
+ /** The connection's wire services, reused rather than rebuilt per call. */
20
+ wireServices?: Record<string, unknown>;
19
21
  }) => Promise<unknown>;
@@ -2,7 +2,7 @@ import { combineMiddleware, runMiddleware } from '../../middleware-runner.js';
2
2
  import { runPikkuFunc } from '../../function/function-runner.js';
3
3
  import { createMiddlewareSessionWireProps, } from '../../services/user-session-service.js';
4
4
  import { combineChannelMiddleware, wrapChannelWithMiddleware, } from './channel-middleware-runner.js';
5
- export const runChannelLifecycleWithMiddleware = async ({ channelConfig, meta, lifecycleConfig, lifecycleType, services, channel, data, channelMiddlewareMeta, userSession, }) => {
5
+ export const runChannelLifecycleWithMiddleware = async ({ channelConfig, meta, lifecycleConfig, lifecycleType, services, channel, data, channelMiddlewareMeta, userSession, wireServices, }) => {
6
6
  const lifecycleMiddleware = typeof lifecycleConfig === 'object' && 'middleware' in lifecycleConfig
7
7
  ? lifecycleConfig.middleware || []
8
8
  : [];
@@ -25,6 +25,7 @@ export const runChannelLifecycleWithMiddleware = async ({ channelConfig, meta, l
25
25
  const runLifecycle = async () => {
26
26
  return await runPikkuFunc('channel', channelConfig.name, meta.pikkuFuncId, {
27
27
  singletonServices: services,
28
+ wireServices,
28
29
  data: () => data,
29
30
  wire,
30
31
  sessionService: userSession,
@@ -1,7 +1,7 @@
1
1
  import type { CoreServices, CoreUserSession } from '../../types/core.types.js';
2
2
  import type { BinaryData, CoreChannel, PikkuChannelHandler } from './channel.types.js';
3
3
  import type { SessionService } from '../../services/user-session-service.js';
4
- export declare const processMessageHandlers: (services: CoreServices, channelConfig: CoreChannel<any, any>, channelHandler: PikkuChannelHandler, userSession?: SessionService<CoreUserSession>) => {
4
+ export declare const processMessageHandlers: (services: CoreServices, channelConfig: CoreChannel<any, any>, channelHandler: PikkuChannelHandler, userSession?: SessionService<CoreUserSession>, wireServices?: Record<string, unknown>) => {
5
5
  onMessage: (rawData: unknown) => Promise<unknown>;
6
6
  onBinaryMessage: ((data: BinaryData) => Promise<BinaryData | void>) | undefined;
7
7
  };
@@ -37,7 +37,7 @@ const validateAuth = (requiresSession, session, onMessage) => {
37
37
  }
38
38
  return true;
39
39
  };
40
- export const processMessageHandlers = (services, channelConfig, channelHandler, userSession) => {
40
+ export const processMessageHandlers = (services, channelConfig, channelHandler, userSession, wireServices) => {
41
41
  const logger = services.logger;
42
42
  const requiresSession = channelConfig.auth !== false;
43
43
  const processMessage = async (data, onMessage, routingProperty, routerValue) => {
@@ -76,6 +76,7 @@ export const processMessageHandlers = (services, channelConfig, channelHandler,
76
76
  const wireChannelMiddleware = channelConfig.channelMiddleware || [];
77
77
  return await runPikkuFunc('channel', cacheKey, pikkuFuncId, {
78
78
  singletonServices: services,
79
+ wireServices,
79
80
  data: () => data,
80
81
  inheritedMiddleware,
81
82
  wireMiddleware,
@@ -89,6 +89,7 @@ export const runLocalChannel = async ({ channelId, request, response, route, ski
89
89
  data: openingData,
90
90
  channelMiddlewareMeta: meta.channelMiddleware,
91
91
  userSession,
92
+ wireServices,
92
93
  });
93
94
  if (result !== undefined) {
94
95
  await channel.send(result);
@@ -117,6 +118,7 @@ export const runLocalChannel = async ({ channelId, request, response, route, ski
117
118
  channel,
118
119
  channelMiddlewareMeta: meta.channelMiddleware,
119
120
  userSession,
121
+ wireServices,
120
122
  });
121
123
  }
122
124
  catch (e) {
@@ -131,7 +133,7 @@ export const runLocalChannel = async ({ channelId, request, response, route, ski
131
133
  }
132
134
  await closeServices();
133
135
  });
134
- const { onMessage, onBinaryMessage } = processMessageHandlers(services, channelConfig, channelHandler, userSession);
136
+ const { onMessage, onBinaryMessage } = processMessageHandlers(services, channelConfig, channelHandler, userSession, wireServices);
135
137
  channelHandler.registerOnMessage(async (data) => {
136
138
  try {
137
139
  const result = await onMessage(data);
@@ -80,6 +80,7 @@ export const runChannelConnect = async ({ channelId, channelObject, request, res
80
80
  data: openingData,
81
81
  channelMiddlewareMeta: meta.channelMiddleware,
82
82
  userSession,
83
+ wireServices,
83
84
  });
84
85
  }
85
86
  const pikkuUserId = userSession.getPikkuUserId();
@@ -150,6 +151,7 @@ export const runChannelDisconnect = async ({ channelId, channelStore, channelHan
150
151
  channel,
151
152
  channelMiddlewareMeta: meta.channelMiddleware,
152
153
  userSession,
154
+ wireServices,
153
155
  });
154
156
  }
155
157
  catch (e) {
@@ -197,7 +199,7 @@ export const runChannelMessage = async ({ channelId, channelStore, channelHandle
197
199
  };
198
200
  let response;
199
201
  try {
200
- const { onMessage } = processMessageHandlers(services, channelConfig, channelHandler, userSession);
202
+ const { onMessage } = processMessageHandlers(services, channelConfig, channelHandler, userSession, wireServices);
201
203
  response = await onMessage(data);
202
204
  }
203
205
  catch (e) {
@@ -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
+ };
@@ -62,6 +62,7 @@ caller is entitled to assume.
62
62
  - [A column's at-rest form is an axis of its own](core-column-form-is-an-axis-of-its-own.md) — How a value is stored is independent of how sensitive it is, so form carries a required nominal brand on writes while classification stays optional on reads
63
63
  - [The data-classification brand is an optional property](core-data-classification-brand-is-an-optional-property.md) — Making **classification** required would break ordinary Kysely operands, so the brand only constrains values flowing out
64
64
  - [The function runner restores the wire fields it overwrites](core-function-runner-restores-the-wire-fields-it-overwrites.md) — One wire object is reused across nested calls, so functionId, audit, addonNamespace and rpc are saved and put back in a finally
65
+ - [The function runner resolves wire services itself](the-runner-resolves-wire-services-itself.md) — A function's wire services come from its own package's factory or the app's registered one, never borrowed from whoever called it
65
66
  - [Hot reload merges generated meta and never replaces it](core-hot-reload-merges-generated-meta-never-replaces-it.md) — Reloading codegen output must preserve runtime-registered meta, which no generated JSON contains
66
67
  - [Hot reload owns its module registry instead of re-importing](core-hot-reload-owns-its-module-registry.md) — Dev reload transpiles to CJS and runs modules through vm.compileFunction, because the native ESM loader map cannot be evicted
67
68
  - [Middleware order is resolution scope first, then priority](core-middleware-order-is-scope-then-priority.md) — Middleware is collected global to function, then stably sorted by priority, deduped, frozen and cached per wire
@@ -0,0 +1,31 @@
1
+ ---
2
+ type: decision
3
+ title: The function runner resolves wire services itself
4
+ description: A function's wire services come from its own package's factory or the app's registered one, never borrowed from whoever called it
5
+ tags: core
6
+ ---
7
+
8
+ # The function runner resolves wire services itself
9
+
10
+ `runPikkuFunc` picks the wire-services factory for the function it is about to
11
+ run: the function's addon package factory if it has one, else a factory the
12
+ transport passed explicitly, else the app's registered one
13
+ (`getCreateWireServices()`). A caller that passes nothing still gets the
14
+ function its wire services.
15
+
16
+ Before this, only the transports passed a factory. `rpc.invoke`, `rpc.exposed`,
17
+ gateway handlers and webhook sources passed none, so an app function reached
18
+ through them ran on whatever services its caller had already merged. That held
19
+ until `singletonServicesOnly` let a caller skip building wire services: the
20
+ generated `rpcCaller` behind `/rpc/:rpcName` reads no service, was marked, and
21
+ every app function behind it lost `events` and every other wire service. Nested
22
+ RPC therefore gets `resolvedSingletonServices`, not the caller's merged set.
23
+
24
+ Channels are the one caller that legitimately shares wire services: they build
25
+ one set per connection. They hand it over as `wireServices`, which the runner
26
+ uses as-is and does not close, because the connection owns its lifetime.
27
+
28
+ **What this rules out:** passing a caller's merged services down as
29
+ `singletonServices` to carry wire services into a nested call, and deciding in
30
+ one function's meta whether a different function gets wire services.
31
+ `singletonServicesOnly` only ever describes the function it is set on.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.127",
3
+ "version": "0.12.129",
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",