@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.
- package/CHANGELOG.md +24 -0
- package/dist/function/function-meta.types.d.ts +7 -0
- package/dist/function/function-runner.d.ts +6 -1
- package/dist/function/function-runner.js +19 -13
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/trigger-source-store.d.ts +62 -0
- package/dist/services/trigger-source-store.js +47 -0
- package/dist/types/core.types.d.ts +3 -0
- package/dist/utils/hmac.d.ts +41 -0
- package/dist/utils/hmac.js +82 -1
- package/dist/wirings/channel/channel-common.d.ts +3 -1
- package/dist/wirings/channel/channel-common.js +2 -1
- package/dist/wirings/channel/channel-handler.d.ts +1 -1
- package/dist/wirings/channel/channel-handler.js +2 -1
- package/dist/wirings/channel/local/local-channel-runner.js +3 -1
- package/dist/wirings/channel/serverless/serverless-channel-runner.js +3 -1
- package/dist/wirings/trigger/index.d.ts +2 -2
- package/dist/wirings/trigger/index.js +1 -1
- package/dist/wirings/trigger/webhook-source-runner.d.ts +29 -9
- package/dist/wirings/trigger/webhook-source-runner.js +106 -55
- package/dist/wirings/trigger/webhook-source.types.d.ts +4 -7
- package/dist/wirings/workflow/pikku-workflow-service.js +2 -0
- package/dist/wirings/workflow/workflow-version-store.d.ts +8 -0
- package/dist/wirings/workflow/workflow-version-store.js +21 -0
- package/knowledge/decisions/internals/index.md +1 -0
- package/knowledge/decisions/internals/the-runner-resolves-wire-services-itself.md +31 -0
- package/package.json +1 -1
- 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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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(
|
|
293
|
+
const rpc = rpcService.getContextRPCService(resolvedSingletonServices, invocationWire, { sessionService }, 0, callerPackageName);
|
|
288
294
|
Object.defineProperty(invocationWire, 'rpc', {
|
|
289
295
|
value: rpc,
|
|
290
296
|
writable: true,
|
package/dist/services/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/services/index.js
CHANGED
|
@@ -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
|
package/dist/utils/hmac.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/utils/hmac.js
CHANGED
|
@@ -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
|
|
35
|
-
*
|
|
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,
|
|
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
|
|
158
|
-
*
|
|
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,
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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:
|
|
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
package/src/public-surface.json
CHANGED
|
@@ -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",
|