@pikku/kysely 0.13.6 → 0.13.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +505 -0
  2. package/dist/src/index.d.ts +4 -1
  3. package/dist/src/index.js +3 -1
  4. package/dist/src/kysely-audit-service.d.ts +34 -2
  5. package/dist/src/kysely-audit-service.js +163 -3
  6. package/dist/src/kysely-credential-service.d.ts +3 -0
  7. package/dist/src/kysely-credential-service.js +50 -11
  8. package/dist/src/kysely-scope-service.d.ts +38 -0
  9. package/dist/src/kysely-scope-service.js +125 -2
  10. package/dist/src/kysely-secret-service.d.ts +7 -3
  11. package/dist/src/kysely-secret-service.js +55 -13
  12. package/dist/src/kysely-tables.d.ts +49 -2
  13. package/dist/src/kysely-virtual-user-run-store.d.ts +43 -0
  14. package/dist/src/kysely-virtual-user-run-store.js +131 -0
  15. package/dist/src/kysely-workflow-service.d.ts +0 -1
  16. package/dist/src/kysely-workflow-service.js +0 -12
  17. package/dist/src/schema/audit.schema.d.ts +16 -0
  18. package/dist/src/schema/audit.schema.js +47 -0
  19. package/dist/src/schema/credential.schema.d.ts +3 -2
  20. package/dist/src/schema/credential.schema.js +9 -3
  21. package/dist/src/schema/index.d.ts +17 -4
  22. package/dist/src/schema/index.js +40 -6
  23. package/dist/src/schema/pikku-schema.types.d.ts +41 -5
  24. package/dist/src/schema/pikku-schema.types.js +27 -12
  25. package/dist/src/schema/scope.schema.js +12 -0
  26. package/dist/src/schema/secret.schema.js +5 -0
  27. package/dist/src/schema/virtual-user.schema.d.ts +19 -0
  28. package/dist/src/schema/virtual-user.schema.js +54 -0
  29. package/dist/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +2 -2
  31. package/src/create-audited-kysely.test.ts +10 -10
  32. package/src/index.ts +7 -0
  33. package/src/kysely-audit-service.test.ts +360 -0
  34. package/src/kysely-audit-service.ts +181 -4
  35. package/src/kysely-credential-service.ts +57 -12
  36. package/src/kysely-scope-service.ts +151 -2
  37. package/src/kysely-secret-service.ts +70 -21
  38. package/src/kysely-services.test.ts +123 -1
  39. package/src/kysely-tables.test.ts +12 -1
  40. package/src/kysely-tables.ts +53 -2
  41. package/src/kysely-virtual-user-run-store.test.ts +212 -0
  42. package/src/kysely-virtual-user-run-store.ts +149 -0
  43. package/src/kysely-workflow-service.ts +0 -17
  44. package/src/schema/audit.schema.ts +55 -0
  45. package/src/schema/credential.schema.ts +14 -3
  46. package/src/schema/index.ts +59 -10
  47. package/src/schema/pikku-schema.types.ts +65 -10
  48. package/src/schema/schema.test.ts +82 -4
  49. package/src/schema/scope.schema.ts +14 -0
  50. package/src/schema/secret.schema.ts +9 -0
  51. package/src/schema/virtual-user.schema.ts +65 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,508 @@
