@pikku/inspector 0.12.50 → 0.12.54

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 (116) hide show
  1. package/CHANGELOG.md +743 -0
  2. package/OPTIMIZATION-PLAN.md +9 -3
  3. package/dist/add/add-auth.d.ts +1 -1
  4. package/dist/add/add-auth.js +1 -1
  5. package/dist/add/add-cli.js +7 -0
  6. package/dist/add/add-credential.js +1 -1
  7. package/dist/add/add-functions.js +199 -8
  8. package/dist/add/add-http-route.js +16 -6
  9. package/dist/add/add-middleware.js +46 -3
  10. package/dist/add/add-personas.d.ts +13 -0
  11. package/dist/add/add-personas.js +216 -0
  12. package/dist/add/add-scope.js +1 -1
  13. package/dist/add/add-secret.js +1 -1
  14. package/dist/add/add-system-role.d.ts +2 -0
  15. package/dist/add/add-system-role.js +102 -0
  16. package/dist/add/add-variable.js +1 -1
  17. package/dist/add/add-wire-addon.js +31 -0
  18. package/dist/add/add-workflow.js +12 -1
  19. package/dist/error-codes.d.ts +17 -1
  20. package/dist/error-codes.js +23 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/inspector.js +33 -1
  23. package/dist/types.d.ts +55 -2
  24. package/dist/utils/annotate-http-route-auth.d.ts +22 -0
  25. package/dist/utils/annotate-http-route-auth.js +48 -0
  26. package/dist/utils/check-secret-sinks.d.ts +31 -0
  27. package/dist/utils/check-secret-sinks.js +117 -0
  28. package/dist/utils/extract-function-name.js +13 -4
  29. package/dist/utils/extract-function-node.js +18 -1
  30. package/dist/utils/extract-secret-usage.d.ts +9 -0
  31. package/dist/utils/extract-secret-usage.js +61 -0
  32. package/dist/utils/literal-properties.d.ts +22 -0
  33. package/dist/utils/literal-properties.js +69 -0
  34. package/dist/utils/load-addon-functions-meta.js +26 -1
  35. package/dist/utils/permissions.js +8 -0
  36. package/dist/utils/post-process.d.ts +68 -3
  37. package/dist/utils/post-process.js +246 -13
  38. package/dist/utils/secret-alias-services.d.ts +8 -0
  39. package/dist/utils/secret-alias-services.js +45 -0
  40. package/dist/utils/secret-brokers.d.ts +2 -0
  41. package/dist/utils/secret-brokers.js +9 -0
  42. package/dist/utils/serialize-inspector-state.d.ts +10 -0
  43. package/dist/utils/serialize-inspector-state.js +18 -0
  44. package/dist/utils/serialize-middleware-groups-meta.js +3 -0
  45. package/dist/utils/single-declaration.d.ts +32 -0
  46. package/dist/utils/single-declaration.js +43 -0
  47. package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
  48. package/dist/utils/validate-exposed-functions-gated.js +93 -0
  49. package/dist/utils/validate-tags-resolve-to-middleware.d.ts +20 -0
  50. package/dist/utils/validate-tags-resolve-to-middleware.js +41 -0
  51. package/dist/utils/workflow/dsl/extract-dsl-workflow.d.ts +6 -0
  52. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +48 -1
  53. package/dist/utils/workflow/dsl/patterns.d.ts +9 -4
  54. package/dist/utils/workflow/dsl/patterns.js +26 -4
  55. package/dist/visit.js +4 -0
  56. package/package.json +3 -3
  57. package/src/add/add-auth.test.ts +5 -3
  58. package/src/add/add-auth.ts +1 -1
  59. package/src/add/add-cli.ts +8 -0
  60. package/src/add/add-credential.ts +1 -1
  61. package/src/add/add-functions.test.ts +263 -1
  62. package/src/add/add-functions.ts +273 -11
  63. package/src/add/add-http-route.ts +20 -7
  64. package/src/add/add-middleware.test.ts +196 -0
  65. package/src/add/add-middleware.ts +64 -4
  66. package/src/add/add-personas.test.ts +382 -0
  67. package/src/add/add-personas.ts +285 -0
  68. package/src/add/add-scenario-step.test.ts +87 -5
  69. package/src/add/add-scope.test.ts +103 -30
  70. package/src/add/add-scope.ts +1 -1
  71. package/src/add/add-secret.ts +1 -1
  72. package/src/add/add-subject-scenario-steps.test.ts +161 -0
  73. package/src/add/add-system-role.test.ts +297 -0
  74. package/src/add/add-system-role.ts +164 -0
  75. package/src/add/add-variable.ts +1 -1
  76. package/src/add/add-wire-addon.test.ts +111 -0
  77. package/src/add/add-wire-addon.ts +29 -0
  78. package/src/add/add-workflow.ts +21 -1
  79. package/src/add/addon-bans.test.ts +4 -4
  80. package/src/add/allow-escape-hatches.test.ts +144 -0
  81. package/src/add/audit-meta.test.ts +75 -0
  82. package/src/add/config-value-metadata.test.ts +11 -11
  83. package/src/add/secret-sink-check.test.ts +211 -0
  84. package/src/add/wire-name-literal.test.ts +2 -2
  85. package/src/error-codes.ts +26 -0
  86. package/src/index.ts +4 -1
  87. package/src/inspector.ts +39 -0
  88. package/src/types.ts +56 -2
  89. package/src/utils/annotate-http-route-auth.test.ts +163 -0
  90. package/src/utils/annotate-http-route-auth.ts +52 -0
  91. package/src/utils/check-secret-sinks.ts +145 -0
  92. package/src/utils/extract-function-name.ts +14 -4
  93. package/src/utils/extract-function-node.ts +18 -6
  94. package/src/utils/extract-secret-usage.test.ts +81 -0
  95. package/src/utils/extract-secret-usage.ts +73 -0
  96. package/src/utils/literal-properties.ts +97 -0
  97. package/src/utils/load-addon-functions-meta.ts +32 -2
  98. package/src/utils/permissions-shorthand.test.ts +79 -0
  99. package/src/utils/permissions.ts +7 -0
  100. package/src/utils/post-process.test.ts +133 -0
  101. package/src/utils/post-process.ts +313 -12
  102. package/src/utils/secret-alias-services.test.ts +146 -0
  103. package/src/utils/secret-alias-services.ts +58 -0
  104. package/src/utils/secret-brokers.test.ts +53 -0
  105. package/src/utils/secret-brokers.ts +11 -0
  106. package/src/utils/serialize-inspector-state.ts +28 -0
  107. package/src/utils/serialize-middleware-groups-meta.ts +3 -0
  108. package/src/utils/single-declaration.ts +57 -0
  109. package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
  110. package/src/utils/validate-exposed-functions-gated.ts +103 -0
  111. package/src/utils/validate-tags-resolve-to-middleware.test.ts +105 -0
  112. package/src/utils/validate-tags-resolve-to-middleware.ts +48 -0
  113. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +57 -0
  114. package/src/utils/workflow/dsl/patterns.ts +33 -4
  115. package/src/visit.ts +4 -0
  116. package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,746 @@
