@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.
- package/CHANGELOG.md +111 -0
- package/dist/function/function-meta.types.d.ts +14 -0
- package/dist/middleware-runner.d.ts +4 -0
- package/dist/middleware-runner.js +52 -16
- package/dist/services/in-memory-lease-service.d.ts +13 -0
- package/dist/services/in-memory-lease-service.js +49 -0
- package/dist/services/in-memory-workflow-service.d.ts +11 -3
- package/dist/services/in-memory-workflow-service.js +53 -23
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +2 -0
- package/dist/services/lease-service.d.ts +61 -0
- package/dist/services/lease-service.js +110 -0
- package/dist/services/trigger-source-store.d.ts +19 -2
- package/dist/services/trigger-source-store.js +14 -1
- package/dist/services/workflow-service.d.ts +1 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/service-tests/lease-service-tests.d.ts +3 -0
- package/dist/testing/service-tests/lease-service-tests.js +138 -0
- package/dist/testing/service-tests/workflow-fencing-tests.d.ts +18 -0
- package/dist/testing/service-tests/workflow-fencing-tests.js +216 -0
- package/dist/testing/service-tests.d.ts +5 -0
- package/dist/testing/service-tests.js +8 -0
- package/dist/types/core.types.d.ts +12 -8
- package/dist/utils/hmac.d.ts +14 -10
- package/dist/utils/hmac.js +29 -25
- package/dist/wirings/http/pikku-fetch-http-response.js +13 -0
- package/dist/wirings/secret/derive-oauth2-app-secrets.js +1 -0
- package/dist/wirings/secret/secret.types.d.ts +6 -0
- package/dist/wirings/trigger/index.d.ts +4 -4
- package/dist/wirings/trigger/index.js +2 -2
- package/dist/wirings/trigger/webhook-source-runner.d.ts +51 -4
- package/dist/wirings/trigger/webhook-source-runner.js +201 -15
- package/dist/wirings/trigger/webhook-source.types.d.ts +50 -0
- package/dist/wirings/trigger/webhook-source.types.js +2 -0
- package/dist/wirings/workflow/graph/graph-runner.js +33 -14
- package/dist/wirings/workflow/index.d.ts +4 -3
- package/dist/wirings/workflow/index.js +3 -2
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +57 -18
- package/dist/wirings/workflow/pikku-workflow-service.js +148 -145
- package/dist/wirings/workflow/workflow-constants.d.ts +28 -1
- package/dist/wirings/workflow/workflow-constants.js +28 -1
- package/dist/wirings/workflow/workflow-errors.d.ts +21 -0
- package/dist/wirings/workflow/workflow-errors.js +38 -0
- package/dist/wirings/workflow/workflow-queue-routing.d.ts +4 -2
- package/dist/wirings/workflow/workflow-queue-routing.js +9 -1
- package/dist/wirings/workflow/workflow-run-lease.d.ts +10 -0
- package/dist/wirings/workflow/workflow-run-lease.js +25 -0
- package/dist/wirings/workflow/workflow-run-status.d.ts +4 -0
- package/dist/wirings/workflow/workflow-run-status.js +37 -0
- package/dist/wirings/workflow/workflow-status-stream.d.ts +2 -1
- package/dist/wirings/workflow/workflow-status-stream.js +18 -1
- package/dist/wirings/workflow/workflow-step-claim.d.ts +13 -1
- package/dist/wirings/workflow/workflow-step-claim.js +29 -5
- package/dist/wirings/workflow/workflow-step-lease.d.ts +25 -0
- package/dist/wirings/workflow/workflow-step-lease.js +53 -0
- package/dist/wirings/workflow/workflow-step-retry.d.ts +14 -0
- package/dist/wirings/workflow/workflow-step-retry.js +33 -0
- package/dist/wirings/workflow/workflow-version-fallback.d.ts +11 -0
- package/dist/wirings/workflow/workflow-version-fallback.js +30 -0
- package/dist/wirings/workflow/workflow.types.d.ts +17 -0
- package/knowledge/decisions/internals/a-held-run-is-woken-later-not-retried.md +28 -0
- package/knowledge/decisions/internals/index.md +2 -1
- package/knowledge/decisions/internals/the-in-memory-workflow-service-is-inline-only-and-single-process.md +2 -2
- package/knowledge/decisions/internals/workflow-step-lock-is-held-only-to-claim-the-step.md +12 -5
- package/knowledge/decisions/security/an-actor-credential-is-derived-per-persona.md +4 -3
- package/package.json +1 -1
- 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
|
|
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'>;
|
package/dist/utils/hmac.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
18
|
-
*
|
|
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;
|
package/dist/utils/hmac.js
CHANGED
|
@@ -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
|
-
*
|
|
24
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
...(
|
|
240
|
-
|
|
241
|
-
}
|
|
414
|
+
...(state ? { state } : {}),
|
|
415
|
+
...(Object.keys(credentials).length ? { credentials } : {}),
|
|
416
|
+
};
|
|
242
417
|
outcomes.push(outcome);
|
|
243
418
|
}
|
|
244
|
-
|
|
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.
|