1
+ ## 0.13.10
2
+
3
+ ### Patch Changes
4
+
5
+ - 3df4f95: Scaffold virtual user runs as RPCs, backed by a run store.
6
+
7
+ `pikku persona run` could already turn a declared persona loose on a running
8
+ stage, but only from a terminal, and the result only existed in that terminal's
9
+ output. There was no way for CI, a console, or a scheduled job to start a run —
10
+ and nothing kept what a run found, so this week's findings could not be compared
11
+ against last week's.
12
+
13
+ `scaffold.virtualUser` now generates two RPCs and the function behind them:
14
+ - `runVirtualUser({ persona, goals?, memory?, disposition?, budget?, seed? })
15
+ -> { runId }`
16
+ - `getVirtualUserRun({ runId }) -> { status, findings, tally, memory, … }`
17
+
18
+ They are gated on separate scopes — `virtualUser:run` and `virtualUser:read` —
19
+ because an adversarial run's findings are working exploits carrying live ids,
20
+ which makes reading them the more sensitive of the two. Production refuses every
21
+ disposition but `accountable`, checked against the effective one so the
22
+ per-run override cannot smuggle another in.
23
+
24
+ **A run is not a workflow and not a queued job.** It explores, so no two
25
+ attempts take the same steps and there is nothing to replay; and the record
26
+ already carries the progress a queue would only be holding on the way to the
27
+ same place. `runVirtualUser` writes the record, dispatches without awaiting, and
28
+ returns the id. The cost is stated on the type: a restart mid-run strands a
29
+ record at `running`, so a run older than its budget window and still `running`
30
+ is dead rather than working.
31
+
32
+ `@pikku/core` gains `VirtualUserRunStore` (with `virtualUserRunStore` on
33
+ `CoreSingletonServices`), and `@pikku/kysely` ships
34
+ `KyselyVirtualUserRunStore`, which creates its own table on first use like the
35
+ audit sink — the runtime never needs it, so it arrives with the feature that
36
+ fills it rather than in every database.
37
+
38
+ Also in core: `prepareVirtualUserRun`, which derives the catalogue, intents,
39
+ scopes and reachable agents in one place. `pikku persona run` reads the
40
+ inspector state and the generated RPC reads `metaService`, and the two have to
41
+ agree — otherwise the same persona and seed explore a different API depending on
42
+ how the run was started. `personaScopes` moved here from the CLI for the same
43
+ reason and is still re-exported from its old home. `PRODUCTION_DISPOSITION` is
44
+ now exported from `@pikku/core/virtual-user`, which it should always have been.
45
+
46
+ - Updated dependencies [3df4f95]
47
+ - @pikku/core@0.12.77
48
+
49
+ ## 0.13.9
50
+
51
+ ### Patch Changes
52
+
53
+ - 62ea4cc: The audit trail is now readable — in the generated meta, through an RPC, and as
54
+ a page in the console.
55
+
56
+ `audit: true` reaches `FunctionRuntimeMeta.audit` as its resolved form
57
+ (`{ durability }`), so which functions record anything is answerable without
58
+ running them. It is informational: the runner still resolves audit from the live
59
+ function config, so meta and runtime cannot disagree.
60
+
61
+ `AuditService` grows an optional read side — `query(AuditQuery)` and `facets()`.
62
+ Optional because a sink can legitimately be write-only: a queue producer that
63
+ hands events to another system has nothing to read back, and a reader that finds
64
+ these absent should say the trail is not readable here rather than that it is
65
+ empty. The two are very different answers to give someone auditing a system.
66
+
67
+ `KyselyAuditService` implements both, newest first with offset paging, filtered
68
+ by user, action and time window. Two things it now gets right that are easy to
69
+ get wrong: an empty filter array means "match nothing" rather than "no filter",
70
+ and results are read by physical _and_ camelCase key, because `CamelCasePlugin`
71
+ is on most pikku Kysely instances and renames result keys on the way out — the
72
+ mismatch does not throw, it returns a page of `undefined`. `init()` creates the
73
+ `audit` table for projects that do not migrate it themselves, from a new
74
+ exported `auditSchema` that stays out of `pikkuSchemas` because the runtime does
75
+ not need it.
76
+
77
+ The console addon exposes `console:getAudits` and `console:getAuditFilters`
78
+ behind a new `pikku:audit:read` scope, and forwards the application's `audit`
79
+ service into the addon's own services — without that last part every install
80
+ reported the trail as unreadable, whatever sink it had configured.
81
+
82
+ The console gets an Audit trail page: an infinite list filtered server-side by
83
+ user and action, and a row that opens the whole event, metadata rendered as a
84
+ JSON tree. Refused, unreadable and empty are three different screens, because
85
+ "you may not read this", "nobody can read this" and "nothing happened" are three
86
+ different facts.
87
+
88
+ Events name the person who caused them. The trail records a user id — the only
89
+ thing stable enough to record, since a name can change after the event — so
90
+ `getAudits` resolves those ids against better-auth's user directory at read
91
+ time, and the page shows the name while keeping the recorded id on the event.
92
+ The filter follows: pick a colleague by name, filter by the id. A scenario
93
+ actor is labelled as one, so synthetic traffic is not mistaken for real, and a
94
+ caller who was signed out shows the wire identity pikku resolved for them
95
+ rather than being credited to the system.
96
+
97
+ **Breaking, for anyone already reading `AuditEvent`:** `actor` is now
98
+ `userIdentity`, and its type `AuditActor` is `AuditUserIdentity`; `AuditQuery`
99
+ takes `userIds`/`orgId` in place of `actorUserIds`/`actorOrgId`, and
100
+ `AuditFacets` returns `userIds`. In pikku an _actor_ is a synthetic person a
101
+ scenario drives, flagged on the user row — so naming the causer of an event
102
+ `actor` made the synthetic case unsayable (`actor.actor === true`) and implied
103
+ every recorded action was a test. The overwhelming majority are ordinary
104
+ customers. The `audit` table follows: `actor_user_id` / `actor_org_id`
105
+ are now `user_id` / `org_id`, and a `pikku_user_id` column joins them so the
106
+ wire identity of a caller who never signed in survives the round trip — the
107
+ sink was dropping it, which left the console's Session field permanently
108
+ blank. A project that already migrated the table needs to rename the two
109
+ columns and add the third; `KyselyAuditService.init()` creates the new shape
110
+ for anyone who did not.
111
+
112
+ - 78b29f0: `SecretService` now returns a `SecretValue<T>` rather than the bare value, so a
113
+ vault secret cannot reach a sink by accident.
114
+
115
+ `SecretValue` is nominally typed, which means it is not assignable to `string`
116
+ (or to any other concretely-typed field). Every sink with a real type — a
117
+ database column, an email body, a session payload — rejects it with no lint
118
+ rule involved. The sinks typed `any`, `unknown`, or a free generic — the logger,
119
+ queue payloads, webhook and email inputs, and a function's own output — are
120
+ guarded with `Safe<T>`, which collapses a `SecretValue` found anywhere inside
121
+ `T`, however deeply nested, to `never`.
122
+
123
+ Unwrap deliberately at the point the secret reaches the wire:
124
+
125
+ ```ts
126
+ const secret = await secrets.getSecret('BETTER_AUTH_SECRET')
127
+ betterAuth({ secret: secret.reveal() })
128
+ ```
129
+
130
+ Two behaviours cover what types cannot see. Structured serialization redacts —
131
+ `JSON.stringify` and node's inspect both yield `[secret]`, so an audit or log
132
+ write stays honest without crashing the request. String coercion throws
133
+ `SecretCoercionError`, because a template literal is always a leak.
134
+
135
+ `AuditLog.write` is guarded the same way as the logger, since an audit event
136
+ carries `input` and `metadata` as `unknown` and nominality alone cannot stop a
137
+ secret landing in one.
138
+
139
+ `.reveal()` is the deliberate escape hatch, and what it hands back is an
140
+ ordinary string as far as every sink signature is concerned. **PKU953** closes
141
+ that gap: under `pikku all --security` the inspector reports a revealed secret
142
+ that flows into a logger, an audit, a queue, an email or a webhook — `console` included.
143
+
144
+ This also fixed a real one: `remote-addon-auth.ts` called `String(token)` on an
145
+ `unknown` and wrote the result straight into an `Authorization` header.
146
+
147
+ - Updated dependencies [62ea4cc]
148
+ - Updated dependencies [9dddff8]
149
+ - Updated dependencies [78b29f0]
150
+ - @pikku/core@0.12.76
151
+
152
+ ## 0.13.8
153
+
154
+ ### Patch Changes
155
+
156
+ - cabd9dc: Add a `db.schema` CLI config option, so `pikku db generate` can write the runtime tables into a named postgres schema.
157
+
158
+ Without it the generator emits unqualified `create table` against the default `search_path` of `"$user", public`. A project that keeps everything in one namespace — `app`, say — gets a second copy of every runtime table in `public` alongside the ones it already has, which is how stray `public.ai_*` tables appear next to the real `app.ai_*` ones.
159
+
160
+ `compilePikkuSchemas` takes the schema and binds only the rendered SQL, never the caller's connection: that connection is the throwaway database the declaration was just applied to, and qualifying it would create tables in a schema the scratch database has never heard of.
161
+
162
+ Raw SQL is not rewritten by `withSchema`, so `rawStatement` now also accepts a builder taking a `SchemaContext` — the expression index on `credentials` uses it to qualify its own table. Statements otherwise pick the context up from whatever connection they are handed, so a schema-bound connection needs nothing said twice.
163
+
164
+ Two fixes fall out of it:
165
+ - The `ALTER TABLE` delta for a partially covered source is written from bare introspected names, so it is qualified explicitly. Unqualified it altered a table in whichever schema `search_path` found.
166
+ - A source was only counted as partially covered on an exact name match, so a project whose migrations already create `app.workflow_step` read as "nothing covered" and had its whole schema re-emitted over tables that were already there. It now matches the schema qualifier the same way the drift diff does.
167
+
168
+ `db.schema` is postgres only, and is rejected with an explanation on sqlite, whose `REFERENCES` clause takes a bare table name.
169
+
170
+ - Updated dependencies [32277d5]
171
+ - Updated dependencies [ea8aabf]
172
+ - Updated dependencies [33e96ab]
173
+ - Updated dependencies [fd72e58]
174
+ - Updated dependencies [fd72e58]
175
+ - Updated dependencies [fd72e58]
176
+ - Updated dependencies [894b2f8]
177
+ - Updated dependencies [dd19aa7]
178
+ - Updated dependencies [50ec500]
179
+ - @pikku/core@0.12.75
180
+
181
+ ## 0.13.7
182
+
183
+ ### Patch Changes
184
+
185
+ - 6a307f0: Fix four latent correctness bugs in the function, RPC and error runtimes, and
186
+ remove dead code from the workflow service surface.
187
+
188
+ `WorkflowService.getNodesWithoutSteps` is gone. It was declared on the abstract
189
+ service and implemented by all five storage backends, and nothing ever called
190
+ it — hence the non-core packages in this changeset, which only lose that method.
191
+
192
+ **An RPC could execute twice.** `RPCService.invoke`, its addon path, and
193
+ `rpcWithWire` each wrapped the _execution_ of a resolved function in a `try`
194
+ whose `catch` treated `RPCNotFoundError` as "not found locally" and re-dispatched
195
+ the call through `deploymentService`. A nested `rpc.invoke` to a missing name,
196
+ raised from inside an already-running function, therefore re-ran that function on
197
+ a remote instance after its local side effects had already committed. Resolution
198
+ is now separated from execution on all three paths, so only a genuinely
199
+ unresolvable name reaches the fallback. What escapes to callers is unchanged.
200
+
201
+ **`addonNamespace` leaked between sibling calls.** The function runner's
202
+ middleware path restored `rpc`, `functionId`, `audit` and `addonNamespace` after
203
+ an invocation; the non-middleware path restored the first three but not the
204
+ fourth. A call into an addon function with no middleware left the addon's
205
+ namespace on the wire, so subsequent sibling calls resolved the wrong
206
+ per-instance singletons and `credentialOverrides`.
207
+
208
+ **Errors registered on a subclass never resolved.** `misc.errors` was typed
209
+ `Map<PikkuError, ErrorDetails>` — instances — while `addError` stores
210
+ constructors, a mismatch hidden by its `error: any` parameter. The instance
211
+ lookup in `getErrorResponse` was consequently dead, and lookup fell through to a
212
+ scan comparing `constructor.name`, so a subclass of a registered error got no
213
+ status mapping at all. The map is now typed `Map<PikkuErrorConstructor, …>` and
214
+ lookup walks the prototype chain first. Name matching is retained, deliberately,
215
+ as the fallback that keeps error mapping working when two copies of
216
+ `@pikku/core` are installed.
217
+
218
+ **`createWeakUID` collided across instances.** The prefix was
219
+ `Date.now().toString(36)` evaluated at module load, so any two instances loading
220
+ the module in the same millisecond emitted identical `channelId` and `requestId`
221
+ sequences — reproducibly, not just in principle. It is now seeded lazily from
222
+ `crypto.randomUUID()`.
223
+
224
+ Also: `pikkuState` keys its global map with `Symbol.for` rather than `Symbol`, so
225
+ two copies of the package share registrations instead of silently getting
226
+ disjoint state; and the local channel upgrade path no longer keys its middleware
227
+ cache on the raw request path, which grew the cache without bound while the
228
+ cached value never varied by path.
229
+
230
+ - afef587: Close eleven security weaknesses found in a review of `@pikku/core`. Most are
231
+ breaking, and two invalidate data or credentials already in the wild — read the
232
+ migration notes before upgrading.
233
+
234
+ **Breaking: AI agent thread ownership now fails closed.** Reading, listing,
235
+ resuming or approving an existing thread or run requires a resolved session
236
+ principal (`userId`, or `orgId` for `sessionScope: 'org'`), regardless of the
237
+ agent's `auth` setting. Previously a request without a session had no ownership
238
+ model at all: the caller-supplied `resourceId` was accepted as the ownership
239
+ key, so any caller could read or resume another party's thread by naming its
240
+ `resourceId`. Worse, `threadOwnerConstraint` returned `undefined` for a
241
+ sessionless caller, and `undefined` means _no filter_ rather than _no rows_ —
242
+ so `getAgentThreads` returned every thread in the deployment. It now returns
243
+ `string[]`, empty for a sessionless caller, which every storage backend already
244
+ treats as matching nothing. Sessionless agents still run one-shot conversations,
245
+ each with a fresh unguessable owner; what they lose is cross-request continuity.
246
+ Wire a session to restore it.
247
+
248
+ **Breaking: stored secrets and credentials must be re-entered.** `deriveKey` ran
249
+ a single unsalted round of SHA-256 over the passphrase and used the digest
250
+ directly as the AES-GCM key — roughly one hash per brute-force guess, with one
251
+ rainbow table working against every deployment. It is now PBKDF2-HMAC-SHA256 at
252
+ 600,000 iterations over a random salt. There is no compatibility path: every
253
+ value held by the kysely, mongodb and redis secret services and the kysely
254
+ credential service becomes undecryptable. They fail loud, naming the key and
255
+ `key_version`, so the app hard-fails on first secret read until each is re-set.
256
+
257
+ The KEK salt is scoped to the key version and stored alongside it, rather than
258
+ per secret, so a bulk read costs one derivation instead of N — `getSecrets` over
259
+ 50 secrets went from ~2.3s to ~48ms, and rotation from ~4.6s to ~94ms. This adds
260
+ a salt table (kysely), hash field (redis) or collection (mongodb), created
261
+ automatically on first use.
262
+
263
+ **Breaking: `PIKKU_REMOTE_SECRET` must be at least 32 characters.** The
264
+ remote-RPC session envelope moved from PBKDF2 to HKDF, which expands
265
+ high-entropy key material rather than stretching a low-entropy passphrase. That
266
+ took a remote hop from ~269ms to ~0.4ms — PBKDF2 was running twice per request —
267
+ but HKDF supplies no brute-force resistance, so the secret must carry the
268
+ entropy itself. A shorter secret now throws `WeakKeyMaterialError` at both ends.
269
+ Generate one with `openssl rand -base64 32` and roll it out to every service in
270
+ the mesh together: existing bearer tokens are format-incompatible, so a partial
271
+ rollout produces 401s until every instance is updated. The Cloudflare, Lambda
272
+ and Azure deployment services each hand-rolled a copy of `buildRemoteHeaders`
273
+ and now call the shared one, which is what keeps the two sides in step.
274
+
275
+ **Breaking: previously signed content URLs stop verifying.** `LocalContent`
276
+ signed only `{signedAt, expiresAt, notBefore}`, so a signature proved when a URL
277
+ was issued but never what it was issued for — any valid token was a skeleton
278
+ key, and swapping the pathname from a public thumbnail to a private document
279
+ still verified. The signature now binds the request path. Separately, the
280
+ verifier returned "valid" when no JWT service was wired, which is how
281
+ `pikku serve` ran: a forged `?signedAt=0&expiresAt=99999999999999` was accepted.
282
+ It now rejects with 403, `LocalContent` requires a `JWTService`, and
283
+ `pikku serve`/`pikku dev` mint an ephemeral per-process signing key so local
284
+ development works without shipping a fail-open path. In-flight signed URLs must
285
+ be re-issued.
286
+
287
+ **Request body size limits now apply to every adapter.** The `maxBodySize` cap
288
+ existed only in `PikkuFetchHTTPRequest`. The real hole was uWebSockets, which
289
+ drove `res.onData` itself and concatenated every chunk with no bound and nothing
290
+ downstream able to intervene; it now drops chunks past the limit and replies 413
291
+ before routing. Fastify delegates to its native `bodyLimit` (set only when
292
+ `maxBodySize` is configured, so fastify's stricter 1 MB default is never
293
+ loosened), and `PikkuExpressServer` feeds the limit into its body parsers. Two
294
+ paths can only reject rather than prevent, and are documented as such:
295
+ `express-middleware` mounted on your own app receives an already-parsed body, so
296
+ that deployment must bound its own parser; Next server actions bottom out at
297
+ `experimental.serverActions.bodySizeLimit`.
298
+
299
+ **Breaking: the console addon's privileged functions are gated by default.**
300
+ `wireAddon` gains a `scopes` option that applies to every function in the
301
+ addon's namespace, and the console scaffold now generates
302
+ `wireAddon({ name: 'console', package: '@pikku/addon-console', scopes: ['admin'] })`.
303
+ Previously the console's entire privileged surface — around 54 functions
304
+ including `credentialGet`, which returns a resolved OAuth token for an arbitrary
305
+ `userId`, `updateFunctionBody`, and `installAddon`, which shells out to a
306
+ package install — was protected only by an optional host-registered
307
+ `addGlobalPermission`. `resolveGlobalPermissions` returns `[]` when none is
308
+ registered and permission checking then no-ops, so an app that never registered
309
+ one served those functions to anyone, and with the template's default
310
+ `scaffold.rpc: "no-auth"` that meant unauthenticated. All of them now return 403
311
+ `MissingScopeError` without an `admin`, `admin:*` or `*` scope. **Regenerating
312
+ is required** — an app holding an old `console.gen.ts` stays open.
313
+ `installAddon` and `installOpenapiAddon` additionally declare their own
314
+ `auth: true, scopes: ['admin']`, and `getAgentThreads` now scopes its listing to
315
+ the session's own threads unless the caller holds admin.
316
+
317
+ Addon scopes are enforced in `runPikkuFunc` rather than at the RPC boundary,
318
+ because a wiring can reference an addon function directly — the inspector
319
+ records the addon's `packageName` on HTTP, channel, schedule, queue, CLI,
320
+ trigger, gateway and MCP wirings — and those paths never call `resolveNamespace`.
321
+ Enforcing at the RPC seam would have covered only the `namespace:function` form
322
+ while reading as complete.
323
+
324
+ **Breaking: `wireAddon`'s `auth` and `tags` now apply on direct wirings too.**
325
+ Both were read only by `resolveAddonFunction`, so they had exactly the hole
326
+ scopes had: `wireAddon({ name: 'console', package: '@pikku/addon-console', auth:
327
+ true, tags: ['admin'] })` gated `rpc('console:credentialGet')` and gated nothing
328
+ at all on an HTTP route wired straight to `credentialGet`. A consumer who
329
+ reached for the documented way to lock an addon down got a control that was
330
+ silently inert on every wiring except one. Both now resolve in `runPikkuFunc`.
331
+ `auth` merges as an OR — `auth: false` from an addon is ignored on a direct
332
+ wiring, because an addon may require a session the wiring did not but must never
333
+ waive one it did. Addon tags resolve to concrete middleware against the **root**
334
+ tag groups before the call rather than being folded into the function's
335
+ inherited middleware: `addTagMiddleware('admin', …)` is written by the consuming
336
+ app and registers under the root package, whereas `combineMiddleware` would look
337
+ the tag up under the addon's own `packageName`, where it does not exist.
338
+
339
+ One consequence worth naming: an addon that wires `auth: true` and also runs its
340
+ own sessionless internal work — a scheduled task or queue worker inside the
341
+ addon calling a sibling function — is now gated where it previously was not,
342
+ because a bare `rpc('fn')` from inside the addon reaches `runPikkuFunc` with the
343
+ addon's `packageName` like any other call. This reverses a decision that
344
+ deliberately scoped the gate to the namespaced boundary; that reasoning held
345
+ only while the boundary was real, and a direct wiring can enter an addon without
346
+ crossing it, so "already inside" is not something the runtime can infer. Such an
347
+ addon should carry authorization on the function via
348
+ `pikkuFunc({ permissions })`, which has always been enforced on every path. A
349
+ follow-up will add execution provenance so an intra-addon call can be
350
+ distinguished from an external one and skip the addon-level check; that needs a
351
+ marker no caller outside the process can set, which is its own design problem
352
+ and does not belong in a security fix.
353
+
354
+ **Codegen now warns when an exposed function has no gate (PKU574).** The
355
+ generated `POST /rpc/:rpcName` dispatcher forwards to `rpc.exposed`, which
356
+ refuses anything without `expose: true` — but nothing checked whether the
357
+ target was gated, because a dispatcher cannot know what it dispatches to. The
358
+ console shipped ~54 privileged functions through that gap and the toolchain was
359
+ silent. The inspector now reports every function that is exposed, sessionless,
360
+ and carries no `auth`, `scopes` or `permissions` of its own and none from a
361
+ governing `wireAddon`. It is a `warn`, not a critical: `expose: true` on an
362
+ ungated sessionless function is correct for a genuinely public endpoint, so it
363
+ blocks a build only under `--fail-on-warn`.
364
+
365
+ Two pieces of metadata were missing for this to be answerable statically, and
366
+ both are now recorded. A `pikkuSessionlessFunc`'s own `auth: true` was read at
367
+ runtime but never written to function meta, so a self-gated function was
368
+ indistinguishable from an ungated one — `sessionless` carries the baseline, and
369
+ `auth` now carries the tightening. And `wireAddon`'s `scopes`, `auth` and `tags`
370
+ were not parsed at all: the inspector recorded the addon's `rpcEndpoint` and its
371
+ secret, variable and credential overrides, and dropped every one of its gates.
372
+ An addon whose gates are not statically knowable is treated as gated, because a
373
+ false positive on a correctly-secured addon costs more than the one case it
374
+ would catch.
375
+
376
+ **Breaking: an application's global permissions now apply inside addons.**
377
+ `resolveGlobalPermissions` read only the bucket matching the function's own
378
+ package, but the generated `addGlobalPermission` wrapper takes no package
379
+ argument and always registers under the root. An app-wide rule like "every
380
+ request needs a signed-in user" therefore stopped at the addon boundary, and the
381
+ bucket an addon's functions did read was one no host could write to — which is
382
+ why the console addon's recommended `addGlobalPermission([isAdmin],
383
+ '@pikku/addon-console')` was never a gate anybody could actually install. A
384
+ function now resolves the root bucket and its own package's, root first.
385
+ Unioning is safe in a way nothing else here would be: globals AND, so adding the
386
+ root ones can only tighten. Package buckets stay one-way — a package's globals
387
+ never reach root functions, or an installed addon could gate the whole
388
+ application. Apps with both a root global and addon-provided functions will see
389
+ those functions gated where they previously were not.
390
+
391
+ **Codegen now records whether each HTTP route requires a session, and warns
392
+ about inert addon tags (PKU575).** Four separate things can demand a session —
393
+ the function's `sessionless`, its own `auth`, the route's (or its group's)
394
+ `auth`, and the addon it belongs to — and answering "which routes are open?"
395
+ meant joining all four by hand and knowing which wins. Each route's meta now
396
+ carries the resolved `requiresSession` alongside the route's own `auth`. Scopes
397
+ count as requiring one, since they are matched against the session's and fail
398
+ closed. Anything not statically knowable resolves to `true`, matching PKU574:
399
+ a route that looks stricter than it is costs less than one that looks open and
400
+ isn't. Separately, `wireAddon({ tags: ['admin'] })` reads like a gate and is
401
+ applied like one right up until no `addTagMiddleware('admin', …)` exists, at
402
+ which point it resolves to an empty list and gates nothing; that now warns.
403
+ Only addon tags are reported — a tag on a function is as likely to be
404
+ organizational, and warning about those would bury the case that matters.
405
+
406
+ **Object-shorthand permissions were missing from meta.** The inspector visited
407
+ `ts.PropertyAssignment` but not `ts.ShorthandPropertyAssignment`, so
408
+ `permissions: { canAdminOrg }` — enforced identically to the longhand form at
409
+ runtime, since `verifyPermissions` has a non-array branch — was recorded as _no
410
+ permissions at all_. That is the most dangerous direction for meta to be wrong
411
+ in: an audit reading it sees an open door where one is shut. It cost this review
412
+ a false IDOR report across ~35 billing and org functions before the source
413
+ contradicted the metadata.
414
+
415
+ **Functions that authorize in their own body can say so.** A webhook receiver
416
+ verifying a signature, or a handler redeeming a signed invite, is genuinely
417
+ closed while carrying no session, scope or permission — indistinguishable in
418
+ meta from one nobody remembered to gate, and so warned about forever by PKU574.
419
+ `selfAuthenticated: true` on the function config records the claim and silences
420
+ the warning for that function. It is declarative only: nothing at runtime reads
421
+ it and it grants nothing. Detection was rejected deliberately — inferring it
422
+ from the body means a function that _looks_ like it checks something silences
423
+ the warning while checking nothing, and a warning that is usually wrong stops
424
+ being read.
425
+
426
+ **Breaking: `scaffold.<feature>` is now `boolean | { auth?, path? }`, and `true`
427
+ means authenticated.** The old `'auth' | 'no-auth' | false` read like a
428
+ starter-file preference while being a live authorization decision, set three
429
+ directories from the functions it governed — the shape the console incident
430
+ took. A surface now becomes public only by writing `{ auth: false }`, so
431
+ omitting a field can never open anything: the failure mode of a forgotten flag
432
+ is a locked door. `{ path }` additionally overrides where the file is
433
+ generated, which previously could only be set for all features at once via
434
+ `pikkuDir`.
435
+
436
+ The legacy strings are **refused, not coerced**. `resolveScaffoldFeature` throws
437
+ naming the key and its replacement (`"rpc": "no-auth"` → `"rpc": { "auth":
438
+ false }`), and it does so at config load, not downstream. An earlier design used
439
+ a bare `string` for the output path, under which `"no-auth"` would have parsed
440
+ as a file named `no-auth` and every unmigrated config would have silently
441
+ produced nonsense; the object form makes any string invalid, so the failure is
442
+ loud.
443
+
444
+ The collapse is deliberately not uniform in effect. For `rpc` the flag was a
445
+ blanket "no anonymous RPC in this app" set on a dispatcher that cannot know what
446
+ it dispatches to; for `userAdmin` it was redundant, since the generated
447
+ functions are already `pikkuFunc` with `scopes: ['admin:users:list']`. But for
448
+ `agent`, `workflow`, `events` and `scenarios` it is the only gate — those
449
+ generate real endpoints the app never authors — so `true` keeps them
450
+ authenticated rather than opening them as a side effect of a config cleanup.
451
+ `webhook` and `remoteRpc` have no auth dimension at all (`serialize-remote-rpc.ts`
452
+ hardcodes `auth: false`), so an `{ auth }` on them is ignored. The three
453
+ configs in this repo are migrated preserving their current behaviour exactly.
454
+
455
+ **Queue job identities are signed.** A job carried the producer's `pikkuUserId`
456
+ as a plain string and the worker resolved a session from it with no
457
+ verification, so write access to the queue backend was act-as-any-user. The
458
+ identity is now `pq1.<claim>.<hmac>`, HMAC-SHA256 over the claim and the
459
+ canonicalized job payload, keyed by HKDF expansion of a new
460
+ `PIKKU_QUEUE_IDENTITY_SECRET`. Producers opt in by wrapping their queue service
461
+ with `SignedQueueService`. This fails safe rather than closed: with no secret
462
+ configured the identity is dropped and jobs still process, warning once per
463
+ process, so no existing deployment breaks on upgrade — it simply loses queue
464
+ identity until the secret is set. The payload rather than the job id is bound
465
+ because SQS, Cloudflare Queues, Azure and the in-memory service all mint ids
466
+ after `add` returns.
467
+
468
+ **Workflow inline state is read from the run record.** `isInline` consulted a
469
+ process-local `Map`, while `WorkflowRun.inline` is durable. Any instance that
470
+ did not start a run disagreed with the record, so one instance could dispatch a
471
+ queued job for a workflow another was already executing in-process. It is now
472
+ async and resolves through the durable identity, cached only when a context
473
+ already exists so a passive reader allocates nothing. The same `Map` also leaked:
474
+ `nextStepKey` fabricated replay state on every step, and `releaseContext`
475
+ refused to free anything carrying it, so runs whose steps executed outside a
476
+ `beginReplay` bracket — the step-worker queue path — stranded their context and
477
+ step state for the process lifetime. Contexts are now released by an explicit
478
+ execution counter. Step ordinals reset per execution rather than accumulating
479
+ across step-worker invocations in one process, which makes step naming
480
+ independent of how work was distributed.
481
+
482
+ **Secret reads fail loud in every store.** `MongoDBSecretService.getSecrets`
483
+ skipped rows that failed to decrypt, and the redis equivalent dropped every
484
+ rejection via `Promise.allSettled`, including the "No KEK available for
485
+ key_version N" configuration error. Both now throw, naming the key and its key
486
+ version, matching the kysely behaviour. This matters most alongside the KEK
487
+ change above: without it, an upgrade surfaces as a partial secrets map and an
488
+ opaque downstream failure instead of an error naming the secret to re-enter.
489
+
490
+ **A second middleware registration for a pattern no longer erases the first.**
491
+ `addHTTPMiddleware`, `addTagMiddleware` and `addChannelMiddleware` groups are
492
+ keyed by pattern or tag and held one source file each, so a second file's call
493
+ overwrote the first's. Codegen emits its imports from what is stored, so the
494
+ losing file was never imported and its middleware never registered — the
495
+ runtime composes repeated registrations for a pattern happily, and only codegen
496
+ dropped one. Adding an unrelated `addHTTPMiddleware('*', …)` to an app was
497
+ enough to silently unregister the generated better-auth session bridge, which
498
+ fails open and gives no sign until a request arrives without a session. A group
499
+ now carries every registration made for it, and all of them are imported.
500
+
501
+ - Updated dependencies [6a307f0]
502
+ - Updated dependencies [afef587]
503
+ - Updated dependencies [8075f6a]
504
+ - @pikku/core@0.12.74
505
+
1
506
  ## 0.13.6
2
507
 
3
508
  ### Patch Changes
@@ -16,8 +16,11 @@ export { KyselyScopeService } from './kysely-scope-service.js';
16
16
  export { KyselyWebhookService } from './kysely-webhook-service.js';
17
17
  export { createAuditedKysely, type CreateAuditedKyselyOptions, } from './create-audited-kysely.js';
18
18
  export { KyselyAuditService } from './kysely-audit-service.js';
19
+ export { KyselyVirtualUserRunStore } from './kysely-virtual-user-run-store.js';
19
20
  export { SerializePlugin, SqliteSerializePlugin, BaseSerializePlugin, defaultSerializer, defaultDeserializer, maybeJson, skipTransform, dateRegex, type Serializer, type Deserializer, type SerializePluginOptions, } from './serialize-plugin.js';
20
- export { pikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, ensurePikkuSchema, type EnsureOutcome, resolveRequirements, unsatisfiedRequirements, rawStatement, requiredType, type PikkuSchema, type RequiredTypes, type SchemaRequirement, type UnmetRequirement, type SchemaStatement, type SchemaStatementFactory, } from './schema/index.js';
21
+ export { virtualUserSchema } from './schema/virtual-user.schema.js';
22
+ export { auditSchema, pikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, ensurePikkuSchema, type EnsureOutcome, resolveRequirements, unsatisfiedRequirements, rawStatement, requiredType, type PikkuSchema, type RequiredTypes, type SchemaRequirement, type UnmetRequirement, type SchemaStatement, type SchemaStatementFactory, } from './schema/index.js';
21
23
  export type { KyselyPikkuDB } from './kysely-tables.js';
22
24
  export type { WorkflowRunService } from '@pikku/core/workflow';
23
25
  export type { AgentRunService, AgentRunRow } from '@pikku/core/ai-agent';
26
+ export type { VirtualUserRunStore, VirtualUserRunRecord, } from '@pikku/core/virtual-user';
package/dist/src/index.js CHANGED
@@ -14,5 +14,7 @@ export { KyselyScopeService } from './kysely-scope-service.js';
14
14
  export { KyselyWebhookService } from './kysely-webhook-service.js';
15
15
  export { createAuditedKysely, } from './create-audited-kysely.js';
16
16
  export { KyselyAuditService } from './kysely-audit-service.js';
17
+ export { KyselyVirtualUserRunStore } from './kysely-virtual-user-run-store.js';
17
18
  export { SerializePlugin, SqliteSerializePlugin, BaseSerializePlugin, defaultSerializer, defaultDeserializer, maybeJson, skipTransform, dateRegex, } from './serialize-plugin.js';
18
- export { pikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, ensurePikkuSchema, resolveRequirements, unsatisfiedRequirements, rawStatement, requiredType, } from './schema/index.js';
19
+ export { virtualUserSchema } from './schema/virtual-user.schema.js';
20
+ export { auditSchema, pikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, ensurePikkuSchema, resolveRequirements, unsatisfiedRequirements, rawStatement, requiredType, } from './schema/index.js';
@@ -1,4 +1,4 @@
1
- import type { AuditEvent, AuditEventBatch, AuditService } from '@pikku/core';
1
+ import type { AuditEvent, AuditEventBatch, AuditFacets, AuditQuery, AuditQueryResult, AuditService } from '@pikku/core';
2
2
  import type { Kysely } from 'kysely';
3
3
  /**
4
4
  * Durable {@link AuditService} that persists AuditEvents to an `audit` table
@@ -6,14 +6,46 @@ import type { Kysely } from 'kysely';
6
6
  *
7
7
  * The column mapping matches a platform audit-queue consumer, so a locally-run
8
8
  * project and a deployed stage write identical rows (the read side relies only
9
- * on `type`, `occurred_at`, `actor_user_id`, and the `data` JSON that holds
9
+ * on `type`, `occurred_at`, `user_id`, and the `data` JSON that holds
10
10
  * `metadata`). The `audit` table is an optional per-project migration (not in
11
11
  * the generated schema), so rows are inserted untyped; all columns are TEXT on
12
12
  * every engine and ON CONFLICT DO NOTHING keeps writes idempotent on retries.
13
13
  */
14
14
  export declare class KyselyAuditService implements AuditService {
15
15
  private db;
16
+ private initialized;
16
17
  constructor(db: Kysely<any>);
18
+ /**
19
+ * Creates the `audit` table if the database has none.
20
+ *
21
+ * Optional — a project that migrates the table itself can skip it, and the
22
+ * declaration is written to match the documented shape either way. Calling it
23
+ * on a database that already has the table is a no-op, so two instances
24
+ * booting cold do not race each other into a half-applied schema.
25
+ */
26
+ init(): Promise<void>;
17
27
  audit(event: AuditEvent): Promise<void>;
18
28
  write(batch: AuditEventBatch): Promise<void>;
29
+ /**
30
+ * A page of the trail, newest first.
31
+ *
32
+ * Offset paging, matching the rest of the console. The trail is append-only
33
+ * and ordered by `occurred_at DESC`, so events written *while* a reader is
34
+ * paging shift the window and can repeat a row across pages. That is visible
35
+ * but harmless for browsing; anything that must not miss a row should page by
36
+ * a bounded `from`/`to` window instead of scrolling.
37
+ *
38
+ * Ties on `occurred_at` are broken by `audit_id` so the order is total —
39
+ * without it a batch flushed in one call shares a timestamp and the engine is
40
+ * free to order it differently per query, which drops or repeats rows at a
41
+ * page boundary.
42
+ */
43
+ query(query?: AuditQuery): Promise<AuditQueryResult>;
44
+ facets(): Promise<AuditFacets>;
45
+ /**
46
+ * An empty array means "match nothing" and is applied as such. Treating it as
47
+ * "no filter" would turn a reader's deselect-everything into a full-trail
48
+ * read, which is the opposite of what they asked for.
49
+ */
50
+ private applyFilters;
19
51
  }