1
+ ## 0.12.54
2
+
3
+ ### Patch Changes
4
+
5
+ - 62ea4cc: The audit trail is now readable — in the generated meta, through an RPC, and as
6
+ a page in the console.
7
+
8
+ `audit: true` reaches `FunctionRuntimeMeta.audit` as its resolved form
9
+ (`{ durability }`), so which functions record anything is answerable without
10
+ running them. It is informational: the runner still resolves audit from the live
11
+ function config, so meta and runtime cannot disagree.
12
+
13
+ `AuditService` grows an optional read side — `query(AuditQuery)` and `facets()`.
14
+ Optional because a sink can legitimately be write-only: a queue producer that
15
+ hands events to another system has nothing to read back, and a reader that finds
16
+ these absent should say the trail is not readable here rather than that it is
17
+ empty. The two are very different answers to give someone auditing a system.
18
+
19
+ `KyselyAuditService` implements both, newest first with offset paging, filtered
20
+ by user, action and time window. Two things it now gets right that are easy to
21
+ get wrong: an empty filter array means "match nothing" rather than "no filter",
22
+ and results are read by physical _and_ camelCase key, because `CamelCasePlugin`
23
+ is on most pikku Kysely instances and renames result keys on the way out — the
24
+ mismatch does not throw, it returns a page of `undefined`. `init()` creates the
25
+ `audit` table for projects that do not migrate it themselves, from a new
26
+ exported `auditSchema` that stays out of `pikkuSchemas` because the runtime does
27
+ not need it.
28
+
29
+ The console addon exposes `console:getAudits` and `console:getAuditFilters`
30
+ behind a new `pikku:audit:read` scope, and forwards the application's `audit`
31
+ service into the addon's own services — without that last part every install
32
+ reported the trail as unreadable, whatever sink it had configured.
33
+
34
+ The console gets an Audit trail page: an infinite list filtered server-side by
35
+ user and action, and a row that opens the whole event, metadata rendered as a
36
+ JSON tree. Refused, unreadable and empty are three different screens, because
37
+ "you may not read this", "nobody can read this" and "nothing happened" are three
38
+ different facts.
39
+
40
+ Events name the person who caused them. The trail records a user id — the only
41
+ thing stable enough to record, since a name can change after the event — so
42
+ `getAudits` resolves those ids against better-auth's user directory at read
43
+ time, and the page shows the name while keeping the recorded id on the event.
44
+ The filter follows: pick a colleague by name, filter by the id. A scenario
45
+ actor is labelled as one, so synthetic traffic is not mistaken for real, and a
46
+ caller who was signed out shows the wire identity pikku resolved for them
47
+ rather than being credited to the system.
48
+
49
+ **Breaking, for anyone already reading `AuditEvent`:** `actor` is now
50
+ `userIdentity`, and its type `AuditActor` is `AuditUserIdentity`; `AuditQuery`
51
+ takes `userIds`/`orgId` in place of `actorUserIds`/`actorOrgId`, and
52
+ `AuditFacets` returns `userIds`. In pikku an _actor_ is a synthetic person a
53
+ scenario drives, flagged on the user row — so naming the causer of an event
54
+ `actor` made the synthetic case unsayable (`actor.actor === true`) and implied
55
+ every recorded action was a test. The overwhelming majority are ordinary
56
+ customers. The `audit` table follows: `actor_user_id` / `actor_org_id`
57
+ are now `user_id` / `org_id`, and a `pikku_user_id` column joins them so the
58
+ wire identity of a caller who never signed in survives the round trip — the
59
+ sink was dropping it, which left the console's Session field permanently
60
+ blank. A project that already migrated the table needs to rename the two
61
+ columns and add the third; `KyselyAuditService.init()` creates the new shape
62
+ for anyone who did not.
63
+
64
+ - 9dddff8: Split a column's at-rest form out of its classification.
65
+
66
+ `security: 'encrypted'` sat beside `'secret'` as though the two were
67
+ alternatives, which made the field unanswerable: a token hash and a live bearer
68
+ token are both secret, one must never be encrypted — the digest _is_ the lookup
69
+ key — and the other must always be. A column now carries a second, independent
70
+ `form: 'plain' | 'hashed' | 'wrapped' | 'sealed'` saying how the bytes are held.
71
+
72
+ Declaring a form other than `plain` makes the column's INSERT/UPDATE type
73
+ nominal — `WrappedValue`, `SealedValue`, `HashedValue` — so a plain string no
74
+ longer compiles there and the only way to write the column is with something an
75
+ encrypt, seal or hash call produced. `envelopeEncrypt`, `envelopeRewrap` and
76
+ `wrapDEK` now return the brand, and a new `hashToken` produces `HashedValue`, so
77
+ the round trip needs no casts; `column-form.ts` exports deliberately-named
78
+ `unsafeAs*` assertions for backfills, fixtures and values sealed elsewhere.
79
+ Reads are unaffected — the brands widen to `string` and compose with the
80
+ classification brand as `Secret<WrappedValue>`.
81
+
82
+ `wrapped` and `sealed` stay distinct because a sealed value is one the
83
+ application cannot read back; storing one where the other belongs is a row
84
+ nobody can open.
85
+
86
+ A `secret` column that has not declared a form now warns (PKU483), and a form on
87
+ a non-text column warns and is dropped (PKU484). Both are warnings, so existing
88
+ projects keep migrating — `pikku db --fail-on-warn` opts into the ratchet, and
89
+ an explicit `form: 'plain'` is the acknowledgement that silences it. The legacy
90
+ `security: 'encrypted'` keeps working and now expands to the pair it always
91
+ meant, `secret` + `wrapped`.
92
+
93
+ - 9dddff8: Read schema-qualified annotations, and let a project name its default schema.
94
+
95
+ `DbClassificationMap` nests tables under their schema whenever a project sets
96
+ `db.schema`, but `loadAnnotations` read the sidecar as a flat table→column map.
97
+ It therefore took the schema for a table and each table for a column, found no
98
+ recognised fields, and dropped every annotation on the floor — so a project
99
+ could mark a column `secret` and watch it generate as `private`, with no error
100
+ anywhere. The parser now detects the extra level (a column entry's values are
101
+ primitives; a table's are objects) and flattens it, so both shapes load.
102
+
103
+ New `db.defaultSchema` drops one schema's qualifier from the generated types:
104
+ with `defaultSchema: 'app'`, `app.user` is queried as `selectFrom('user')` and
105
+ typed as `User` rather than `AppUser`, matching what a project whose
106
+ `search_path` already resolves the schema actually writes. Tables in other
107
+ schemas stay qualified. Where dropping the qualifier would make two tables
108
+ share a name, the table keeps its qualifier and the codegen warns (PKU485)
109
+ rather than letting one silently shadow the other — queries against the loser
110
+ would have typechecked against the wrong columns.
111
+
112
+ The generated key is what Kysely puts in the SQL, so this is opt-in and
113
+ separate from `db.schema`: setting it for a schema the connection does not
114
+ resolve gives you queries that compile and then fail to find their table.
115
+
116
+ - 155528a: The PKU910 sessionless-output diagnostic advised marking a column `@public`, a
117
+ SQL-comment annotation syntax that no longer exists. Column classification is
118
+ sourced solely from the hand-authored `db/annotations.ts`, so the message now
119
+ points there.
120
+ - 78b29f0: `SecretService` now returns a `SecretValue<T>` rather than the bare value, so a
121
+ vault secret cannot reach a sink by accident.
122
+
123
+ `SecretValue` is nominally typed, which means it is not assignable to `string`
124
+ (or to any other concretely-typed field). Every sink with a real type — a
125
+ database column, an email body, a session payload — rejects it with no lint
126
+ rule involved. The sinks typed `any`, `unknown`, or a free generic — the logger,
127
+ queue payloads, webhook and email inputs, and a function's own output — are
128
+ guarded with `Safe<T>`, which collapses a `SecretValue` found anywhere inside
129
+ `T`, however deeply nested, to `never`.
130
+
131
+ Unwrap deliberately at the point the secret reaches the wire:
132
+
133
+ ```ts
134
+ const secret = await secrets.getSecret('BETTER_AUTH_SECRET')
135
+ betterAuth({ secret: secret.reveal() })
136
+ ```
137
+
138
+ Two behaviours cover what types cannot see. Structured serialization redacts —
139
+ `JSON.stringify` and node's inspect both yield `[secret]`, so an audit or log
140
+ write stays honest without crashing the request. String coercion throws
141
+ `SecretCoercionError`, because a template literal is always a leak.
142
+
143
+ `AuditLog.write` is guarded the same way as the logger, since an audit event
144
+ carries `input` and `metadata` as `unknown` and nominality alone cannot stop a
145
+ secret landing in one.
146
+
147
+ `.reveal()` is the deliberate escape hatch, and what it hands back is an
148
+ ordinary string as far as every sink signature is concerned. **PKU953** closes
149
+ that gap: under `pikku all --security` the inspector reports a revealed secret
150
+ that flows into a logger, an audit, a queue, an email or a webhook — `console` included.
151
+
152
+ This also fixed a real one: `remote-addon-auth.ts` called `String(token)` on an
153
+ `unknown` and wrote the result straight into an `Authorization` header.
154
+
155
+ - Updated dependencies [62ea4cc]
156
+ - Updated dependencies [9dddff8]
157
+ - Updated dependencies [78b29f0]
158
+ - @pikku/core@0.12.76
159
+
160
+ ## 0.12.53
161
+
162
+ ### Patch Changes
163
+
164
+ - fd72e58: Drop `scenario.step` — a scenario step is now always a `given`, `when` or
165
+ `then`.
166
+
167
+ `step` rendered no keyword, which made it the phase to reach for whenever a
168
+ step did not obviously fit one of the three. That is exactly the step a reader
169
+ cannot check: a scenario is read by people deciding whether it describes the
170
+ behaviour they wanted, and a row that says what it does without saying whether
171
+ it is setup, action or claim tells them nothing to agree or disagree with. It
172
+ was also the escape hatch from the assertion lint — a scenario with no `then`
173
+ could be made to stop complaining by demoting its steps rather than by
174
+ asserting anything.
175
+
176
+ Replace `scenario.step(...)` with whichever of `given`, `when` or `then` the
177
+ step actually is. `then` is not a rename: it makes the step's bindings
178
+ witnesses rather than alternatives, so every declared surface runs and they
179
+ must agree.
180
+
181
+ - d041d5b: Let a generated file declare scopes, roles and personas without taking the
182
+ single-declaration slot.
183
+
184
+ `defineScope`, `defineSystemRole` and `definePersonas` are one-per-codebase, and
185
+ the CLI generates declarations of its own — `user-admin.gen.ts` ships the whole
186
+ `admin` scope tree. Any project that scaffolded one therefore had two
187
+ declarations and failed codegen with PKU583, and the losing file's scopes were
188
+ dropped from the metadata rather than merged.
189
+
190
+ The rule exists to name the one place a person reads from and adds to, and
191
+ nobody adds to a file the next codegen run overwrites. A generated declaration
192
+ is still extracted; it just neither claims the slot nor collides with the app's
193
+ own.
194
+
195
+ - fd72e58: Make personas a first-class surface rather than a detail of the test runner.
196
+
197
+ A persona is now read in three places — the knowledge base resolves `persona:`
198
+ URIs against it, scenarios cast it as an actor, and a virtual user runs as it —
199
+ so it gets its own page at `/personas` under a new **People** section in the
200
+ rail, alongside Users. The card is a profile: avatar, name, job title, computed
201
+ address, the system roles they hold, and how many scenarios cast them. Opening
202
+ one expands each role to the scopes it confers, which is the half of the picture
203
+ that explains a 403.
204
+
205
+ `definePersonas` takes an optional `avatarUrl` — any URL a browser can load.
206
+ Nothing is derived from the address: a persona's address is synthetic, so a
207
+ derived identicon would be the same shrug for everyone. Omitted, the console
208
+ keeps drawing the deterministic colour-and-icon avatar from the persona's id.
209
+
210
+ - fd72e58: Read the actors that are not people on the personas page.
211
+
212
+ The platform — the app acting on itself, what `pikkuPlatformScenarioStep`
213
+ declares — now has a row of its own, alongside one per addon whose system a
214
+ step makes act. They sit behind a People / System / All filter that opens on
215
+ the people: a subject holds no roles and signs in as nobody, so leading with it
216
+ would put the rows nothing is authorized through above the ones that are.
217
+
218
+ The platform row is built in rather than derived. A project that has never
219
+ written a platform step still has a platform, and a card that appeared the
220
+ moment somebody declared their first step would read as a feature they had
221
+ switched on.
222
+
223
+ Also: PKU680 now counts `expectService`, `expectError` and `expectEventually`
224
+ as assertions. They are inline steps and carry no phase, so a scenario whose
225
+ only witness was a recorded service call was being told it never asserts.
226
+
227
+ - 894b2f8: `defineScope` and `defineSystemRole` accumulate across call sites again. Only `definePersonas` is one-per-codebase.
228
+
229
+ The previous release made all three single-declaration constructs, which no project scaffolding user-admin could satisfy: the CLI generates a `defineScope` of its own in `user-admin.gen.ts` carrying the whole `admin` tree, and `@pikku/addon-console` spells the same tree out again, so a second hand-written declaration failed the build with PKU583 — and the losing file's scopes were dropped from the metadata rather than merged.
230
+
231
+ Exempting generated files would have reinstated exactly the ambiguity the rule removes, only for the files nobody can read the rule from. The real fix is for `admin` to be a default scope nobody declares, at which point the rule can come back for scopes and roles.
232
+
233
+ `definePersonas` is unaffected: nothing generates one, so its single call site stands.
234
+
235
+ - dd19aa7: Drop `scopes` from sessionless functions, rename `selfAuthenticated`, and make both
236
+ escape hatches opt-in.
237
+
238
+ **`scopes` are gone from `pikkuSessionlessFunc`.** They are AND-ed and `verifyScopes`
239
+ fails closed on a session that does not exist, so every scope listed on a sessionless
240
+ function rejected the anonymous caller it exists to serve. `CorePikkuSessionlessFunctionConfig`
241
+ now states this once in core, and the generated `pikkuSessionlessFunc` / `pikkuVoidFunc`
242
+ configs derive from it — so the field is absent rather than subtracted.
243
+
244
+ `@pikku/addon-console`'s `installAddon` and `installOpenapiAddon` are now `pikkuFunc`.
245
+ Both set `auth: true` and `scopes: ['admin']`, and a test exercises that gate, so the
246
+ scopes were load-bearing — they only compiled as sessionless because the config accepted
247
+ a field it could not honour. No behaviour change: both already required a session.
248
+
249
+ **`selfAuthenticated` is now `permissionsInBody`.** It never described authentication:
250
+ what it records is that the permission check lives in the function body rather than in a
251
+ declared `permissions` entry.
252
+
253
+ **Both escape hatches must be opted into**, via a new `allow` block in
254
+ `pikku.config.json`:
255
+
256
+ ```json
257
+ "allow": { "permissionsInBody": true, "complexWorkflows": true }
258
+ ```
259
+
260
+ Unset means unavailable, and using the feature is a build error naming the flag that
261
+ would permit it — PKU576 for `permissionsInBody`, PKU643 for `pikkuWorkflowComplexFunc`.
262
+ Both trade something the tooling can inspect for something only a reader can verify: a
263
+ permission check buried in a body, or workflow steps that cannot be serialized into the
264
+ graph, replayed, or migrated. Both are occasionally right, and both are the path of least
265
+ resistance whenever the declarative form is merely inconvenient. Whoever owns the project
266
+ makes that call once, in writing, instead of every author making it silently at the call
267
+ site.
268
+
269
+ **PKU574's message no longer contradicts any of this.** Every function it reports is
270
+ sessionless — that is how the population is selected, not a finding — yet it opened by
271
+ reporting that they "require neither a session", then advised adding scopes. It now names
272
+ them as sessionless and recommends only gates an anonymous caller can meet:
273
+ `permissions`, `auth: true`, `wireAddon({ auth: true })`, or dropping `expose: true`.
274
+ `permissionsInBody` is deliberately absent from that list: a diagnostic should not
275
+ advertise its own escape hatch.
276
+
277
+ - 50ec500: Make `defineScope`, `defineSystemRole` and `definePersonas` single-declaration constructs
278
+ — exactly one call site per codebase, the rule `pikkuBetterAuth` has always had.
279
+
280
+ Each of the three already takes a keyed object, so one call declares as many entries as
281
+ you like. Spreading the calls across files bought nothing and cost the thing that matters:
282
+ there was no answer to "where do I add a persona?", so downstream tooling and agents had
283
+ nowhere unambiguous to read from or append to. The only duplicate handling that existed
284
+ caught a narrow case — the same id declared twice with different content — and said
285
+ nothing about the same id declared twice in two files.
286
+
287
+ A second call now fails the build with `PKU583` (`defineScope`), `PKU584`
288
+ (`defineSystemRole`) or `PKU585` (`definePersonas`), naming both source files and saying
289
+ to declare them all in one call. A second call in the _same_ file is refused too: "the
290
+ file" is not an answer either when the file holds two calls.
291
+
292
+ - Updated dependencies [32277d5]
293
+ - Updated dependencies [ea8aabf]
294
+ - Updated dependencies [33e96ab]
295
+ - Updated dependencies [fd72e58]
296
+ - Updated dependencies [fd72e58]
297
+ - Updated dependencies [fd72e58]
298
+ - Updated dependencies [894b2f8]
299
+ - Updated dependencies [dd19aa7]
300
+ - Updated dependencies [50ec500]
301
+ - @pikku/core@0.12.75
302
+
303
+ ## 0.12.52
304
+
305
+ ### Patch Changes
306
+
307
+ - 8075f6a: Confine `SecretService` to the places an app is wired.
308
+
309
+ `secrets` is now omitted from the services every function, AI agent, workflow,
310
+ permission and wire receives, and the function runner replaces it with a
311
+ throwing accessor so a cast cannot reach past the type. It stays available in
312
+ `pikkuServices`, `pikkuWireServices`, addon service factories and middleware —
313
+ read a secret there, give it to a service, and have the function ask that
314
+ service.
315
+
316
+ Alongside it:
317
+ - `wireSecret` gains `allowedHosts`, refusing a secret attached to a host it was
318
+ not declared for. Permissive by default; strict via
319
+ `config.secrets.requireAllowedHosts`.
320
+ - `pikku-graph`'s `httpRequest` resolves and attaches its credential inside a new
321
+ `httpRequester` service instead of holding the plaintext in the function.
322
+ - New inspector diagnostics: `PKU950` (a `SecretService` exposed under another
323
+ service name), `PKU951` (a secret read that no `wireSecret` declares) and
324
+ `PKU952` (a secret read with a non-literal key).
325
+
326
+ - Updated dependencies [6a307f0]
327
+ - Updated dependencies [afef587]
328
+ - Updated dependencies [8075f6a]
329
+ - @pikku/core@0.12.74
330
+
331
+ ## 0.12.51
332
+
333
+ ### Patch Changes
334
+
335
+ - c984df6: Give an agent's tools back the descriptions their authors wrote
336
+
337
+ A tool's description is what the model is told the tool does, and the main
338
+ thing it chooses between tools on. It was not reaching the model. `description`
339
+ is classed as a verbose field, so it is stripped from the metadata bundled into
340
+ the generated bootstrap — the copy `pikkuState('function', 'meta')` is built
341
+ from. `buildToolDefs` read the description from there, found it always
342
+ undefined, and fell through to the tool's own name. Every agent has been
343
+ choosing between bare identifiers. The same fallback was offering an addon's
344
+ MCP tools under their names, for the same reason.
345
+
346
+ Tool definitions now resolve descriptions through `metaService`, which reads
347
+ the verbose metadata and falls back to the minimal copy, so the authored text
348
+ is recovered wherever the generated `.pikku` directory is readable. Where it is
349
+ not — no `metaService`, or a deployment shipping only the stripped copy — a
350
+ tool falls back to its name, which is what it did before. Addon metadata is
351
+ likewise loaded verbose-first. `title` is no longer part of the chain: a title
352
+ labels a tool in a UI, it does not tell a model when to reach for it.
353
+
354
+ An addon has to ship the verbose file for any of this to reach it. `tsc` only
355
+ emits the JSON it sees imported and nothing imports the verbose meta, so the
356
+ bundled addons now copy it into `dist` explicitly.
357
+
358
+ `ref()` is resolved at build time. It used to be pushed through codegen as an
359
+ opaque string, so `ref('todos:doesNotExist')` generated cleanly and failed only
360
+ when the agent ran. The inspector now resolves each reference against the
361
+ project's functions, or — using the namespace-to-package mapping `wireAddon`
362
+ already provides — against the addon's own metadata, and reports an unwired
363
+ namespace (`PKU152`) or a missing function (`PKU153`) at codegen. An addon that
364
+ has not been built yet contributed no metadata and is skipped rather than
365
+ reported missing.
366
+
367
+ New `pikku --strict-meta` additionally fails the build on any agent tool with
368
+ no description (`PKU154`), including tools reached through an addon. It is off
369
+ by default, so nothing that builds today stops building; turn it on to hold a
370
+ project to the metadata its agents actually run on.
371
+
372
+ - 63ff32b: Run a CLI's commands on the server, over the connection the client opened
373
+
374
+ A CLI that talks to a service has to ship the service's command tree, so the
375
+ two versions drift: the binary someone installed months ago still believes in
376
+ flags and commands the server has since changed. This makes the command tree
377
+ the server's, and leaves the client holding only a socket.
378
+
379
+ `wireCLI` gains `auth`, and a program wired with a channel entrypoint now
380
+ generates a `__raw` route: the client forwards argv untouched, the server
381
+ parses it, runs the command, and streams the output back as it happens. The
382
+ terminating frame carries the exit code, so a failed remote command still exits
383
+ non-zero locally. Renderers stay on the client and are matched by the command
384
+ id the server reports; an unrecognised command falls back to JSON rather than
385
+ failing.
386
+
387
+ Every channel gains `channel.remote(...)`: calling a function on the peer at
388
+ the other end of the connection and waiting for its answer. A channel is
389
+ otherwise fire-and-forget in both directions, so this is what reaches a peer
390
+ that has no address of its own — a CLI on a laptop, a browser tab, a sandbox
391
+ behind NAT. It is on `channel` rather than `rpc` because it is bound to one
392
+ connection: which peer answers is the socket the call goes out on, not
393
+ something the RPC map could resolve. Any `wireChannel` gets it — a client
394
+ registers what it is willing to answer to, and a name outside that list is
395
+ refused.
396
+
397
+ Requests are correlated by id, time out, and fail fast when the socket closes
398
+ rather than waiting out the timeout. Replies are taken off the socket ahead of
399
+ routing, so a channel needs no route for them and an answer can never be
400
+ mistaken for a new message; the transport is created on first use and released
401
+ when the channel closes, which is also what fails anything the departing peer
402
+ still owed an answer to. Channels that only flow one way — SSE, an agent's
403
+ output stream, a locally-run CLI — refuse the call outright instead of waiting
404
+ for an answer that was never going to come.
405
+
406
+ What a peer answers with is its word, so it is checked before a caller sees it
407
+ — against the schema codegen already generated from the function's declared
408
+ return type, the same one an agent tool or an HTTP response is checked against.
409
+ A capability is declared with `pikkuRemoteChannelFunc`, which takes the usual
410
+ `title` / `description` / `input` / `output` but no `func` — this side owns the
411
+ contract, the peer owns the body. It registers under its name like any other
412
+ function, so `channel.remote` is typed off the same generated map as
413
+ `rpc.remote` and no caller has to cast, and a local call throws rather than
414
+ missing: reaching it locally means a command asked the server for something
415
+ only a client knows. A client on an older build fails the call it answered
416
+ rather than the caller failing later somewhere with no reason to expect a bad
417
+ shape; a name with no declared contract is left alone. Both frame guards
418
+ validate the whole envelope rather than the action tag alone, and a failure
419
+ payload with a non-string name or message falls back rather than being attached
420
+ to an `Error`.
421
+
422
+ The arguments going the other way are checked too, against the schema for the
423
+ capability's declared input, before anything is registered or sent. That is not
424
+ a boundary — the peer runs the code and has to check what it was handed, and a
425
+ caller that meant harm would send arguments that pass. It catches drift, where a
426
+ server built against a newer capability signature calls a client that predates
427
+ it, and fails it here rather than inside someone else's process.
428
+
429
+ A channel-driven CLI command uses this to ask its caller for machine-local
430
+ facts mid-run — a git sha, a working tree, a local file. The CLI wire's own
431
+ channel is synthetic (it exists so a command can stream progress without
432
+ knowing where that goes), so it delegates `remote` to the connection the
433
+ command actually arrived on.
434
+
435
+ Because that runs code on someone's machine at a remote caller's request, the
436
+ capability map says what _can_ run and approval says whether a particular call
437
+ _should_. A capability may be declared `{ execute, needsApproval }`, sharing
438
+ `ApprovalPolicy` — `needsApproval` and `approvalDescriptionFn` — with
439
+ `AIAgentToolDef`, which has carried both since before channels could call back:
440
+ both are an allowlist of named callables invoked by something other than the
441
+ code that wrote them. The runtime around them is deliberately not shared, since
442
+ an agent suspends its run and resumes it later while a reverse call is a live
443
+ await with a person at the other end.
444
+
445
+ A capability written as a bare function is unclassified, and unclassified means
446
+ approval is required — the annotation nobody got round to writing is the one
447
+ most likely to matter, so it fails closed. Declare
448
+ `{ execute, needsApproval: false }` for a capability that may run unattended.
449
+ Nothing infers this: core cannot tell a read-only capability from a destructive
450
+ one, so `needsApproval: false` is the author asserting it, and the assertion is
451
+ the only thing standing between a remote caller and the machine.
452
+
453
+ The default is the opposite of `AIAgentToolDef`'s, where absence means "do not
454
+ ask" — a tool is written by the same people who run the server it executes on,
455
+ and a capability is not.
456
+
457
+ `executeRawCLIViaChannel` reads `--auto-approve` and
458
+ `--dangerously-auto-approve` out of argv (or `PIKKU_AUTO_APPROVE` /
459
+ `PIKKU_DANGEROUSLY_AUTO_APPROVE`) and strips them before argv reaches the
460
+ server — what may run on this machine is this machine's decision, and a flag
461
+ the server can see is one the server could act on. `--auto-approve` permits the
462
+ classified-safe set and refuses the rest; `--dangerously-auto-approve` permits
463
+ everything and says so once on stderr. Interactively the user is asked per
464
+ call, with `y` / `n` / `a`, where `a` is remembered for that one capability for
465
+ the rest of the run and never written to disk — widening it to the session
466
+ would quietly turn an interactive run into `--dangerously-auto-approve`. A run
467
+ with no terminal and no flag refuses rather than assuming yes, because CI is
468
+ exactly where an unattended `git push` would otherwise happen. The tiers are
469
+ meaningful here in a way they would not be for an agent: the caller is a
470
+ deterministic program whose source can be read, so "these calls are always
471
+ fine" is a claim someone can actually justify.
472
+
473
+ A peer that is asking a human sends a pending frame first, which stops the
474
+ caller's timeout. Without it any approval slower than the timeout would fail
475
+ the call and then discard the decision when it finally arrived. The call is
476
+ still failed the moment the socket drops — what actually happens when a peer
477
+ dies mid-prompt — and a peer that sends the frame dishonestly can do nothing
478
+ but keep its own call waiting. A refusal is sent as an answer, so a denied call
479
+ fails its command immediately rather than hanging.
480
+
481
+ Fixes found on the way, each of which broke this path:
482
+ - A websocket upgrade wrote middleware headers (CORS, on every request)
483
+ straight onto the socket, so the first bytes a client saw were headers rather
484
+ than `ws`'s `101` status line and the handshake failed to parse. Header
485
+ writes are now buffered and flushed behind a status line only when the
486
+ upgrade is actually being rejected.
487
+ - An upgrade socket had no error listener while the channel opened, so a client
488
+ that gave up mid-handshake took the whole server process down with an
489
+ unhandled `ECONNRESET`.
490
+ - `onConnect` and `onDisconnect` never saw the session established during the
491
+ upgrade, so a channel could not tell who had just connected.
492
+ - Setting the routing key on a channel result mutated the value in place, which
493
+ throws for a primitive under ESM strict mode.
494
+
495
+ - a7b26c5: rename the inspected declarations to `define*`: `wireScope` → `defineScope`, `wireSecret` → `defineSecret`, `wireVariable` → `defineVariable`, `wireCredential` → `defineCredential`
496
+
497
+ `wire*` meant two unrelated things. A transport wiring attaches a function to
498
+ something that can invoke it — `wireHTTP`, `wireChannel`, `wireScheduler`,
499
+ `wireQueueWorker` and the rest — and the thing it wires runs. These four wire
500
+ nothing: they are no-ops that exist only so the call typechecks, they are
501
+ tree-shaken out of the build, and their whole job is to be found by the
502
+ inspector's AST pass and turned into a type union. One word for both left the
503
+ declaration reading like a registration with a runtime.
504
+
505
+ So the vocabulary splits: **`wire*` is a transport, `define*` is an inspected
506
+ declaration.**
507
+
508
+ ```ts
509
+ import { defineScope } from '@pikku/core/scope'
510
+ import { defineSecret } from '@pikku/core/secret'
511
+ import { defineVariable } from '@pikku/core/variable'
512
+ import { defineCredential } from '@pikku/core/credential'
513
+
514
+ defineScope({ admin: { scopes: { invoices: { scopes: { create: {} } } } } })
515
+ ```
516
+
517
+ **Breaking:** no alias is kept. Rename the four call sites; the module subpaths
518
+ (`@pikku/core/scope`, `/secret`, `/variable`) are unchanged.
519
+
520
+ The inspector matches these by identifier text, so a stale `wire*` call is not a
521
+ type error — it is silently not extracted, and the generated union comes back
522
+ empty. That fails as "this scope isn't declared" on code that was fine a moment
523
+ ago, nowhere near the declaration. Grep for the old names rather than trusting a
524
+ clean build.
525
+
526
+ An addon published with `.pikku` output generated before this release re-exports
527
+ `wireSecret` from `@pikku/core/secret` and will not typecheck against this core
528
+ until it is rebuilt and republished.
529
+
530
+ - 457cb25: Add `definePersonas()`: the people a project's scenarios and virtual users run
531
+ as, declared in code.
532
+
533
+ There used to be three names for two-and-a-bit things — an _actor_ in
534
+ `scenarios.actors`, a _persona_ in `scenarios.personas`, and a _virtual user_
535
+ declared separately against an actor. In practice almost every actor was its own
536
+ kind, so the second set carried no information and the third was a third place
537
+ for a name to drift. There is now one declaration:
538
+
539
+ ```ts
540
+ definePersonas({
541
+ shopper: {
542
+ name: 'Sam Shopper',
543
+ jobTitle: 'Shopper',
544
+ personality: 'Buys in a hurry and leaves tabs open',
545
+ roles: ['customer'],
546
+ disposition: 'careless',
547
+ goals: ['Buy something without reading anything'],
548
+ account: {},
549
+ },
550
+ })
551
+ ```
552
+
553
+ A persona is a person: what they are like, what they want, the roles they hold,
554
+ and **one** account they sign in with — `account: {}` plus `linkedAccounts` for
555
+ the rare case of more, modelled on how better-auth does linking. A persona with a
556
+ `disposition` is a virtual user; `runnable: false` marks someone who only ever
557
+ exists to be acted upon — banned, shared with, reset — and is never handed a
558
+ session.
559
+
560
+ **A persona names roles, never scopes.** Scopes come from `defineSystemRole()`
561
+ expansion, so the build fails if a persona names a role nobody declared, and
562
+ fails again if a role confers a scope no `defineScope` declares. Running one only
563
+ ever has to check that its roles are still valid.
564
+
565
+ **Addresses are computed, never declared.** `personaEmail(id, domain, runId)`
566
+ derives `<id>[+runId]@<domain>` from `scenarios.emailDomain`, so a seed, a
567
+ scenario run and a virtual-user run cannot disagree about who they are signing in
568
+ as. `scenarios.actors` and `scenarios.personas` are gone from
569
+ `pikku.config.json` — only `emailDomain` remains.
570
+
571
+ `actor` survives in exactly one place: the name of a **slot in a scenario step**,
572
+ which is the role a persona is cast in for that step. `pikkuVirtualUser()`,
573
+ `kind`, `grants` and the `actor` field are removed; the `actors` service is now
574
+ `personas`, and the CLI's `virtual-user` commands are now `pikku persona list` /
575
+ `pikku persona run`. `budget` and `allowApprovalRequired` moved to run flags —
576
+ how much you will spend today is not a fact about a person.
577
+
578
+ `@pikku/cucumber` drops its `Actor` class and `ActorDispatchContext`: a
579
+ hand-rolled cookie jar that a persona's own typed session replaces outright.
580
+
581
+ - f7567ad: Add `defineSystemRole()`: roles that ship with the product, declared in code.
582
+
583
+ A system role is to a console-composed role what an AWS managed policy is to a
584
+ customer-managed one — the console may show and grant it, but not rename,
585
+ re-scope or delete it. The CLI extracts declarations by AST and generates a
586
+ `SystemRoleName` union, so naming a role that does not exist fails the build,
587
+ and a role granting a scope no `defineScope` declares fails it too.
588
+
589
+ Removal is additive on the same terms as `defineScope`: deleting a declaration
590
+ leaves an inert row rather than revoking everyone's grant mid-deploy.
591
+
592
+ `ScopeService` gains `syncSystemRoles`, `findStaleSystemRoles` and
593
+ `pruneSystemRoles`; `Role` gains `system` and `declared`. Implementations
594
+ enforce immutability through the shared `assertRoleIsMutable` /
595
+ `assertRoleNameAvailable` guards rather than each inventing the rule.
596
+
597
+ - 457cb25: Let a persona do a real job in production, and say where it may act.
598
+
599
+ A persona was only ever a test subject: something you pointed at a stage to find
600
+ out what the product does wrong. But the same declaration — a name, a job, the
601
+ roles it holds and what it is trying to get done — describes a teammate doing
602
+ the work for real, and nothing about the engine cared which one it was.
603
+
604
+ Four changes make that difference explicit and enforced.
605
+
606
+ **`environments` moves to the top level of `pikku.config.json`**, out from under
607
+ `scenarios`. It was never a scenario's anything: `persona run` targets one, and
608
+ now so does `persona sync`. An environment may be flagged `production: true` —
609
+ a flag rather than a reserved name, because projects call it `prod`, `live` or
610
+ `eu-prod`, and more than one environment can be production.
611
+
612
+ **A persona may name its `environments`.** Omitting them means every configured
613
+ environment _except_ the production ones, so nothing reaches production by being
614
+ forgotten. Naming a production environment requires `disposition: 'accountable'`.
615
+ The rule is checked twice, on purpose: the inspector refuses to generate a
616
+ declaration that breaks it, and sign-in re-checks against the environment
617
+ actually resolved — the build check trusts the file, and the run check does not
618
+ trust which artifact got deployed. An unresolved environment fails closed.
619
+
620
+ **`disposition: 'accountable'`** is that production disposition. It sits opposite
621
+ `adversarial` on the intent axis rather than the care axis: what it changes stays
622
+ changed, every call is recorded against its name, and it stops to ask rather than
623
+ acting and reporting afterwards. Alongside it, **agents now appear in a persona's
624
+ computed catalogue**, gated by the same scopes as the RPCs — an agent is reached
625
+ rather than declared, so a persona finds the specialists its roles unlock and
626
+ chooses between calling the API itself and handing the work over. That also fixes
627
+ a latent gap: `talkTo` was wired at the target but never advertised in the
628
+ instructions, so it was never used.
629
+
630
+ **`pikku persona sync <environment>`** provisions them: it creates each account
631
+ and applies the roles it declares, additively, and never revokes. Seeding is test
632
+ data and `db seed` does not run in production; a teammate doing a real job still
633
+ needs an account and its grants. It needs both halves of an environment — its API
634
+ to sign the person in, its database to write the grants — and `--dry-run` reports
635
+ who would be provisioned, with what, and why anyone was skipped.
636
+
637
+ In the console, a virtual user now says where it may act — the environments it
638
+ named, or the rule when it named none — and its dossier carries the `sync`
639
+ command alongside the `run` one, because the account is not a by-product of a
640
+ run. `accountable` reads as a disposition like the rest.
641
+
642
+ - 86a50b9: scenario: replace `browser: true` + `func` with per-surface bindings on `pikkuScenarioStep`
643
+
644
+ A step now declares one implementation per surface it can be driven through:
645
+
646
+ ```ts
647
+ export const buysTheItem = pikkuScenarioStep<{ sku: string }, { orderId: string }>({
648
+ name: 'buysTheItem',
649
+ description: 'buys the item',
650
+ browser: async (services, data, { browser }) => { ... },
651
+ default: async (services, data, { rpc }) => { ... },
652
+ })
653
+ ```
654
+
655
+ `pikku scenario run --run browser|cli|default` picks which surface the run drives,
656
+ and the two phases resolve bindings differently:
657
+ - **Actions** (`given` / `when` / `step`) run exactly one binding — the run
658
+ surface if it has one, otherwise `default`. A step with neither now fails with
659
+ `ScenarioNoSurfaceBinding` instead of silently running server-side.
660
+ - **Assertions** (`then`) are witnesses, not alternatives: every declared binding
661
+ runs and they must agree. Two surfaces reporting different things fails the run
662
+ with `ScenarioWitnessDisagreement` rather than reporting a pass. An assertion
663
+ with no witness the run can execute at all fails with `ScenarioNoWitness` —
664
+ without it the step returns `undefined` and renders as a tick, reporting a pass
665
+ for something nobody checked.
666
+
667
+ A scenario written as a step ladder that never calls `then` is now a **PKU680**
668
+ critical. It proves only that nothing threw, so an assertion-free ladder of
669
+ browser-bound actions would score perfect coverage while checking nothing.
670
+
671
+ The report gains a surface-coverage line — `n/m steps ran on browser`, counted
672
+ over every step, so an action that fell back to the server lowers the ratio
673
+ rather than needing a footnote. That also makes surfaces comparable over one
674
+ denominator: a scenario is `4/4` on a default run and `3/4` on a browser one.
675
+ Assertions that fell back are named separately and gate `--strict`, since a
676
+ sentence claiming the actor saw something nobody looked at is a different problem
677
+ from an action taking a shortcut.
678
+
679
+ **Breaking:** `browser: true` and the third `B extends boolean` type argument are
680
+ gone. Rename `func` to `default` (or to `browser` where the step drove a browser)
681
+ and drop the type argument.
682
+
683
+ - 0e0f6eb: Add virtual users: LLM-driven synthetic users that work a real stage in
684
+ character.
685
+
686
+ A scenario proves a path somebody thought of. A virtual user works the same
687
+ ground without the script — it signs in as a declared persona over the app's own
688
+ auth, is handed the scenarios' BDD prose and the schema of every endpoint it may
689
+ reach, and decides for itself what to do. It asserts nothing; a run produces
690
+ findings, and their absence only ever means "not this time, not with this seed".
691
+
692
+ There is nothing extra to declare. A persona with a `disposition` is a virtual
693
+ user, and running it is what makes it one — see the `definePersonas` changeset
694
+ for the declaration itself. Listing, describing or running one never loads the
695
+ app: the inspector reads the literal declaration, the CLI writes
696
+ `scopes/pikku-personas-meta.gen.json`, and `MetaService.getPersonasMeta()`
697
+ serves it.
698
+
699
+ **Dispositions are engine dials, not prose.** Each carries its own intent weights
700
+ (continue / suspend / resume / abandon), temperature, re-read and repeat rates,
701
+ and switches: `careless` puts things down and picks them up in the wrong order,
702
+ `newcomer` starts with no memory, `auditor` is never offered a mutation,
703
+ `adversarial` is shown the catalogue its roles do not cover — being offered a
704
+ call it should not be able to make is the test — while those roles stay live as
705
+ the oracle, so a success outside them is authorization drift rather than a pass.
706
+
707
+ **Nothing is retrieved against.** The whole reachable catalogue goes into the
708
+ instructions (~8k tokens on a 430-RPC project, cached for the run), because a
709
+ ranking function would make the user only as adventurous as the ranking and lose
710
+ exactly the endpoints worth stumbling into. Schema first: an endpoint must be
711
+ described before it may be called.
712
+
713
+ **No money in core.** The engine counts steps, calls, mutations and tokens; what
714
+ they cost is the app's to decide through `stop(tally)`.
715
+
716
+ CLI: `pikku persona list` and `pikku persona run <environment> [name]`, with
717
+ flags overriding a declaration for reproduction (`--seed`, `--steps`,
718
+ `--disposition`). Spending is a run flag too — `--steps`, `--mutations` and
719
+ `--duration` bound a run, because how much you will spend today is not a fact
720
+ about a person. Console: a Virtual Users screen beside Scenarios, built out of
721
+ core's own derivation functions so it shows a run's actual inputs rather than a
722
+ second implementation of them.
723
+
724
+ `dev-ai-runner` now ships its own `@pikku/ai-vercel` and
725
+ `@ai-sdk/openai-compatible` instead of requiring them from the project. Behind a
726
+ proxy one openai-compatible provider answers for every prefix, so there was never
727
+ a per-vendor package worth making somebody install; the project's copies still
728
+ win when it has them, and both load from the same place or neither does.
729
+
730
+ - Updated dependencies [c984df6]
731
+ - Updated dependencies [63ff32b]
732
+ - Updated dependencies [ba6cc08]
733
+ - Updated dependencies [d007191]
734
+ - Updated dependencies [a7b26c5]
735
+ - Updated dependencies [457cb25]
736
+ - Updated dependencies [f7567ad]
737
+ - Updated dependencies [ba6cc08]
738
+ - Updated dependencies [a2e21e5]
739
+ - Updated dependencies [457cb25]
740
+ - Updated dependencies [86a50b9]
741
+ - Updated dependencies [0e0f6eb]
742
+ - @pikku/core@0.12.73
743
+
1
744
  ## 0.12.50
2
745
 
3
746
  ### Patch Changes