@pikku/core 0.12.129 → 0.12.130

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## 0.12.130
2
+
3
+ ### Patch Changes
4
+
5
+ - dfcd351: The deploy planner now binds a unit to the units of the functions its functions call with `rpc.invoke('name')` / `rpc.remote('name')`. Before, a caller and callee split into different units (e.g. a no-service function in `svc-base` calling a DB function in `svc-kysely`) got no service binding and failed on the deployed stage with "No service binding for function". The inspector records literal RPC names per function as `invokes` (single, double or substitution-free template quotes; `rpc!`, `wire.rpc`) and warns on a computed name, which the planner cannot see (#1883).
6
+ - cf40182: Deployed units that call `rpc.startWorkflow('x')` now get x's meta, so the call no longer fails with `WorkflowNotFoundError`. The inspector records `startsWorkflows` for literal `rpc.startWorkflow(...)` calls. It warns when a handler computes the workflow name or passes `rpc` to a helper, because the planner cannot see those calls.
7
+
8
+ With workflow queues, the deploy planner gives a starter unit the workflow meta and orchestrator queue meta only (new `--workflowMeta` filter), plus `workflow-state` and `queue` services. Without queues the start runs inline, so the whole workflow is bundled. Core's `startWorkflow` now requires the workflow registration only for inline runs; queued runs need only the meta.
9
+
10
+ - 5bce779: `pikku dev` registers webhook sources with their providers when `PIKKU_DEV_WEBHOOK_URL` is set. What it registered, signing secrets included, is kept in a git-ignored `.webhook-registrations.gen.json` next to `pikku.config.json`, so a database reset or `.pikku` wipe does not register again, and nothing reaches a provider while the url and events are unchanged. Registrations no source declares any more are warned about on every run, with the endpoint and label to delete by hand. The prefix defaults to `dev-<username>`; override it with `PIKKU_DEV_WEBHOOK_LABEL_PREFIX`. New in core: `reconcileWebhookRegistrations`.
11
+ - 5bce779: Webhook trigger sources are off until someone turns them on, and addons declare their own.
12
+
13
+ An addon calls `wireTriggerWebhookSource` in its own package, and an app that wires the addon gets the source (its route included) without declaring it: named and routed after the addon's namespace, so two instances get one each. A source the app declares under the same name wins.
14
+
15
+ Every source now has an `enabled` switch in the `triggerSourceStore`, off by default. `reconcileTriggerSources` registers only enabled sources and records each one's `baseUrl` and `labelPrefix`; a disabled source's route answers 404 without running `receive`. New in core: `enableTriggerSource` registers a source with its provider and `disableTriggerSource` stops it receiving, then tears it down, both at the recorded address unless one is given. The admin addon exposes them as `triggerSourceEnable` and `triggerSourceDisable`. The `pikkuTriggerSource` table gains `enabled`, `baseUrl` and `labelPrefix`: run `pikku db generate` for the migration.
16
+
1
17
  ## 0.12.129
2
18
 
3
19
  ### Patch Changes
@@ -138,12 +138,26 @@ export type FunctionMeta = FunctionRuntimeMeta & Partial<{
138
138
  isDirectFunction: boolean;
139
139
  sourceFile: string;
140
140
  exportedName: string;
141
+ /**
142
+ * RPC names this function's own body calls by literal name through
143
+ * `rpc.invoke(...)` / `rpc.remote(...)`. The deploy planner binds the
144
+ * function's unit to each callee's unit from this; a call with a computed
145
+ * name is not listed, and is warned about instead.
146
+ */
147
+ invokes: string[];
141
148
  /** File containing the handler body when it differs from sourceFile (imported handlers) */
142
149
  bodySourceFile?: string;
143
150
  /** 1-indexed first line of the handler body (verbose meta; coverage mapping) */
144
151
  bodyStart: number;
145
152
  /** 1-indexed last line of the handler body (verbose meta; coverage mapping) */
146
153
  bodyEnd: number;
154
+ /**
155
+ * Workflow names this function's own body starts by literal name through
156
+ * `rpc.startWorkflow(...)`. The deploy planner bundles each one's meta
157
+ * into the function's unit, which is all a queued start needs. A call
158
+ * made from a helper the handler passes `rpc` to is not listed.
159
+ */
160
+ startsWorkflows: string[];
147
161
  } & CommonWireMeta>;
148
162
  export type FunctionsRuntimeMeta = Record<string, FunctionRuntimeMeta>;
149
163
  export type FunctionsMeta = Record<string, FunctionMeta>;
@@ -3,15 +3,22 @@ export type TriggerSourceKind = 'webhook';
3
3
  export type DeclaredTriggerSource = {
4
4
  name: string;
5
5
  kind: TriggerSourceKind;
6
+ /** Where the running app is served and how it labels provider endpoints: what enabling a source registers it with. */
7
+ baseUrl?: string;
8
+ labelPrefix?: string;
6
9
  };
7
10
  /**
8
11
  * What was registered with the provider for one declared trigger source.
9
12
  * `state` is what the last `setup` returned — provider identifiers only; any
10
13
  * secret it issued lives in the credential service.
11
14
  */
12
- export type TriggerSourceRow = DeclaredTriggerSource & {
15
+ export type TriggerSourceRow = Required<Pick<DeclaredTriggerSource, 'name' | 'kind'>> & {
16
+ baseUrl: string | null;
17
+ labelPrefix: string | null;
13
18
  /** False once the declaration has gone from code without a teardown: an orphan. */
14
19
  declared: boolean;
20
+ /** Only an enabled source is registered with its provider or receives events. Off until someone turns it on. */
21
+ enabled: boolean;
15
22
  status: string | null;
16
23
  state: WebhookSourceState | null;
17
24
  /** The last step's instructions or error. */
@@ -25,11 +32,12 @@ export type TriggerSourceResult = {
25
32
  };
26
33
  /** What each declared trigger source registered with its provider. */
27
34
  export interface TriggerSourceStore {
28
- /** Upserts the declared sources and marks the rest undeclared. */
35
+ /** Upserts the declared sources, keeping a known address when none is given, and marks the rest undeclared. */
29
36
  syncTriggerSources(sources: DeclaredTriggerSource[]): Promise<void>;
30
37
  listTriggerSources(): Promise<TriggerSourceRow[]>;
31
38
  getTriggerSource(name: string): Promise<TriggerSourceRow | null>;
32
39
  recordTriggerSource(name: string, result: TriggerSourceResult): Promise<void>;
40
+ setTriggerSourceEnabled(name: string, enabled: boolean): Promise<void>;
33
41
  deleteTriggerSource(name: string): Promise<void>;
34
42
  }
35
43
  export declare class InMemoryTriggerSourceStore implements TriggerSourceStore {
@@ -38,8 +46,12 @@ export declare class InMemoryTriggerSourceStore implements TriggerSourceStore {
38
46
  listTriggerSources(): Promise<{
39
47
  name: string;
40
48
  kind: TriggerSourceKind;
49
+ baseUrl: string | null;
50
+ labelPrefix: string | null;
41
51
  /** False once the declaration has gone from code without a teardown: an orphan. */
42
52
  declared: boolean;
53
+ /** Only an enabled source is registered with its provider or receives events. Off until someone turns it on. */
54
+ enabled: boolean;
43
55
  status: string | null;
44
56
  state: WebhookSourceState | null;
45
57
  /** The last step's instructions or error. */
@@ -49,8 +61,12 @@ export declare class InMemoryTriggerSourceStore implements TriggerSourceStore {
49
61
  getTriggerSource(name: string): Promise<{
50
62
  name: string;
51
63
  kind: TriggerSourceKind;
64
+ baseUrl: string | null;
65
+ labelPrefix: string | null;
52
66
  /** False once the declaration has gone from code without a teardown: an orphan. */
53
67
  declared: boolean;
68
+ /** Only an enabled source is registered with its provider or receives events. Off until someone turns it on. */
69
+ enabled: boolean;
54
70
  status: string | null;
55
71
  state: WebhookSourceState | null;
56
72
  /** The last step's instructions or error. */
@@ -58,5 +74,6 @@ export declare class InMemoryTriggerSourceStore implements TriggerSourceStore {
58
74
  updatedAt: string | null;
59
75
  } | null>;
60
76
  recordTriggerSource(name: string, result: TriggerSourceResult): Promise<void>;
77
+ setTriggerSourceEnabled(name: string, enabled: boolean): Promise<void>;
61
78
  deleteTriggerSource(name: string): Promise<void>;
62
79
  }
@@ -5,16 +5,23 @@ export class InMemoryTriggerSourceStore {
5
5
  for (const row of this.rows.values()) {
6
6
  row.declared = names.has(row.name);
7
7
  }
8
- for (const { name, kind } of sources) {
8
+ for (const { name, kind, baseUrl, labelPrefix } of sources) {
9
9
  const row = this.rows.get(name);
10
10
  if (row) {
11
11
  row.kind = kind;
12
+ if (baseUrl !== undefined)
13
+ row.baseUrl = baseUrl;
14
+ if (labelPrefix !== undefined)
15
+ row.labelPrefix = labelPrefix;
12
16
  continue;
13
17
  }
14
18
  this.rows.set(name, {
15
19
  name,
16
20
  kind,
21
+ baseUrl: baseUrl ?? null,
22
+ labelPrefix: labelPrefix ?? null,
17
23
  declared: true,
24
+ enabled: false,
18
25
  status: null,
19
26
  state: null,
20
27
  detail: null,
@@ -41,6 +48,12 @@ export class InMemoryTriggerSourceStore {
41
48
  row.detail = result.detail ?? null;
42
49
  row.updatedAt = new Date().toISOString();
43
50
  }
51
+ async setTriggerSourceEnabled(name, enabled) {
52
+ const row = this.rows.get(name);
53
+ if (!row)
54
+ throw new Error(`Unknown trigger source: ${name}`);
55
+ row.enabled = enabled;
56
+ }
44
57
  async deleteTriggerSource(name) {
45
58
  this.rows.delete(name);
46
59
  }
@@ -1,6 +1,6 @@
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';
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
4
  export { PIKKU_INCOMING_WEBHOOK_QUEUE_NAME } from './webhook-source.types.js';
5
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';
@@ -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';
2
+ export { wireTriggerWebhookSource, receiveWebhookSourceRequest, dispatchWebhookSourceJob, runWebhookSourceLifecycle, declaredTriggerSources, reconcileTriggerSources, enableTriggerSource, disableTriggerSource, reconcileWebhookRegistrations, 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';
@@ -9,7 +9,8 @@ export declare const subscribedWebhookEvents: (source: string) => string[];
9
9
  /**
10
10
  * The body of a webhook source's route: `receive`, validate, and queue every
11
11
  * event some trigger listens for. Events nobody listens for are answered and
12
- * dropped, so the provider does not retry them forever.
12
+ * dropped, so the provider does not retry them forever. With a
13
+ * `triggerSourceStore`, a source nobody enabled answers 404.
13
14
  */
14
15
  export declare const receiveWebhookSourceRequest: (sourceName: string, wire: {
15
16
  http?: PikkuHTTP;
@@ -44,15 +45,61 @@ export declare const runWebhookSourceLifecycle: ({ action, previous, singletonSe
44
45
  singletonServices?: CoreSingletonServices;
45
46
  }) => Promise<WebhookSourceOutcome[]>;
46
47
  /** The trigger sources this app declares, as the store is synced to. */
47
- export declare const declaredTriggerSources: () => DeclaredTriggerSource[];
48
+ export declare const declaredTriggerSources: (address?: LifecycleInput) => DeclaredTriggerSource[];
48
49
  /**
49
- * Registers every declared webhook source with its provider: `check`, then
50
+ * Registers every enabled webhook source with its provider: `check`, then
50
51
  * `setup` where it is missing or drifted, recording what was registered.
51
- * Run after a deployment goes live.
52
+ * A source nobody enabled is skipped. Run after a deployment goes live.
52
53
  */
53
54
  export declare const reconcileTriggerSources: ({ singletonServices, ...input }: LifecycleInput & {
54
55
  singletonServices?: CoreSingletonServices;
55
56
  }) => Promise<WebhookSourceOutcome[]>;
57
+ type SwitchInput = Partial<LifecycleInput> & {
58
+ name: string;
59
+ singletonServices?: CoreSingletonServices;
60
+ };
61
+ /**
62
+ * Turns a declared webhook source on and registers it with its provider, at
63
+ * the address given or the one the last deployment recorded.
64
+ */
65
+ export declare const enableTriggerSource: ({ singletonServices, ...input }: SwitchInput) => Promise<WebhookSourceOutcome>;
66
+ /**
67
+ * Turns a webhook source off: it stops receiving at once, then is removed
68
+ * from its provider. A failed teardown leaves it off and records the error.
69
+ */
70
+ export declare const disableTriggerSource: ({ singletonServices, ...input }: SwitchInput) => Promise<WebhookSourceOutcome>;
71
+ /** What one source registered with its provider, as a caller keeps it between runs. */
72
+ export type WebhookRegistration = {
73
+ url: string;
74
+ events: string[];
75
+ status: WebhookSourceOutcome['status'];
76
+ state?: WebhookSourceState;
77
+ /** The signing secrets `setup` stored, so a wiped credential store can be refilled without registering again. */
78
+ credentials?: Record<string, unknown>;
79
+ };
80
+ export type WebhookRegistrations = Record<string, WebhookRegistration>;
81
+ export type OrphanedWebhookRegistration = {
82
+ source: string;
83
+ url: string;
84
+ label: string;
85
+ };
86
+ /**
87
+ * Registers every declared webhook source against registrations the caller
88
+ * keeps rather than a `triggerSourceStore`, as `pikku dev` does in a file that
89
+ * outlives its database. A source whose url and events match what was
90
+ * registered is left alone without calling the provider, and its signing
91
+ * secrets are put back if the credential store lost them. Registrations no
92
+ * source declares any more are returned as orphans and kept, since only the
93
+ * code that set one up could tear it down.
94
+ */
95
+ export declare const reconcileWebhookRegistrations: ({ registrations, singletonServices, ...input }: LifecycleInput & {
96
+ registrations: WebhookRegistrations;
97
+ singletonServices?: CoreSingletonServices;
98
+ }) => Promise<{
99
+ registrations: WebhookRegistrations;
100
+ outcomes: WebhookSourceOutcome[];
101
+ orphans: OrphanedWebhookRegistration[];
102
+ }>;
56
103
  /**
57
104
  * Removes the named webhook sources from their providers and forgets them.
58
105
  * Run on the outgoing deployment for the sources the next release drops,
@@ -79,11 +79,16 @@ const validateEvents = async (source, events, logger) => {
79
79
  /**
80
80
  * The body of a webhook source's route: `receive`, validate, and queue every
81
81
  * event some trigger listens for. Events nobody listens for are answered and
82
- * dropped, so the provider does not retry them forever.
82
+ * dropped, so the provider does not retry them forever. With a
83
+ * `triggerSourceStore`, a source nobody enabled answers 404.
83
84
  */
84
85
  export const receiveWebhookSourceRequest = async (sourceName, wire) => {
85
86
  const singletonServices = getSingletonServices();
86
87
  const meta = getSourceMeta(sourceName);
88
+ const store = singletonServices.triggerSourceStore;
89
+ if (store && !(await store.getTriggerSource(sourceName))?.enabled) {
90
+ return new Response(null, { status: 404 });
91
+ }
87
92
  const source = pikkuState(null, 'trigger', 'webhookSources').get(sourceName);
88
93
  const request = await readRequest(wire.http);
89
94
  const result = meta.receive
@@ -152,9 +157,12 @@ export const dispatchWebhookSourceJob = async (job) => {
152
157
  const recordAttempt = (singletonServices, receiptId, trigger, error) => singletonServices.incomingWebhookService
153
158
  ?.recordAttempt(receiptId, { trigger, ...(error ? { error } : {}) })
154
159
  .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}`;
160
+ const sourceAddress = (meta, { baseUrl, labelPrefix }) => ({
161
+ url: `${baseUrl.replace(/\/+$/, '')}${meta.route}`,
162
+ label: `${labelPrefix}:${meta.name}`,
163
+ });
164
+ const runSourceLifecycleStep = async (meta, action, lifecycleInput, previous, singletonServices) => {
165
+ const { url, label } = sourceAddress(meta, lifecycleInput);
158
166
  const input = {
159
167
  url,
160
168
  label,
@@ -217,31 +225,165 @@ const requireTriggerSourceStore = (singletonServices) => {
217
225
  return store;
218
226
  };
219
227
  /** The trigger sources this app declares, as the store is synced to. */
220
- export const declaredTriggerSources = () => Object.keys(pikkuState(null, 'trigger', 'webhookSourceMeta')).map((name) => ({
228
+ export const declaredTriggerSources = (address) => Object.keys(pikkuState(null, 'trigger', 'webhookSourceMeta')).map((name) => ({
221
229
  name,
222
230
  kind: 'webhook',
231
+ ...address,
223
232
  }));
224
233
  const detailOf = (outcome) => outcome.instructions ?? outcome.error ?? outcome.reason ?? null;
234
+ const registerTriggerSource = async (store, meta, input, previous, singletonServices) => {
235
+ const outcome = await runSourceLifecycleStep(meta, 'setup', input, previous, singletonServices);
236
+ await store.recordTriggerSource(meta.name, {
237
+ status: outcome.status,
238
+ ...(outcome.state ? { state: outcome.state } : {}),
239
+ detail: detailOf(outcome),
240
+ });
241
+ return outcome;
242
+ };
225
243
  /**
226
- * Registers every declared webhook source with its provider: `check`, then
244
+ * Registers every enabled webhook source with its provider: `check`, then
227
245
  * `setup` where it is missing or drifted, recording what was registered.
228
- * Run after a deployment goes live.
246
+ * A source nobody enabled is skipped. Run after a deployment goes live.
229
247
  */
230
248
  export const reconcileTriggerSources = async ({ singletonServices = getSingletonServices(), ...input }) => {
231
249
  const store = requireTriggerSourceStore(singletonServices);
232
- await store.syncTriggerSources(declaredTriggerSources());
250
+ await store.syncTriggerSources(declaredTriggerSources({
251
+ baseUrl: input.baseUrl,
252
+ labelPrefix: input.labelPrefix,
253
+ }));
233
254
  const outcomes = [];
234
255
  for (const meta of Object.values(pikkuState(null, 'trigger', 'webhookSourceMeta'))) {
235
256
  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, {
257
+ if (!row?.enabled) {
258
+ outcomes.push({
259
+ source: meta.name,
260
+ url: sourceAddress(meta, input).url,
261
+ status: 'skipped',
262
+ reason: 'disabled',
263
+ });
264
+ continue;
265
+ }
266
+ outcomes.push(await registerTriggerSource(store, meta, input, row.state ?? undefined, singletonServices));
267
+ }
268
+ return outcomes;
269
+ };
270
+ /** The address given, or the one the last deployment synced onto the row. */
271
+ const switchAddress = (row, { baseUrl, labelPrefix, name }) => {
272
+ const address = {
273
+ baseUrl: baseUrl ?? row?.baseUrl,
274
+ labelPrefix: labelPrefix ?? row?.labelPrefix,
275
+ };
276
+ if (!address.baseUrl || !address.labelPrefix) {
277
+ throw new Error(`Trigger source '${name}' has no address yet: pass baseUrl and labelPrefix, or deploy so reconcile records them.`);
278
+ }
279
+ return address;
280
+ };
281
+ /**
282
+ * Turns a declared webhook source on and registers it with its provider, at
283
+ * the address given or the one the last deployment recorded.
284
+ */
285
+ export const enableTriggerSource = async ({ singletonServices = getSingletonServices(), ...input }) => {
286
+ const store = requireTriggerSourceStore(singletonServices);
287
+ const meta = getSourceMeta(input.name);
288
+ await store.syncTriggerSources(declaredTriggerSources());
289
+ const row = await store.getTriggerSource(input.name);
290
+ const address = switchAddress(row, input);
291
+ await store.setTriggerSourceEnabled(input.name, true);
292
+ return registerTriggerSource(store, meta, address, row?.state ?? undefined, singletonServices);
293
+ };
294
+ /**
295
+ * Turns a webhook source off: it stops receiving at once, then is removed
296
+ * from its provider. A failed teardown leaves it off and records the error.
297
+ */
298
+ export const disableTriggerSource = async ({ singletonServices = getSingletonServices(), ...input }) => {
299
+ const store = requireTriggerSourceStore(singletonServices);
300
+ const meta = getSourceMeta(input.name);
301
+ const row = await store.getTriggerSource(input.name);
302
+ const address = switchAddress(row, input);
303
+ await store.setTriggerSourceEnabled(input.name, false);
304
+ const outcome = await runSourceLifecycleStep(meta, 'teardown', address, row?.state ?? undefined, singletonServices);
305
+ await store.recordTriggerSource(input.name, {
306
+ status: outcome.status,
307
+ ...(outcome.status === 'failed' ? {} : { state: null }),
308
+ detail: detailOf(outcome),
309
+ });
310
+ return outcome;
311
+ };
312
+ /** The singleton credentials the package owning a source's `setup` declares: where its signing secret goes. */
313
+ const sourceCredentialNames = (meta) => {
314
+ if (!meta.setup)
315
+ return [];
316
+ const namespace = meta.setup.includes(':') ? meta.setup.split(':')[0] : '';
317
+ const packageName = pikkuState(null, 'addons', 'packages').get(namespace)?.package ?? null;
318
+ const declared = pikkuState(packageName, 'package', 'credentialsMeta') ?? {};
319
+ return Object.entries(declared)
320
+ .filter(([, credential]) => credential.type === 'singleton' && !credential.oauth2)
321
+ .map(([name]) => name);
322
+ };
323
+ const sameEvents = (a, b) => a.length === b.length && a.every((event) => b.includes(event));
324
+ /**
325
+ * Registers every declared webhook source against registrations the caller
326
+ * keeps rather than a `triggerSourceStore`, as `pikku dev` does in a file that
327
+ * outlives its database. A source whose url and events match what was
328
+ * registered is left alone without calling the provider, and its signing
329
+ * secrets are put back if the credential store lost them. Registrations no
330
+ * source declares any more are returned as orphans and kept, since only the
331
+ * code that set one up could tear it down.
332
+ */
333
+ export const reconcileWebhookRegistrations = async ({ registrations, singletonServices = getSingletonServices(), ...input }) => {
334
+ const credentialService = singletonServices.credentialService;
335
+ const sources = pikkuState(null, 'trigger', 'webhookSourceMeta');
336
+ const next = {};
337
+ const outcomes = [];
338
+ for (const meta of Object.values(sources)) {
339
+ const { url } = sourceAddress(meta, input);
340
+ const events = subscribedWebhookEvents(meta.name);
341
+ const previous = registrations[meta.name];
342
+ const names = sourceCredentialNames(meta);
343
+ if (credentialService) {
344
+ for (const [name, value] of Object.entries(previous?.credentials ?? {})) {
345
+ if (!(await credentialService.has(name))) {
346
+ await credentialService.set(name, value);
347
+ }
348
+ }
349
+ }
350
+ if (previous &&
351
+ previous.status !== 'failed' &&
352
+ previous.url === url &&
353
+ sameEvents(previous.events, events)) {
354
+ next[meta.name] = previous;
355
+ outcomes.push({ source: meta.name, url, status: 'unchanged' });
356
+ continue;
357
+ }
358
+ const outcome = await runSourceLifecycleStep(meta, 'setup', input, previous?.state, singletonServices);
359
+ const credentials = {};
360
+ for (const name of names) {
361
+ const value = await credentialService?.get(name);
362
+ if (value != null)
363
+ credentials[name] = value;
364
+ }
365
+ const state = outcome.state ?? previous?.state;
366
+ next[meta.name] = {
367
+ url,
368
+ events,
238
369
  status: outcome.status,
239
- ...(outcome.state ? { state: outcome.state } : {}),
240
- detail: detailOf(outcome),
241
- });
370
+ ...(state ? { state } : {}),
371
+ ...(Object.keys(credentials).length ? { credentials } : {}),
372
+ };
242
373
  outcomes.push(outcome);
243
374
  }
244
- return outcomes;
375
+ const orphans = [];
376
+ for (const [source, registration] of Object.entries(registrations)) {
377
+ if (sources[source])
378
+ continue;
379
+ next[source] = registration;
380
+ orphans.push({
381
+ source,
382
+ url: registration.url,
383
+ label: `${input.labelPrefix}:${source}`,
384
+ });
385
+ }
386
+ return { registrations: next, outcomes, orphans };
245
387
  };
246
388
  /**
247
389
  * Removes the named webhook sources from their providers and forgets them.
@@ -385,15 +385,15 @@ export class PikkuWorkflowService {
385
385
  const shouldInline = options?.inline || !getSingletonServices()?.queueService;
386
386
  return runWorkflowGraph(this, name, input, rpcService, shouldInline, options?.startNode, wire, workflowMeta);
387
387
  }
388
- const registrations = pikkuState(packageName, 'workflows', 'registrations');
389
- const workflow = registrations.get(resolved?.resolvedName ?? name);
390
- if (!workflow) {
391
- throw new WorkflowNotFoundError(name);
392
- }
393
388
  if (!workflowMeta.graphHash) {
394
389
  throw new Error(`Missing workflow graphHash for '${name}'`);
395
390
  }
396
391
  const shouldInline = options?.inline || !getSingletonServices()?.queueService;
392
+ // A queued run needs only the meta; the orchestrator's unit runs it.
393
+ const registrations = pikkuState(packageName, 'workflows', 'registrations');
394
+ if (shouldInline && !registrations.get(resolved?.resolvedName ?? name)) {
395
+ throw new WorkflowNotFoundError(name);
396
+ }
397
397
  const runId = await this.createRun(name, input, shouldInline, workflowMeta.graphHash, wire, {
398
398
  deterministic: workflowMeta.deterministic,
399
399
  plannedSteps: workflowMeta.plannedSteps,
@@ -26,9 +26,10 @@ rotating it invalidates every credential at once.
26
26
 
27
27
  This is what lets a holder be handed less than everything:
28
28
 
29
- - The browser switcher gets `VITE_DEV_ACTOR_SECRETS`, one credential per
30
- declared persona. The root stays on the dev server, so a bundle can no longer
31
- hold the thing that is entitled to every persona.
29
+ - The browser switcher gets no credential at all. It used to be handed
30
+ `VITE_DEV_ACTOR_SECRETS`, one per persona; it now names a persona by id at
31
+ `/sign-in/persona`, which the server gates with the actor opt-in and the
32
+ `devSwitcher` flag, so a bundle holds nothing worth taking.
32
33
  - A run can be given `PIKKU_PERSONA_SECRETS` (`id=secret,…`, minted with
33
34
  `pikku persona secret`) instead of the root, and then it can sign in as those
34
35
  personas and no others. Asking for one outside the list throws naming the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.129",
3
+ "version": "0.12.130",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -183,9 +183,12 @@
183
183
  "PIKKU_INCOMING_WEBHOOK_QUEUE_NAME",
184
184
  "PikkuTriggerService",
185
185
  "declaredTriggerSources",
186
+ "disableTriggerSource",
186
187
  "dispatchWebhookSourceJob",
188
+ "enableTriggerSource",
187
189
  "receiveWebhookSourceRequest",
188
190
  "reconcileTriggerSources",
191
+ "reconcileWebhookRegistrations",
189
192
  "runWebhookSourceLifecycle",
190
193
  "subscribedWebhookEvents",
191
194
  "teardownTriggerSources",