@pikku/mongodb 0.12.11 → 0.12.13

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,5 +1,411 @@
1
1
  # @pikku/mongodb
2
2
 
3
+ ## 0.12.13
4
+
5
+ ### Patch Changes
6
+
7
+ - 6a307f0: Fix four latent correctness bugs in the function, RPC and error runtimes, and
8
+ remove dead code from the workflow service surface.
9
+
10
+ `WorkflowService.getNodesWithoutSteps` is gone. It was declared on the abstract
11
+ service and implemented by all five storage backends, and nothing ever called
12
+ it — hence the non-core packages in this changeset, which only lose that method.
13
+
14
+ **An RPC could execute twice.** `RPCService.invoke`, its addon path, and
15
+ `rpcWithWire` each wrapped the _execution_ of a resolved function in a `try`
16
+ whose `catch` treated `RPCNotFoundError` as "not found locally" and re-dispatched
17
+ the call through `deploymentService`. A nested `rpc.invoke` to a missing name,
18
+ raised from inside an already-running function, therefore re-ran that function on
19
+ a remote instance after its local side effects had already committed. Resolution
20
+ is now separated from execution on all three paths, so only a genuinely
21
+ unresolvable name reaches the fallback. What escapes to callers is unchanged.
22
+
23
+ **`addonNamespace` leaked between sibling calls.** The function runner's
24
+ middleware path restored `rpc`, `functionId`, `audit` and `addonNamespace` after
25
+ an invocation; the non-middleware path restored the first three but not the
26
+ fourth. A call into an addon function with no middleware left the addon's
27
+ namespace on the wire, so subsequent sibling calls resolved the wrong
28
+ per-instance singletons and `credentialOverrides`.
29
+
30
+ **Errors registered on a subclass never resolved.** `misc.errors` was typed
31
+ `Map<PikkuError, ErrorDetails>` — instances — while `addError` stores
32
+ constructors, a mismatch hidden by its `error: any` parameter. The instance
33
+ lookup in `getErrorResponse` was consequently dead, and lookup fell through to a
34
+ scan comparing `constructor.name`, so a subclass of a registered error got no
35
+ status mapping at all. The map is now typed `Map<PikkuErrorConstructor, …>` and
36
+ lookup walks the prototype chain first. Name matching is retained, deliberately,
37
+ as the fallback that keeps error mapping working when two copies of
38
+ `@pikku/core` are installed.
39
+
40
+ **`createWeakUID` collided across instances.** The prefix was
41
+ `Date.now().toString(36)` evaluated at module load, so any two instances loading
42
+ the module in the same millisecond emitted identical `channelId` and `requestId`
43
+ sequences — reproducibly, not just in principle. It is now seeded lazily from
44
+ `crypto.randomUUID()`.
45
+
46
+ Also: `pikkuState` keys its global map with `Symbol.for` rather than `Symbol`, so
47
+ two copies of the package share registrations instead of silently getting
48
+ disjoint state; and the local channel upgrade path no longer keys its middleware
49
+ cache on the raw request path, which grew the cache without bound while the
50
+ cached value never varied by path.
51
+
52
+ - afef587: Close eleven security weaknesses found in a review of `@pikku/core`. Most are
53
+ breaking, and two invalidate data or credentials already in the wild — read the
54
+ migration notes before upgrading.
55
+
56
+ **Breaking: AI agent thread ownership now fails closed.** Reading, listing,
57
+ resuming or approving an existing thread or run requires a resolved session
58
+ principal (`userId`, or `orgId` for `sessionScope: 'org'`), regardless of the
59
+ agent's `auth` setting. Previously a request without a session had no ownership
60
+ model at all: the caller-supplied `resourceId` was accepted as the ownership
61
+ key, so any caller could read or resume another party's thread by naming its
62
+ `resourceId`. Worse, `threadOwnerConstraint` returned `undefined` for a
63
+ sessionless caller, and `undefined` means _no filter_ rather than _no rows_ —
64
+ so `getAgentThreads` returned every thread in the deployment. It now returns
65
+ `string[]`, empty for a sessionless caller, which every storage backend already
66
+ treats as matching nothing. Sessionless agents still run one-shot conversations,
67
+ each with a fresh unguessable owner; what they lose is cross-request continuity.
68
+ Wire a session to restore it.
69
+
70
+ **Breaking: stored secrets and credentials must be re-entered.** `deriveKey` ran
71
+ a single unsalted round of SHA-256 over the passphrase and used the digest
72
+ directly as the AES-GCM key — roughly one hash per brute-force guess, with one
73
+ rainbow table working against every deployment. It is now PBKDF2-HMAC-SHA256 at
74
+ 600,000 iterations over a random salt. There is no compatibility path: every
75
+ value held by the kysely, mongodb and redis secret services and the kysely
76
+ credential service becomes undecryptable. They fail loud, naming the key and
77
+ `key_version`, so the app hard-fails on first secret read until each is re-set.
78
+
79
+ The KEK salt is scoped to the key version and stored alongside it, rather than
80
+ per secret, so a bulk read costs one derivation instead of N — `getSecrets` over
81
+ 50 secrets went from ~2.3s to ~48ms, and rotation from ~4.6s to ~94ms. This adds
82
+ a salt table (kysely), hash field (redis) or collection (mongodb), created
83
+ automatically on first use.
84
+
85
+ **Breaking: `PIKKU_REMOTE_SECRET` must be at least 32 characters.** The
86
+ remote-RPC session envelope moved from PBKDF2 to HKDF, which expands
87
+ high-entropy key material rather than stretching a low-entropy passphrase. That
88
+ took a remote hop from ~269ms to ~0.4ms — PBKDF2 was running twice per request —
89
+ but HKDF supplies no brute-force resistance, so the secret must carry the
90
+ entropy itself. A shorter secret now throws `WeakKeyMaterialError` at both ends.
91
+ Generate one with `openssl rand -base64 32` and roll it out to every service in
92
+ the mesh together: existing bearer tokens are format-incompatible, so a partial
93
+ rollout produces 401s until every instance is updated. The Cloudflare, Lambda
94
+ and Azure deployment services each hand-rolled a copy of `buildRemoteHeaders`
95
+ and now call the shared one, which is what keeps the two sides in step.
96
+
97
+ **Breaking: previously signed content URLs stop verifying.** `LocalContent`
98
+ signed only `{signedAt, expiresAt, notBefore}`, so a signature proved when a URL
99
+ was issued but never what it was issued for — any valid token was a skeleton
100
+ key, and swapping the pathname from a public thumbnail to a private document
101
+ still verified. The signature now binds the request path. Separately, the
102
+ verifier returned "valid" when no JWT service was wired, which is how
103
+ `pikku serve` ran: a forged `?signedAt=0&expiresAt=99999999999999` was accepted.
104
+ It now rejects with 403, `LocalContent` requires a `JWTService`, and
105
+ `pikku serve`/`pikku dev` mint an ephemeral per-process signing key so local
106
+ development works without shipping a fail-open path. In-flight signed URLs must
107
+ be re-issued.
108
+
109
+ **Request body size limits now apply to every adapter.** The `maxBodySize` cap
110
+ existed only in `PikkuFetchHTTPRequest`. The real hole was uWebSockets, which
111
+ drove `res.onData` itself and concatenated every chunk with no bound and nothing
112
+ downstream able to intervene; it now drops chunks past the limit and replies 413
113
+ before routing. Fastify delegates to its native `bodyLimit` (set only when
114
+ `maxBodySize` is configured, so fastify's stricter 1 MB default is never
115
+ loosened), and `PikkuExpressServer` feeds the limit into its body parsers. Two
116
+ paths can only reject rather than prevent, and are documented as such:
117
+ `express-middleware` mounted on your own app receives an already-parsed body, so
118
+ that deployment must bound its own parser; Next server actions bottom out at
119
+ `experimental.serverActions.bodySizeLimit`.
120
+
121
+ **Breaking: the console addon's privileged functions are gated by default.**
122
+ `wireAddon` gains a `scopes` option that applies to every function in the
123
+ addon's namespace, and the console scaffold now generates
124
+ `wireAddon({ name: 'console', package: '@pikku/addon-console', scopes: ['admin'] })`.
125
+ Previously the console's entire privileged surface — around 54 functions
126
+ including `credentialGet`, which returns a resolved OAuth token for an arbitrary
127
+ `userId`, `updateFunctionBody`, and `installAddon`, which shells out to a
128
+ package install — was protected only by an optional host-registered
129
+ `addGlobalPermission`. `resolveGlobalPermissions` returns `[]` when none is
130
+ registered and permission checking then no-ops, so an app that never registered
131
+ one served those functions to anyone, and with the template's default
132
+ `scaffold.rpc: "no-auth"` that meant unauthenticated. All of them now return 403
133
+ `MissingScopeError` without an `admin`, `admin:*` or `*` scope. **Regenerating
134
+ is required** — an app holding an old `console.gen.ts` stays open.
135
+ `installAddon` and `installOpenapiAddon` additionally declare their own
136
+ `auth: true, scopes: ['admin']`, and `getAgentThreads` now scopes its listing to
137
+ the session's own threads unless the caller holds admin.
138
+
139
+ Addon scopes are enforced in `runPikkuFunc` rather than at the RPC boundary,
140
+ because a wiring can reference an addon function directly — the inspector
141
+ records the addon's `packageName` on HTTP, channel, schedule, queue, CLI,
142
+ trigger, gateway and MCP wirings — and those paths never call `resolveNamespace`.
143
+ Enforcing at the RPC seam would have covered only the `namespace:function` form
144
+ while reading as complete.
145
+
146
+ **Breaking: `wireAddon`'s `auth` and `tags` now apply on direct wirings too.**
147
+ Both were read only by `resolveAddonFunction`, so they had exactly the hole
148
+ scopes had: `wireAddon({ name: 'console', package: '@pikku/addon-console', auth:
149
+ true, tags: ['admin'] })` gated `rpc('console:credentialGet')` and gated nothing
150
+ at all on an HTTP route wired straight to `credentialGet`. A consumer who
151
+ reached for the documented way to lock an addon down got a control that was
152
+ silently inert on every wiring except one. Both now resolve in `runPikkuFunc`.
153
+ `auth` merges as an OR — `auth: false` from an addon is ignored on a direct
154
+ wiring, because an addon may require a session the wiring did not but must never
155
+ waive one it did. Addon tags resolve to concrete middleware against the **root**
156
+ tag groups before the call rather than being folded into the function's
157
+ inherited middleware: `addTagMiddleware('admin', …)` is written by the consuming
158
+ app and registers under the root package, whereas `combineMiddleware` would look
159
+ the tag up under the addon's own `packageName`, where it does not exist.
160
+
161
+ One consequence worth naming: an addon that wires `auth: true` and also runs its
162
+ own sessionless internal work — a scheduled task or queue worker inside the
163
+ addon calling a sibling function — is now gated where it previously was not,
164
+ because a bare `rpc('fn')` from inside the addon reaches `runPikkuFunc` with the
165
+ addon's `packageName` like any other call. This reverses a decision that
166
+ deliberately scoped the gate to the namespaced boundary; that reasoning held
167
+ only while the boundary was real, and a direct wiring can enter an addon without
168
+ crossing it, so "already inside" is not something the runtime can infer. Such an
169
+ addon should carry authorization on the function via
170
+ `pikkuFunc({ permissions })`, which has always been enforced on every path. A
171
+ follow-up will add execution provenance so an intra-addon call can be
172
+ distinguished from an external one and skip the addon-level check; that needs a
173
+ marker no caller outside the process can set, which is its own design problem
174
+ and does not belong in a security fix.
175
+
176
+ **Codegen now warns when an exposed function has no gate (PKU574).** The
177
+ generated `POST /rpc/:rpcName` dispatcher forwards to `rpc.exposed`, which
178
+ refuses anything without `expose: true` — but nothing checked whether the
179
+ target was gated, because a dispatcher cannot know what it dispatches to. The
180
+ console shipped ~54 privileged functions through that gap and the toolchain was
181
+ silent. The inspector now reports every function that is exposed, sessionless,
182
+ and carries no `auth`, `scopes` or `permissions` of its own and none from a
183
+ governing `wireAddon`. It is a `warn`, not a critical: `expose: true` on an
184
+ ungated sessionless function is correct for a genuinely public endpoint, so it
185
+ blocks a build only under `--fail-on-warn`.
186
+
187
+ Two pieces of metadata were missing for this to be answerable statically, and
188
+ both are now recorded. A `pikkuSessionlessFunc`'s own `auth: true` was read at
189
+ runtime but never written to function meta, so a self-gated function was
190
+ indistinguishable from an ungated one — `sessionless` carries the baseline, and
191
+ `auth` now carries the tightening. And `wireAddon`'s `scopes`, `auth` and `tags`
192
+ were not parsed at all: the inspector recorded the addon's `rpcEndpoint` and its
193
+ secret, variable and credential overrides, and dropped every one of its gates.
194
+ An addon whose gates are not statically knowable is treated as gated, because a
195
+ false positive on a correctly-secured addon costs more than the one case it
196
+ would catch.
197
+
198
+ **Breaking: an application's global permissions now apply inside addons.**
199
+ `resolveGlobalPermissions` read only the bucket matching the function's own
200
+ package, but the generated `addGlobalPermission` wrapper takes no package
201
+ argument and always registers under the root. An app-wide rule like "every
202
+ request needs a signed-in user" therefore stopped at the addon boundary, and the
203
+ bucket an addon's functions did read was one no host could write to — which is
204
+ why the console addon's recommended `addGlobalPermission([isAdmin],
205
+ '@pikku/addon-console')` was never a gate anybody could actually install. A
206
+ function now resolves the root bucket and its own package's, root first.
207
+ Unioning is safe in a way nothing else here would be: globals AND, so adding the
208
+ root ones can only tighten. Package buckets stay one-way — a package's globals
209
+ never reach root functions, or an installed addon could gate the whole
210
+ application. Apps with both a root global and addon-provided functions will see
211
+ those functions gated where they previously were not.
212
+
213
+ **Codegen now records whether each HTTP route requires a session, and warns
214
+ about inert addon tags (PKU575).** Four separate things can demand a session —
215
+ the function's `sessionless`, its own `auth`, the route's (or its group's)
216
+ `auth`, and the addon it belongs to — and answering "which routes are open?"
217
+ meant joining all four by hand and knowing which wins. Each route's meta now
218
+ carries the resolved `requiresSession` alongside the route's own `auth`. Scopes
219
+ count as requiring one, since they are matched against the session's and fail
220
+ closed. Anything not statically knowable resolves to `true`, matching PKU574:
221
+ a route that looks stricter than it is costs less than one that looks open and
222
+ isn't. Separately, `wireAddon({ tags: ['admin'] })` reads like a gate and is
223
+ applied like one right up until no `addTagMiddleware('admin', …)` exists, at
224
+ which point it resolves to an empty list and gates nothing; that now warns.
225
+ Only addon tags are reported — a tag on a function is as likely to be
226
+ organizational, and warning about those would bury the case that matters.
227
+
228
+ **Object-shorthand permissions were missing from meta.** The inspector visited
229
+ `ts.PropertyAssignment` but not `ts.ShorthandPropertyAssignment`, so
230
+ `permissions: { canAdminOrg }` — enforced identically to the longhand form at
231
+ runtime, since `verifyPermissions` has a non-array branch — was recorded as _no
232
+ permissions at all_. That is the most dangerous direction for meta to be wrong
233
+ in: an audit reading it sees an open door where one is shut. It cost this review
234
+ a false IDOR report across ~35 billing and org functions before the source
235
+ contradicted the metadata.
236
+
237
+ **Functions that authorize in their own body can say so.** A webhook receiver
238
+ verifying a signature, or a handler redeeming a signed invite, is genuinely
239
+ closed while carrying no session, scope or permission — indistinguishable in
240
+ meta from one nobody remembered to gate, and so warned about forever by PKU574.
241
+ `selfAuthenticated: true` on the function config records the claim and silences
242
+ the warning for that function. It is declarative only: nothing at runtime reads
243
+ it and it grants nothing. Detection was rejected deliberately — inferring it
244
+ from the body means a function that _looks_ like it checks something silences
245
+ the warning while checking nothing, and a warning that is usually wrong stops
246
+ being read.
247
+
248
+ **Breaking: `scaffold.<feature>` is now `boolean | { auth?, path? }`, and `true`
249
+ means authenticated.** The old `'auth' | 'no-auth' | false` read like a
250
+ starter-file preference while being a live authorization decision, set three
251
+ directories from the functions it governed — the shape the console incident
252
+ took. A surface now becomes public only by writing `{ auth: false }`, so
253
+ omitting a field can never open anything: the failure mode of a forgotten flag
254
+ is a locked door. `{ path }` additionally overrides where the file is
255
+ generated, which previously could only be set for all features at once via
256
+ `pikkuDir`.
257
+
258
+ The legacy strings are **refused, not coerced**. `resolveScaffoldFeature` throws
259
+ naming the key and its replacement (`"rpc": "no-auth"` → `"rpc": { "auth":
260
+ false }`), and it does so at config load, not downstream. An earlier design used
261
+ a bare `string` for the output path, under which `"no-auth"` would have parsed
262
+ as a file named `no-auth` and every unmigrated config would have silently
263
+ produced nonsense; the object form makes any string invalid, so the failure is
264
+ loud.
265
+
266
+ The collapse is deliberately not uniform in effect. For `rpc` the flag was a
267
+ blanket "no anonymous RPC in this app" set on a dispatcher that cannot know what
268
+ it dispatches to; for `userAdmin` it was redundant, since the generated
269
+ functions are already `pikkuFunc` with `scopes: ['admin:users:list']`. But for
270
+ `agent`, `workflow`, `events` and `scenarios` it is the only gate — those
271
+ generate real endpoints the app never authors — so `true` keeps them
272
+ authenticated rather than opening them as a side effect of a config cleanup.
273
+ `webhook` and `remoteRpc` have no auth dimension at all (`serialize-remote-rpc.ts`
274
+ hardcodes `auth: false`), so an `{ auth }` on them is ignored. The three
275
+ configs in this repo are migrated preserving their current behaviour exactly.
276
+
277
+ **Queue job identities are signed.** A job carried the producer's `pikkuUserId`
278
+ as a plain string and the worker resolved a session from it with no
279
+ verification, so write access to the queue backend was act-as-any-user. The
280
+ identity is now `pq1.<claim>.<hmac>`, HMAC-SHA256 over the claim and the
281
+ canonicalized job payload, keyed by HKDF expansion of a new
282
+ `PIKKU_QUEUE_IDENTITY_SECRET`. Producers opt in by wrapping their queue service
283
+ with `SignedQueueService`. This fails safe rather than closed: with no secret
284
+ configured the identity is dropped and jobs still process, warning once per
285
+ process, so no existing deployment breaks on upgrade — it simply loses queue
286
+ identity until the secret is set. The payload rather than the job id is bound
287
+ because SQS, Cloudflare Queues, Azure and the in-memory service all mint ids
288
+ after `add` returns.
289
+
290
+ **Workflow inline state is read from the run record.** `isInline` consulted a
291
+ process-local `Map`, while `WorkflowRun.inline` is durable. Any instance that
292
+ did not start a run disagreed with the record, so one instance could dispatch a
293
+ queued job for a workflow another was already executing in-process. It is now
294
+ async and resolves through the durable identity, cached only when a context
295
+ already exists so a passive reader allocates nothing. The same `Map` also leaked:
296
+ `nextStepKey` fabricated replay state on every step, and `releaseContext`
297
+ refused to free anything carrying it, so runs whose steps executed outside a
298
+ `beginReplay` bracket — the step-worker queue path — stranded their context and
299
+ step state for the process lifetime. Contexts are now released by an explicit
300
+ execution counter. Step ordinals reset per execution rather than accumulating
301
+ across step-worker invocations in one process, which makes step naming
302
+ independent of how work was distributed.
303
+
304
+ **Secret reads fail loud in every store.** `MongoDBSecretService.getSecrets`
305
+ skipped rows that failed to decrypt, and the redis equivalent dropped every
306
+ rejection via `Promise.allSettled`, including the "No KEK available for
307
+ key_version N" configuration error. Both now throw, naming the key and its key
308
+ version, matching the kysely behaviour. This matters most alongside the KEK
309
+ change above: without it, an upgrade surfaces as a partial secrets map and an
310
+ opaque downstream failure instead of an error naming the secret to re-enter.
311
+
312
+ **A second middleware registration for a pattern no longer erases the first.**
313
+ `addHTTPMiddleware`, `addTagMiddleware` and `addChannelMiddleware` groups are
314
+ keyed by pattern or tag and held one source file each, so a second file's call
315
+ overwrote the first's. Codegen emits its imports from what is stored, so the
316
+ losing file was never imported and its middleware never registered — the
317
+ runtime composes repeated registrations for a pattern happily, and only codegen
318
+ dropped one. Adding an unrelated `addHTTPMiddleware('*', …)` to an app was
319
+ enough to silently unregister the generated better-auth session bridge, which
320
+ fails open and gives no sign until a request arrives without a session. A group
321
+ now carries every registration made for it, and all of them are imported.
322
+
323
+ - Updated dependencies [6a307f0]
324
+ - Updated dependencies [afef587]
325
+ - Updated dependencies [8075f6a]
326
+ - @pikku/core@0.12.74
327
+
328
+ ## 0.12.12
329
+
330
+ ### Patch Changes
331
+
332
+ - 8a2c993: Make the workflow service cheaper to run, and fix two ways it lost state.
333
+
334
+ The SQL workflow tables had no indexes at all, so every step read, every
335
+ history walk and every orchestrator tick was a sequential scan; five indexes
336
+ now cover the columns the engine actually queries by. A replay used to ask for
337
+ each step's row individually — O(N) reads per replay, O(N^2) over a run — and
338
+ now takes one read of the run's steps and serves the walk from it. A step
339
+ transition wrote the step row and its history row as two separate statements,
340
+ so a crash between them left a step saying `succeeded` whose history still said
341
+ `running`; both halves are now one transaction, and the history row is found by
342
+ attempt number rather than by sorting on `created_at`, which two attempts can
343
+ share. Resolving a dynamic workflow read and parsed every AI-generated workflow
344
+ in the deployment to `.find()` one by name; it is a point lookup now.
345
+
346
+ Waiting on a run no longer polls at a fixed interval. `pollIntervalMs` became a
347
+ ceiling rather than a cadence: polling starts at 10ms and widens towards it, so
348
+ a workflow that finishes in milliseconds is no longer held for a full second,
349
+ and a long-running one is not read at full rate for its whole life.
350
+
351
+ Two backend-specific defects: Redis kept a run's state as one JSON blob and
352
+ read-modified-wrote it, so parallel branches setting different variables
353
+ overwrote each other — state is a field per variable now, with the old blob
354
+ still read underneath so runs in flight keep what they had. Mongo's
355
+ `setStepScheduled` never wrote history, leaving a queued step reading as never
356
+ dispatched.
357
+
358
+ Also: dispatch no longer JSON round-trips every step payload before handing it
359
+ to a queue that serialises it anyway — the in-process dev queue, which is the
360
+ only one that was relying on it, does it itself now.
361
+
362
+ Two more defects. A transition whose step had no live attempt wrote its status
363
+ to the step row and silently nothing to history — the exact divergence the
364
+ transaction exists to prevent — and now repairs the step and writes the
365
+ missing row. And resolving a dynamic workflow was non-deterministic on all
366
+ three backends: a name can hold several active versions, and none of them
367
+ ordered the candidates, so which one ran could change between two calls
368
+ reading identical data. The newest version wins, with the graph hash breaking
369
+ a tie.
370
+
371
+ The two attempt columns and the five indexes are declared in the workflow
372
+ schema, so a fresh database gets them at boot. An existing one gets them from
373
+ a migration — `pikku db generate` writes the declaration down — rather than
374
+ from DDL issued at boot.
375
+
376
+ - a261006: **Breaking:** removed dynamic workflows — runtime-defined workflow graphs stored in the database and resolved by name instead of by codegen.
377
+
378
+ The feature was already half-gone. Its authoring surface (`createAgentWorkflow`, `saveAgentWorkflow`, `listAgentWorkflows`, `executeAgentWorkflow`, and the AI-agent instruction builder) was deleted in April 2026 along with its entire e2e suite, and nothing has written a dynamic workflow since. What remained could not execute one either: `executeAgentWorkflow` gated on `pikkuState('workflows', 'meta')`, which only codegen ever populates, so a graph that existed solely in the database was never findable. The two backend families had also drifted onto different `source` sentinels (`'ai-agent'` vs `'dynamic-workflow'`), and the two Redis implementations disagreed on key escaping — so at least one of them matched nothing. Rather than keep shipping plumbing for a path no caller could complete, it is removed until it can be reintroduced deliberately.
379
+
380
+ Removed:
381
+ - `getAIGeneratedWorkflows` from `WorkflowService` and `WorkflowRunService`, and from every backend (in-memory, Redis, MongoDB, Kysely, and the Cloudflare Durable Object service and client — the last two were already a `return []` stub and a rejection).
382
+ - The database-lookup fallbacks in `startWorkflow` and `runWorkflowJob` that resolved a workflow name against stored graphs when static meta had no match.
383
+ - `'dynamic-workflow'` from the `WorkflowRuntimeMeta['source']` union.
384
+ - `validateWorkflowWiring` and `computeEntryNodeIds` from `@pikku/core/workflow`. These validated AI-authored graphs and had no callers in core; the inspector keeps its own private entry-node computation for static graph wiring, which is unaffected.
385
+ - The `workflow-created` AI stream event and its AG-UI `pikku:workflow-created` custom event. Its only emitter went with the April deletion, so it could never fire.
386
+ - The console's `console:getAIWorkflows` RPC, the `useAIWorkflows` hook, the "Dynamic" workflow filter and badge, and the trigger-schema scraper that derived an input form from a stored graph's `$ref` bindings.
387
+
388
+ Kept, because static graph workflows depend on them and this is not a change to versioning:
389
+ - `upsertWorkflowVersion`, `getWorkflowVersion`, `updateWorkflowVersionStatus`, and the `workflowVersions` storage in every backend. These back version-mismatch replay: when a deployed graph's hash changes, in-flight runs continue against the exact graph they started on. No schema migration is needed — the table, its columns, and its `(workflowName, graphHash)` upsert key are unchanged.
390
+ - `generateMermaidDiagram`, which renders any workflow graph and is not specific to dynamic ones.
391
+
392
+ Static `pikkuWorkflowGraph` and DSL workflows are entirely unaffected: they resolve from codegen'd meta, which was always the only path that worked.
393
+
394
+ To revive this post-MVP, the deleted authoring code is recoverable in full — its prompt engineering (a compact tool table upfront, full schemas with flattened dotted output paths returned only after a validation failure) is worth reading before rewriting:
395
+
396
+ ```
397
+ git show f52f3308b^:packages/core/src/wirings/ai-agent/agent-dynamic-workflow.ts
398
+ git show f52f3308b^:packages/core/src/wirings/workflow/graph/graph-validation.ts
399
+ git show f52f3308b --stat # the April removal, incl. the three e2e feature files
400
+ ```
401
+
402
+ Note that reviving it needs more than restoring those files: the queued-step path (`executeWorkflowStep`), `onError` compensation, and sub-workflow resolution all read static meta only and would need a fallback for a graph that exists solely in the database.
403
+
404
+ - Updated dependencies [8a2c993]
405
+ - Updated dependencies [a261006]
406
+ - Updated dependencies [09973b9]
407
+ - @pikku/core@0.12.71
408
+
3
409
  ## 0.12.11
4
410
 
5
411
  ### Patch Changes
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 - present Yasser Fadl and Pikku contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # @pikku/mongodb
2
+
3
+ MongoDB implementations of the Pikku service interfaces — workflow state, agent
4
+ runs, secrets, and the channel and event-hub stores.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ npm install @pikku/mongodb
10
+ ```
11
+
12
+ ## Usage
13
+
14
+ ```typescript
15
+ import { PikkuMongoDB, MongoDBWorkflowService } from '@pikku/mongodb'
16
+
17
+ const { db } = new PikkuMongoDB(logger, 'mongodb://localhost:27017')
18
+
19
+ const workflowService = new MongoDBWorkflowService(db)
20
+ ```
21
+
22
+ `PikkuMongoDB` also accepts an existing `MongoClient`, in which case it will not
23
+ close the connection on shutdown.
24
+
25
+ ## Docs
26
+
27
+ https://pikku.dev/docs
@@ -17,9 +17,13 @@ export declare class MongoDBSecretService implements SecretService {
17
17
  private auditReads;
18
18
  private secrets;
19
19
  private secretsAudit;
20
+ private kekSaltDocs;
21
+ private kekSalts;
22
+ private keks;
20
23
  constructor(db: Db, config: MongoDBSecretServiceConfig);
21
24
  init(): Promise<void>;
22
25
  private logAudit;
26
+ private getKEKSalt;
23
27
  private getKEK;
24
28
  getSecret<T = string>(key: string): Promise<T>;
25
29
  hasSecret(key: string): Promise<boolean>;
@@ -1,4 +1,4 @@
1
- import { envelopeEncrypt, envelopeDecrypt, envelopeRewrap, } from '@pikku/core/crypto-utils';
1
+ import { deriveKEK, generateKEKSalt, envelopeEncrypt, envelopeDecrypt, envelopeRewrap, } from '@pikku/core/crypto-utils';
2
2
  export class MongoDBSecretService {
3
3
  db;
4
4
  initialized = false;
@@ -9,6 +9,9 @@ export class MongoDBSecretService {
9
9
  auditReads;
10
10
  secrets;
11
11
  secretsAudit;
12
+ kekSaltDocs;
13
+ kekSalts = new Map();
14
+ keks = new Map();
12
15
  constructor(db, config) {
13
16
  this.db = db;
14
17
  this.key = config.key;
@@ -22,6 +25,7 @@ export class MongoDBSecretService {
22
25
  return;
23
26
  this.secrets = this.db.collection('secrets');
24
27
  this.secretsAudit = this.db.collection('secrets_audit');
28
+ this.kekSaltDocs = this.db.collection('secret_kek_salts');
25
29
  await this.secrets.createIndex({ _id: 1 });
26
30
  if (this.audit) {
27
31
  await this.secretsAudit.createIndex({ secretKey: 1 });
@@ -40,18 +44,34 @@ export class MongoDBSecretService {
40
44
  performedAt: new Date(),
41
45
  });
42
46
  }
43
- getKEK(version) {
44
- if (version === this.keyVersion)
45
- return this.key;
46
- if (this.previousKey)
47
- return this.previousKey;
48
- throw new Error(`No KEK available for key_version ${version}`);
47
+ async getKEKSalt(version) {
48
+ const cached = this.kekSalts.get(version);
49
+ if (cached)
50
+ return cached;
51
+ const doc = await this.kekSaltDocs.findOneAndUpdate({ _id: version }, { $setOnInsert: { salt: generateKEKSalt(), createdAt: new Date() } }, { upsert: true, returnDocument: 'after' });
52
+ if (!doc?.salt) {
53
+ throw new Error(`Failed to persist a KEK salt for key_version ${version}`);
54
+ }
55
+ this.kekSalts.set(version, doc.salt);
56
+ return doc.salt;
57
+ }
58
+ async getKEK(version) {
59
+ const cached = this.keks.get(version);
60
+ if (cached)
61
+ return cached;
62
+ const passphrase = version === this.keyVersion ? this.key : this.previousKey;
63
+ if (!passphrase) {
64
+ throw new Error(`No KEK available for key_version ${version}`);
65
+ }
66
+ const kek = await deriveKEK(passphrase, await this.getKEKSalt(version));
67
+ this.keks.set(version, kek);
68
+ return kek;
49
69
  }
50
70
  async getSecret(key) {
51
71
  const row = await this.secrets.findOne({ _id: key });
52
72
  if (!row)
53
73
  throw new Error('Requested secret not found');
54
- const kek = this.getKEK(row.keyVersion);
74
+ const kek = await this.getKEK(row.keyVersion);
55
75
  const result = await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek);
56
76
  await this.logAudit(key, 'read');
57
77
  return result;
@@ -61,7 +81,7 @@ export class MongoDBSecretService {
61
81
  return count > 0;
62
82
  }
63
83
  async setSecret(key, value) {
64
- const { ciphertext, wrappedDEK } = await envelopeEncrypt(this.key, value);
84
+ const { ciphertext, wrappedDEK } = await envelopeEncrypt(await this.getKEK(this.keyVersion), value);
65
85
  const now = new Date();
66
86
  await this.secrets.updateOne({ _id: key }, {
67
87
  $set: {
@@ -88,11 +108,12 @@ export class MongoDBSecretService {
88
108
  const out = {};
89
109
  for (const row of rows) {
90
110
  try {
91
- const kek = this.getKEK(row.keyVersion);
111
+ const kek = await this.getKEK(row.keyVersion);
92
112
  out[row._id] = await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek);
93
113
  }
94
- catch {
95
- // skip secrets that fail to decrypt
114
+ catch (cause) {
115
+ throw new Error(`Failed to decrypt secret "${row._id}" (key_version ${row.keyVersion}): ` +
116
+ `the configured KEK does not match the key it was wrapped under`, { cause });
96
117
  }
97
118
  }
98
119
  return out;
@@ -105,8 +126,10 @@ export class MongoDBSecretService {
105
126
  .find({ keyVersion: { $lt: this.keyVersion } })
106
127
  .project({ _id: 1, wrappedDek: 1 })
107
128
  .toArray();
129
+ const oldKEK = await this.getKEK(this.keyVersion - 1);
130
+ const newKEK = await this.getKEK(this.keyVersion);
108
131
  for (const row of rows) {
109
- const newWrappedDEK = await envelopeRewrap(this.previousKey, this.key, row.wrappedDek);
132
+ const newWrappedDEK = await envelopeRewrap(oldKEK, newKEK, row.wrappedDek);
110
133
  await this.secrets.updateOne({ _id: row._id }, {
111
134
  $set: {
112
135
  wrappedDek: newWrappedDEK,
@@ -26,11 +26,6 @@ export declare class MongoDBWorkflowRunService implements WorkflowRunService {
26
26
  graph: any;
27
27
  source: string;
28
28
  } | null>;
29
- getAIGeneratedWorkflows(agentName?: string): Promise<Array<{
30
- workflowName: string;
31
- graphHash: string;
32
- graph: any;
33
- }>>;
34
29
  deleteRun(id: string): Promise<boolean>;
35
30
  private mapRunRow;
36
31
  }
@@ -105,19 +105,6 @@ export class MongoDBWorkflowRunService {
105
105
  source: row.source,
106
106
  };
107
107
  }
108
- async getAIGeneratedWorkflows(agentName) {
109
- const filter = { source: 'ai-agent', status: 'active' };
110
- if (agentName) {
111
- const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
112
- filter.workflowName = { $regex: `^ai:${escaped}:` };
113
- }
114
- const rows = await this.versions.find(filter).toArray();
115
- return rows.map((row) => ({
116
- workflowName: row.workflowName,
117
- graphHash: row.graphHash,
118
- graph: row.graph,
119
- }));
120
- }
121
108
  async deleteRun(id) {
122
109
  const steps = await this.steps.find({ workflowRunId: id }).toArray();
123
110
  const stepIds = steps.map((s) => s._id);
@@ -26,6 +26,16 @@ export declare class MongoDBWorkflowService extends PikkuWorkflowService {
26
26
  }>>;
27
27
  protected setStepRunningImpl(stepId: string): Promise<void>;
28
28
  protected setStepScheduledImpl(stepId: string): Promise<void>;
29
+ /**
30
+ * Move the current attempt of a step's history to a status.
31
+ *
32
+ * Finding the newest attempt and then updating it by `_id` was two round
33
+ * trips with a window in between; `findOneAndUpdate` applies the same sort
34
+ * server-side and writes the row it selected, so nothing can move underneath
35
+ * it. The attempt is still identified by `createdAt` because Mongo keeps no
36
+ * attempt number — see `getStepState`, which counts history documents.
37
+ */
38
+ private writeLatestHistory;
29
39
  private insertHistoryRecord;
30
40
  private getTimestampFieldForStatus;
31
41
  protected setStepChildRunIdImpl(stepId: string, childRunId: string): Promise<void>;
@@ -39,7 +49,6 @@ export declare class MongoDBWorkflowService extends PikkuWorkflowService {
39
49
  failedNodeIds: string[];
40
50
  branchKeys: Record<string, string>;
41
51
  }>;
42
- getNodesWithoutSteps(runId: string, nodeIds: string[]): Promise<string[]>;
43
52
  getStepInstances(runId: string): Promise<Array<{
44
53
  stepName: string;
45
54
  status: StepStatus;
@@ -55,10 +64,5 @@ export declare class MongoDBWorkflowService extends PikkuWorkflowService {
55
64
  graph: any;
56
65
  source: string;
57
66
  } | null>;
58
- getAIGeneratedWorkflows(agentName?: string): Promise<Array<{
59
- workflowName: string;
60
- graphHash: string;
61
- graph: any;
62
- }>>;
63
67
  close(): Promise<void>;
64
68
  }
@@ -124,17 +124,29 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
124
124
  }
125
125
  async setStepRunningImpl(stepId) {
126
126
  await this.steps.updateOne({ _id: stepId }, { $set: { status: 'running', updatedAt: new Date() } });
127
- const latestHistory = await this.stepHistory
128
- .find({ workflowStepId: stepId })
129
- .sort({ createdAt: -1 })
130
- .limit(1)
131
- .toArray();
132
- if (latestHistory.length > 0) {
133
- await this.stepHistory.updateOne({ _id: latestHistory[0]._id }, { $set: { status: 'running', runningAt: new Date() } });
134
- }
127
+ await this.writeLatestHistory(stepId, 'running');
135
128
  }
136
129
  async setStepScheduledImpl(stepId) {
137
130
  await this.steps.updateOne({ _id: stepId }, { $set: { status: 'scheduled', updatedAt: new Date() } });
131
+ await this.writeLatestHistory(stepId, 'scheduled');
132
+ }
133
+ /**
134
+ * Move the current attempt of a step's history to a status.
135
+ *
136
+ * Finding the newest attempt and then updating it by `_id` was two round
137
+ * trips with a window in between; `findOneAndUpdate` applies the same sort
138
+ * server-side and writes the row it selected, so nothing can move underneath
139
+ * it. The attempt is still identified by `createdAt` because Mongo keeps no
140
+ * attempt number — see `getStepState`, which counts history documents.
141
+ */
142
+ async writeLatestHistory(stepId, status, values = {}) {
143
+ const now = new Date();
144
+ const update = { status, ...values };
145
+ const timestampField = this.getTimestampFieldForStatus(status);
146
+ if (timestampField !== 'createdAt') {
147
+ update[timestampField] = now;
148
+ }
149
+ await this.stepHistory.findOneAndUpdate({ workflowStepId: stepId }, { $set: update }, { sort: { createdAt: -1 } });
138
150
  }
139
151
  async insertHistoryRecord(stepId, status, result, error) {
140
152
  const now = new Date();
@@ -187,14 +199,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
187
199
  updatedAt: new Date(),
188
200
  },
189
201
  });
190
- const latestHistory = await this.stepHistory
191
- .find({ workflowStepId: stepId })
192
- .sort({ createdAt: -1 })
193
- .limit(1)
194
- .toArray();
195
- if (latestHistory.length > 0) {
196
- await this.stepHistory.updateOne({ _id: latestHistory[0]._id }, { $set: { status: 'succeeded', result, succeededAt: new Date() } });
197
- }
202
+ await this.writeLatestHistory(stepId, 'succeeded', { result });
198
203
  }
199
204
  async setStepErrorImpl(stepId, error) {
200
205
  const serializedError = {
@@ -210,14 +215,9 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
210
215
  updatedAt: new Date(),
211
216
  },
212
217
  });
213
- const latestHistory = await this.stepHistory
214
- .find({ workflowStepId: stepId })
215
- .sort({ createdAt: -1 })
216
- .limit(1)
217
- .toArray();
218
- if (latestHistory.length > 0) {
219
- await this.stepHistory.updateOne({ _id: latestHistory[0]._id }, { $set: { status: 'failed', error: serializedError, failedAt: new Date() } });
220
- }
218
+ await this.writeLatestHistory(stepId, 'failed', {
219
+ error: serializedError,
220
+ });
221
221
  }
222
222
  async createRetryAttemptImpl(stepId, status) {
223
223
  await this.steps.updateOne({ _id: stepId }, {
@@ -284,19 +284,6 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
284
284
  }
285
285
  return { completedNodeIds, failedNodeIds, branchKeys };
286
286
  }
287
- async getNodesWithoutSteps(runId, nodeIds) {
288
- if (nodeIds.length === 0)
289
- return [];
290
- const result = await this.steps
291
- .find({
292
- workflowRunId: runId,
293
- stepName: { $in: nodeIds },
294
- })
295
- .project({ stepName: 1 })
296
- .toArray();
297
- const existingStepNames = new Set(result.map((r) => r.stepName));
298
- return nodeIds.filter((id) => !existingStepNames.has(id));
299
- }
300
287
  async getStepInstances(runId) {
301
288
  const rows = await this.steps
302
289
  .find({ workflowRunId: runId })
@@ -362,18 +349,5 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
362
349
  async getWorkflowVersion(name, graphHash) {
363
350
  return this.runService.getWorkflowVersion(name, graphHash);
364
351
  }
365
- async getAIGeneratedWorkflows(agentName) {
366
- const filter = { source: 'ai-agent', status: 'active' };
367
- if (agentName) {
368
- const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
369
- filter.workflowName = { $regex: `^ai:${escaped}:` };
370
- }
371
- const docs = await this.versions.find(filter).toArray();
372
- return docs.map((doc) => ({
373
- workflowName: doc.workflowName,
374
- graphHash: doc.graphHash,
375
- graph: doc.graph,
376
- }));
377
- }
378
352
  async close() { }
379
353
  }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@pikku/mongodb",
3
- "version": "0.12.11",
3
+ "version": "0.12.13",
4
+ "description": "MongoDB services for Pikku — workflow state, agent runs, secrets and channel stores",
4
5
  "author": "yasser.fadl@gmail.com",
5
6
  "license": "MIT",
6
7
  "module": "dist/src/index.js",
@@ -17,7 +18,7 @@
17
18
  "prepublishOnly": "yarn build"
18
19
  },
19
20
  "peerDependencies": {
20
- "@pikku/core": "^0.12.69"
21
+ "@pikku/core": "^0.12.74"
21
22
  },
22
23
  "dependencies": {
23
24
  "mongodb": "^6.12.0"
@@ -1,6 +1,8 @@
1
1
  import type { SecretService } from '@pikku/core/services'
2
2
  import type { Db, Collection } from 'mongodb'
3
3
  import {
4
+ deriveKEK,
5
+ generateKEKSalt,
4
6
  envelopeEncrypt,
5
7
  envelopeDecrypt,
6
8
  envelopeRewrap,
@@ -23,6 +25,12 @@ interface SecretDoc {
23
25
  updatedAt: Date
24
26
  }
25
27
 
28
+ interface KekSaltDoc {
29
+ _id: number
30
+ salt: string
31
+ createdAt: Date
32
+ }
33
+
26
34
  interface SecretAuditDoc {
27
35
  _id: string
28
36
  secretKey: string
@@ -39,6 +47,9 @@ export class MongoDBSecretService implements SecretService {
39
47
  private auditReads: boolean
40
48
  private secrets!: Collection<SecretDoc>
41
49
  private secretsAudit!: Collection<SecretAuditDoc>
50
+ private kekSaltDocs!: Collection<KekSaltDoc>
51
+ private kekSalts = new Map<number, string>()
52
+ private keks = new Map<number, CryptoKey>()
42
53
 
43
54
  constructor(
44
55
  private db: Db,
@@ -56,6 +67,7 @@ export class MongoDBSecretService implements SecretService {
56
67
 
57
68
  this.secrets = this.db.collection<SecretDoc>('secrets')
58
69
  this.secretsAudit = this.db.collection<SecretAuditDoc>('secrets_audit')
70
+ this.kekSaltDocs = this.db.collection<KekSaltDoc>('secret_kek_salts')
59
71
 
60
72
  await this.secrets.createIndex({ _id: 1 })
61
73
 
@@ -81,17 +93,42 @@ export class MongoDBSecretService implements SecretService {
81
93
  })
82
94
  }
83
95
 
84
- private getKEK(version: number): string {
85
- if (version === this.keyVersion) return this.key
86
- if (this.previousKey) return this.previousKey
87
- throw new Error(`No KEK available for key_version ${version}`)
96
+ private async getKEKSalt(version: number): Promise<string> {
97
+ const cached = this.kekSalts.get(version)
98
+ if (cached) return cached
99
+
100
+ const doc = await this.kekSaltDocs.findOneAndUpdate(
101
+ { _id: version },
102
+ { $setOnInsert: { salt: generateKEKSalt(), createdAt: new Date() } },
103
+ { upsert: true, returnDocument: 'after' }
104
+ )
105
+ if (!doc?.salt) {
106
+ throw new Error(`Failed to persist a KEK salt for key_version ${version}`)
107
+ }
108
+
109
+ this.kekSalts.set(version, doc.salt)
110
+ return doc.salt
111
+ }
112
+
113
+ private async getKEK(version: number): Promise<CryptoKey> {
114
+ const cached = this.keks.get(version)
115
+ if (cached) return cached
116
+
117
+ const passphrase = version === this.keyVersion ? this.key : this.previousKey
118
+ if (!passphrase) {
119
+ throw new Error(`No KEK available for key_version ${version}`)
120
+ }
121
+
122
+ const kek = await deriveKEK(passphrase, await this.getKEKSalt(version))
123
+ this.keks.set(version, kek)
124
+ return kek
88
125
  }
89
126
 
90
127
  async getSecret<T = string>(key: string): Promise<T> {
91
128
  const row = await this.secrets.findOne({ _id: key })
92
129
  if (!row) throw new Error('Requested secret not found')
93
130
 
94
- const kek = this.getKEK(row.keyVersion)
131
+ const kek = await this.getKEK(row.keyVersion)
95
132
  const result = await envelopeDecrypt<T>(kek, row.ciphertext, row.wrappedDek)
96
133
  await this.logAudit(key, 'read')
97
134
  return result
@@ -103,7 +140,10 @@ export class MongoDBSecretService implements SecretService {
103
140
  }
104
141
 
105
142
  async setSecret(key: string, value: unknown): Promise<void> {
106
- const { ciphertext, wrappedDEK } = await envelopeEncrypt(this.key, value)
143
+ const { ciphertext, wrappedDEK } = await envelopeEncrypt(
144
+ await this.getKEK(this.keyVersion),
145
+ value
146
+ )
107
147
  const now = new Date()
108
148
 
109
149
  await this.secrets.updateOne(
@@ -131,23 +171,27 @@ export class MongoDBSecretService implements SecretService {
131
171
  await this.logAudit(key, 'delete')
132
172
  }
133
173
 
134
- async getSecrets<
135
- T extends Record<string, unknown> = Record<string, unknown>,
136
- >(keys: (keyof T & string)[]): Promise<T> {
174
+ async getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
175
+ keys: (keyof T & string)[]
176
+ ): Promise<T> {
137
177
  const rows = await this.secrets
138
178
  .find({ _id: { $in: keys } } as any)
139
179
  .toArray()
140
180
  const out: Record<string, unknown> = {}
141
181
  for (const row of rows) {
142
182
  try {
143
- const kek = this.getKEK(row.keyVersion)
183
+ const kek = await this.getKEK(row.keyVersion)
144
184
  out[row._id] = await envelopeDecrypt(
145
185
  kek,
146
186
  row.ciphertext,
147
187
  row.wrappedDek
148
188
  )
149
- } catch {
150
- // skip secrets that fail to decrypt
189
+ } catch (cause) {
190
+ throw new Error(
191
+ `Failed to decrypt secret "${row._id}" (key_version ${row.keyVersion}): ` +
192
+ `the configured KEK does not match the key it was wrapped under`,
193
+ { cause }
194
+ )
151
195
  }
152
196
  }
153
197
  return out as T
@@ -163,12 +207,11 @@ export class MongoDBSecretService implements SecretService {
163
207
  .project({ _id: 1, wrappedDek: 1 })
164
208
  .toArray()
165
209
 
210
+ const oldKEK = await this.getKEK(this.keyVersion - 1)
211
+ const newKEK = await this.getKEK(this.keyVersion)
212
+
166
213
  for (const row of rows) {
167
- const newWrappedDEK = await envelopeRewrap(
168
- this.previousKey,
169
- this.key,
170
- row.wrappedDek
171
- )
214
+ const newWrappedDEK = await envelopeRewrap(oldKEK, newKEK, row.wrappedDek)
172
215
  await this.secrets.updateOne(
173
216
  { _id: row._id },
174
217
  {
@@ -106,6 +106,84 @@ function registerTests(name: string, getDb: () => Db) {
106
106
  assert.equal(logs[0]!.action, 'write')
107
107
  })
108
108
  })
109
+
110
+ describe(`MongoDBSecretService decryption failures [${name}]`, () => {
111
+ const kek = 'test-key-encryption-key-32chars!'
112
+ const otherKek = 'a-totally-different-kek-32chars!'
113
+
114
+ test('getSecrets throws naming a secret it cannot decrypt', async () => {
115
+ const writer = new MongoDBSecretService(getDb(), { key: kek })
116
+ await writer.init()
117
+ await writer.setSecret('undecryptable', { token: 'abc' })
118
+
119
+ const reader = new MongoDBSecretService(getDb(), { key: otherKek })
120
+ await reader.init()
121
+
122
+ await assert.rejects(
123
+ () => reader.getSecrets(['undecryptable']),
124
+ (error: Error) => {
125
+ assert.equal(
126
+ error.message,
127
+ 'Failed to decrypt secret "undecryptable" (key_version 1): ' +
128
+ 'the configured KEK does not match the key it was wrapped under'
129
+ )
130
+ assert.ok(error.cause, 'the underlying crypto failure is preserved')
131
+ return true
132
+ }
133
+ )
134
+ })
135
+
136
+ test('getSecrets throws when no KEK is available for a stored key_version', async () => {
137
+ const writer = new MongoDBSecretService(getDb(), {
138
+ key: kek,
139
+ keyVersion: 7,
140
+ })
141
+ await writer.init()
142
+ await writer.setSecret('old-version', 'value')
143
+
144
+ const reader = new MongoDBSecretService(getDb(), {
145
+ key: kek,
146
+ keyVersion: 8,
147
+ })
148
+ await reader.init()
149
+
150
+ await assert.rejects(
151
+ () => reader.getSecrets(['old-version']),
152
+ (error: Error) => {
153
+ assert.equal(
154
+ error.message,
155
+ 'Failed to decrypt secret "old-version" (key_version 7): ' +
156
+ 'the configured KEK does not match the key it was wrapped under'
157
+ )
158
+ return true
159
+ }
160
+ )
161
+ })
162
+
163
+ test('getSecrets returns every secret when they all decrypt', async () => {
164
+ const service = new MongoDBSecretService(getDb(), { key: kek })
165
+ await service.init()
166
+ await service.setSecret('batch-a', { v: 1 })
167
+ await service.setSecret('batch-b', 'two')
168
+
169
+ const out = await service.getSecrets<{
170
+ 'batch-a': { v: number }
171
+ 'batch-b': string
172
+ }>(['batch-a', 'batch-b'])
173
+
174
+ assert.deepEqual(out, { 'batch-a': { v: 1 }, 'batch-b': 'two' })
175
+ })
176
+
177
+ test('getSecrets omits keys that were never stored', async () => {
178
+ const service = new MongoDBSecretService(getDb(), { key: kek })
179
+ await service.init()
180
+ await service.setSecret('present', 'here')
181
+
182
+ const out = await service.getSecrets(['present', 'never-stored'])
183
+
184
+ assert.deepEqual(out, { present: 'here' })
185
+ })
186
+ })
109
187
  }
110
188
 
111
189
  describe('MongoDB Services - In-Memory', () => {
@@ -202,22 +202,6 @@ export class MongoDBWorkflowRunService implements WorkflowRunService {
202
202
  }
203
203
  }
204
204
 
205
- async getAIGeneratedWorkflows(
206
- agentName?: string
207
- ): Promise<Array<{ workflowName: string; graphHash: string; graph: any }>> {
208
- const filter: Record<string, any> = { source: 'ai-agent', status: 'active' }
209
- if (agentName) {
210
- const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
211
- filter.workflowName = { $regex: `^ai:${escaped}:` }
212
- }
213
- const rows = await this.versions.find(filter).toArray()
214
- return rows.map((row) => ({
215
- workflowName: row.workflowName,
216
- graphHash: row.graphHash,
217
- graph: row.graph,
218
- }))
219
- }
220
-
221
205
  async deleteRun(id: string): Promise<boolean> {
222
206
  const steps = await this.steps.find({ workflowRunId: id }).toArray()
223
207
  const stepIds = steps.map((s) => s._id)
@@ -0,0 +1,121 @@
1
+ import { describe, test, before, after, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { MongoClient, type Db } from 'mongodb'
4
+ import { MongoMemoryServer } from 'mongodb-memory-server'
5
+
6
+ import { MongoDBWorkflowService } from './mongodb-workflow-service.js'
7
+
8
+ let mongod: MongoMemoryServer
9
+ let client: MongoClient
10
+ let db: Db
11
+ let ws: MongoDBWorkflowService
12
+ let dbCount = 0
13
+
14
+ before(async () => {
15
+ mongod = await MongoMemoryServer.create()
16
+ client = new MongoClient(mongod.getUri())
17
+ await client.connect()
18
+ })
19
+
20
+ after(async () => {
21
+ await client?.close()
22
+ await mongod?.stop()
23
+ })
24
+
25
+ beforeEach(async () => {
26
+ db = client.db(`wf-${++dbCount}`)
27
+ ws = new MongoDBWorkflowService(db)
28
+ await ws.init()
29
+ })
30
+
31
+ const seedStep = async () => {
32
+ const runId = await ws.createRun('flow', {}, false, 'hash', {
33
+ type: 'test',
34
+ } as any)
35
+ const step = await ws.insertStepState(runId, 'step-1', 'rpc.fn', { x: 1 })
36
+ return { runId, step }
37
+ }
38
+
39
+ /** The history rows of a run, oldest first. */
40
+ const historyOf = async (runId: string) => ws.getRunHistory(runId)
41
+
42
+ describe('a step transition reaches the history', () => {
43
+ test('running stamps the attempt it is running', async () => {
44
+ const { runId, step } = await seedStep()
45
+
46
+ await ws.setStepRunning(step.stepId)
47
+
48
+ const [attempt] = await historyOf(runId)
49
+ assert.equal(attempt!.status, 'running')
50
+ assert.ok(attempt!.runningAt, 'no runningAt, so the step has no duration')
51
+ })
52
+
53
+ test('scheduled stamps the attempt it is queueing', async () => {
54
+ const { runId, step } = await seedStep()
55
+
56
+ await ws.setStepScheduled(step.stepId)
57
+
58
+ const [attempt] = await historyOf(runId)
59
+ assert.equal(
60
+ attempt!.status,
61
+ 'scheduled',
62
+ 'the history still says pending, so a queued step reads as never dispatched'
63
+ )
64
+ assert.ok(attempt!.scheduledAt, 'no scheduledAt on the queued attempt')
65
+ })
66
+
67
+ test('succeeded carries the result', async () => {
68
+ const { runId, step } = await seedStep()
69
+
70
+ await ws.setStepRunning(step.stepId)
71
+ await ws.setStepResult(step.stepId, { ok: true })
72
+
73
+ const [attempt] = await historyOf(runId)
74
+ assert.equal(attempt!.status, 'succeeded')
75
+ assert.deepEqual(attempt!.result, { ok: true })
76
+ assert.ok(attempt!.succeededAt)
77
+ })
78
+
79
+ test('failed carries the error', async () => {
80
+ const { runId, step } = await seedStep()
81
+
82
+ await ws.setStepRunning(step.stepId)
83
+ await ws.setStepError(step.stepId, new Error('exploded'))
84
+
85
+ const [attempt] = await historyOf(runId)
86
+ assert.equal(attempt!.status, 'failed')
87
+ assert.equal(attempt!.error?.message, 'exploded')
88
+ assert.ok(attempt!.failedAt)
89
+ })
90
+
91
+ test('a retry writes to the new attempt, leaving the failed one intact', async () => {
92
+ const { runId, step } = await seedStep()
93
+
94
+ await ws.setStepRunning(step.stepId)
95
+ await ws.setStepError(step.stepId, new Error('first go'))
96
+ await ws.createRetryAttempt(step.stepId, 'pending')
97
+ await ws.setStepRunning(step.stepId)
98
+ await ws.setStepResult(step.stepId, 'second go')
99
+
100
+ const history = await historyOf(runId)
101
+ assert.equal(history.length, 2)
102
+ assert.equal(history[0]!.status, 'failed')
103
+ assert.equal(history[0]!.error?.message, 'first go')
104
+ assert.equal(history[1]!.status, 'succeeded')
105
+ assert.equal(history[1]!.result, 'second go')
106
+ })
107
+
108
+ test('the step row keeps only its latest outcome', async () => {
109
+ const { runId, step } = await seedStep()
110
+
111
+ await ws.setStepError(step.stepId, new Error('first go'))
112
+ await ws.createRetryAttempt(step.stepId, 'pending')
113
+ await ws.setStepResult(step.stepId, 'second go')
114
+
115
+ const state = await ws.getStepState(runId, 'step-1')
116
+ assert.equal(state.status, 'succeeded')
117
+ assert.equal(state.result, 'second go')
118
+ assert.equal(state.error, undefined)
119
+ assert.equal(state.attemptCount, 2)
120
+ })
121
+ })
@@ -255,19 +255,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
255
255
  { _id: stepId },
256
256
  { $set: { status: 'running', updatedAt: new Date() } }
257
257
  )
258
-
259
- const latestHistory = await this.stepHistory
260
- .find({ workflowStepId: stepId })
261
- .sort({ createdAt: -1 })
262
- .limit(1)
263
- .toArray()
264
-
265
- if (latestHistory.length > 0) {
266
- await this.stepHistory.updateOne(
267
- { _id: latestHistory[0]!._id },
268
- { $set: { status: 'running', runningAt: new Date() } }
269
- )
270
- }
258
+ await this.writeLatestHistory(stepId, 'running')
271
259
  }
272
260
 
273
261
  protected async setStepScheduledImpl(stepId: string): Promise<void> {
@@ -275,6 +263,35 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
275
263
  { _id: stepId },
276
264
  { $set: { status: 'scheduled', updatedAt: new Date() } }
277
265
  )
266
+ await this.writeLatestHistory(stepId, 'scheduled')
267
+ }
268
+
269
+ /**
270
+ * Move the current attempt of a step's history to a status.
271
+ *
272
+ * Finding the newest attempt and then updating it by `_id` was two round
273
+ * trips with a window in between; `findOneAndUpdate` applies the same sort
274
+ * server-side and writes the row it selected, so nothing can move underneath
275
+ * it. The attempt is still identified by `createdAt` because Mongo keeps no
276
+ * attempt number — see `getStepState`, which counts history documents.
277
+ */
278
+ private async writeLatestHistory(
279
+ stepId: string,
280
+ status: string,
281
+ values: Record<string, unknown> = {}
282
+ ): Promise<void> {
283
+ const now = new Date()
284
+ const update: Record<string, unknown> = { status, ...values }
285
+ const timestampField = this.getTimestampFieldForStatus(status)
286
+ if (timestampField !== 'createdAt') {
287
+ update[timestampField] = now
288
+ }
289
+
290
+ await this.stepHistory.findOneAndUpdate(
291
+ { workflowStepId: stepId },
292
+ { $set: update as any },
293
+ { sort: { createdAt: -1 } }
294
+ )
278
295
  }
279
296
 
280
297
  private async insertHistoryRecord(
@@ -351,18 +368,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
351
368
  }
352
369
  )
353
370
 
354
- const latestHistory = await this.stepHistory
355
- .find({ workflowStepId: stepId })
356
- .sort({ createdAt: -1 })
357
- .limit(1)
358
- .toArray()
359
-
360
- if (latestHistory.length > 0) {
361
- await this.stepHistory.updateOne(
362
- { _id: latestHistory[0]!._id },
363
- { $set: { status: 'succeeded', result, succeededAt: new Date() } }
364
- )
365
- }
371
+ await this.writeLatestHistory(stepId, 'succeeded', { result })
366
372
  }
367
373
 
368
374
  protected async setStepErrorImpl(
@@ -387,18 +393,9 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
387
393
  }
388
394
  )
389
395
 
390
- const latestHistory = await this.stepHistory
391
- .find({ workflowStepId: stepId })
392
- .sort({ createdAt: -1 })
393
- .limit(1)
394
- .toArray()
395
-
396
- if (latestHistory.length > 0) {
397
- await this.stepHistory.updateOne(
398
- { _id: latestHistory[0]!._id },
399
- { $set: { status: 'failed', error: serializedError, failedAt: new Date() } }
400
- )
401
- }
396
+ await this.writeLatestHistory(stepId, 'failed', {
397
+ error: serializedError,
398
+ })
402
399
  }
403
400
 
404
401
  protected async createRetryAttemptImpl(
@@ -490,25 +487,9 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
490
487
  return { completedNodeIds, failedNodeIds, branchKeys }
491
488
  }
492
489
 
493
- async getNodesWithoutSteps(
494
- runId: string,
495
- nodeIds: string[]
496
- ): Promise<string[]> {
497
- if (nodeIds.length === 0) return []
498
-
499
- const result = await this.steps
500
- .find({
501
- workflowRunId: runId,
502
- stepName: { $in: nodeIds },
503
- })
504
- .project({ stepName: 1 })
505
- .toArray()
506
-
507
- const existingStepNames = new Set(result.map((r) => r.stepName))
508
- return nodeIds.filter((id) => !existingStepNames.has(id))
509
- }
510
-
511
- async getStepInstances(runId: string): Promise<
490
+ async getStepInstances(
491
+ runId: string
492
+ ): Promise<
512
493
  Array<{ stepName: string; status: StepStatus; fromStepName?: string }>
513
494
  > {
514
495
  const rows = await this.steps
@@ -622,21 +603,5 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
622
603
  return this.runService.getWorkflowVersion(name, graphHash)
623
604
  }
624
605
 
625
- async getAIGeneratedWorkflows(
626
- agentName?: string
627
- ): Promise<Array<{ workflowName: string; graphHash: string; graph: any }>> {
628
- const filter: Record<string, any> = { source: 'ai-agent', status: 'active' }
629
- if (agentName) {
630
- const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
631
- filter.workflowName = { $regex: `^ai:${escaped}:` }
632
- }
633
- const docs = await this.versions.find(filter).toArray()
634
- return docs.map((doc) => ({
635
- workflowName: doc.workflowName,
636
- graphHash: doc.graphHash,
637
- graph: doc.graph,
638
- }))
639
- }
640
-
641
606
  async close(): Promise<void> {}
642
607
  }