@pikku/skills 0.12.10 → 0.12.11

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 (60) hide show
  1. package/CHANGELOG.md +768 -0
  2. package/dist/skills.gen.js +1 -1
  3. package/package.json +4 -4
  4. package/skills/pikku-addon/SKILL.md +17 -11
  5. package/skills/pikku-addon/references/addon-package-manifest.md +2 -2
  6. package/skills/pikku-agent/SKILL.md +3 -3
  7. package/skills/pikku-audit/SKILL.md +28 -13
  8. package/skills/pikku-aws/SKILL.md +2 -2
  9. package/skills/pikku-better-auth/SKILL.md +97 -17
  10. package/skills/pikku-build-app/SKILL.md +621 -0
  11. package/skills/pikku-build-app/references/multi-app.md +117 -0
  12. package/skills/pikku-build-app/references/ship.md +98 -0
  13. package/skills/pikku-build-app/references/theming.md +70 -0
  14. package/skills/pikku-build-platform/SKILL.md +239 -0
  15. package/skills/pikku-build-quick/SKILL.md +238 -0
  16. package/skills/pikku-cli/SKILL.md +7 -7
  17. package/skills/pikku-cli/references/complete-example.md +1 -1
  18. package/skills/pikku-concepts/SKILL.md +5 -2
  19. package/skills/pikku-concepts/references/concept-mapping.md +1 -1
  20. package/skills/pikku-config/SKILL.md +5 -3
  21. package/skills/pikku-deploy-azure/SKILL.md +5 -4
  22. package/skills/pikku-emails/SKILL.md +5 -5
  23. package/skills/pikku-fabric/SKILL.md +27 -3
  24. package/skills/pikku-fabric-debug/SKILL.md +1 -1
  25. package/skills/pikku-feature/SKILL.md +5 -4
  26. package/skills/pikku-http/SKILL.md +4 -4
  27. package/skills/pikku-http/references/http-options.md +13 -13
  28. package/skills/pikku-i18n/SKILL.md +2 -1
  29. package/skills/pikku-info/SKILL.md +1 -1
  30. package/skills/pikku-knowledge/SKILL.md +13 -13
  31. package/skills/pikku-mcp/SKILL.md +4 -4
  32. package/skills/pikku-middleware/SKILL.md +5 -5
  33. package/skills/pikku-middleware/references/middleware-patterns.md +14 -8
  34. package/skills/pikku-n8n-import/SKILL.md +12 -12
  35. package/skills/pikku-n8n-import/SPEC.md +3 -0
  36. package/skills/pikku-n8n-import/references/code-translation.md +26 -22
  37. package/skills/pikku-n8n-import/references/loops-and-control.md +7 -7
  38. package/skills/pikku-paraglide/SKILL.md +11 -6
  39. package/skills/pikku-permissions/SKILL.md +19 -15
  40. package/skills/pikku-product-second-opinion/README.md +3 -3
  41. package/skills/pikku-product-second-opinion/example/sample-report.md +15 -12
  42. package/skills/pikku-product-second-opinion/references/report-template.md +10 -7
  43. package/skills/pikku-queue/SKILL.md +1 -1
  44. package/skills/pikku-react/SKILL.md +53 -13
  45. package/skills/pikku-realtime/SKILL.md +51 -19
  46. package/skills/pikku-rtl/SKILL.md +1 -1
  47. package/skills/pikku-scenario/SKILL.md +126 -14
  48. package/skills/pikku-schedule/SKILL.md +6 -1
  49. package/skills/pikku-schema-ajv/SKILL.md +2 -2
  50. package/skills/pikku-schema-cfworker/SKILL.md +1 -1
  51. package/skills/pikku-security/SKILL.md +9 -5
  52. package/skills/pikku-services/SKILL.md +27 -18
  53. package/skills/pikku-services/references/audit-wire-service.md +14 -8
  54. package/skills/pikku-software-archaeology/scripts/validate.mjs +146 -69
  55. package/skills/pikku-tag-middleware/SKILL.md +1 -0
  56. package/skills/pikku-template-clone/SKILL.md +2 -1
  57. package/skills/pikku-trigger/SKILL.md +3 -3
  58. package/skills/pikku-websocket/SKILL.md +4 -3
  59. package/skills/pikku-workflow/SKILL.md +2 -2
  60. package/skills/pikku-workflow/references/workflow-reference.md +24 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/skills",
3
- "version": "0.12.10",
3
+ "version": "0.12.11",
4
4
  "description": "The Pikku agent skills — the instruction set coding agents read to build, wire and deploy Pikku projects",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -26,11 +26,11 @@
26
26
  "skills"
27
27
  ],
28
28
  "devDependencies": {
29
- "@types/node": "^24.11.0",
29
+ "@types/node": "^24.13.3",
30
30
  "typescript": "^6.0.3",
31
- "yaml": "^2.8.2"
31
+ "yaml": "^2.9.0"
32
32
  },
33
33
  "engines": {
34
34
  "node": ">=24"
35
35
  }
36
- }
36
+ }
@@ -40,7 +40,7 @@ See `pikku-concepts` for the core mental model.
40
40
  Register an addon in the consuming project:
41
41
 
42
42
  ```typescript
43
- import { wireAddon } from '#pikku'
43
+ import { wireAddon } from '#pikku/addon'
44
44
 
45
45
  wireAddon({
46
46
  name: string, // Namespace for addon functions (e.g. 'todos')
@@ -123,7 +123,7 @@ Type-safe reference to a function — local or addon — for use in any wiring.
123
123
  returns a function config that proxies the call via RPC at runtime:
124
124
 
125
125
  ```typescript
126
- import { ref } from '#pikku'
126
+ import { ref } from '#pikku/function'
127
127
 
128
128
  ref('todos:addTodo') // namespace:functionName for an addon function
129
129
  ref('myLocalFunc') // a local function by name
@@ -134,7 +134,7 @@ There is no `addon()` helper; `ref()` covers both. For an addon that publishes
134
134
  `refChannel` and `refCLI`, which carry the addon's own route/config metadata:
135
135
 
136
136
  ```typescript
137
- import { refHTTP } from '#pikku'
137
+ import { refHTTP } from '#pikku/function'
138
138
 
139
139
  wireHTTP(refHTTP('todos:listTodos', { basePath: '/api' }))
140
140
  ```
@@ -146,7 +146,7 @@ second argument is always present — an addon never falls back to its own logge
146
146
  variables or secrets; the consuming app supplies them:
147
147
 
148
148
  ```typescript
149
- import { pikkuAddonServices } from '#pikku'
149
+ import { pikkuAddonServices } from '#pikku/addon/setup'
150
150
 
151
151
  export const createSingletonServices = pikkuAddonServices(
152
152
  async (config, { secrets, logger }) => {
@@ -167,7 +167,7 @@ config object.
167
167
  Define per-request services for an addon package (created fresh per HTTP request, queue job, etc.):
168
168
 
169
169
  ```typescript
170
- import { pikkuAddonWireServices } from '#pikku'
170
+ import { pikkuAddonWireServices } from '#pikku/addon/setup'
171
171
 
172
172
  export const createWireServices = pikkuAddonWireServices(
173
173
  async (singletonServices, wire) => {
@@ -195,7 +195,7 @@ This generates `package.json` (exports `.pikku/*` + `dist/`), `pikku.config.json
195
195
 
196
196
  ```typescript
197
197
  // src/services.ts
198
- import { pikkuAddonServices, pikkuAddonWireServices } from '#pikku'
198
+ import { pikkuAddonServices, pikkuAddonWireServices } from '#pikku/addon/setup'
199
199
  import { TodoStore } from './todo-store.service.js'
200
200
 
201
201
  export const createSingletonServices = pikkuAddonServices(async () => {
@@ -213,10 +213,14 @@ export const createWireServices = pikkuAddonWireServices(
213
213
 
214
214
  ### Functions
215
215
 
216
+ An addon generates its whole tree under `#pikku/addon/*`, so it authors against
217
+ `#pikku/addon/function`, `#pikku/addon/http` and so on. An application's leaves
218
+ stay flat, which is what stops a linked addon resolving against its host.
219
+
216
220
  ```typescript
217
221
  // src/functions/addTodo.function.ts
218
222
  import { z } from 'zod'
219
- import { pikkuSessionlessFunc } from '#pikku'
223
+ import { pikkuSessionlessFunc } from '#pikku/addon/function'
220
224
 
221
225
  const AddTodoInput = z.object({ title: z.string() })
222
226
  const AddTodoOutput = z.object({ id: z.string(), title: z.string() })
@@ -269,7 +273,7 @@ yarn add @my-org/addon-todos
269
273
 
270
274
  ```typescript
271
275
  // wirings/todos.wirings.ts
272
- import { wireAddon } from '#pikku'
276
+ import { wireAddon } from '#pikku/addon'
273
277
 
274
278
  wireAddon({ name: 'todos', package: '@my-org/addon-todos' })
275
279
  ```
@@ -290,7 +294,8 @@ export const myFunc = pikkuFunc({
290
294
  ### Wire to HTTP
291
295
 
292
296
  ```typescript
293
- import { wireHTTP, ref } from '#pikku'
297
+ import { wireHTTP } from '#pikku/http'
298
+ import { ref } from '#pikku/function'
294
299
 
295
300
  wireHTTP({
296
301
  method: 'get',
@@ -303,7 +308,8 @@ wireHTTP({
303
308
  Or batch multiple addon routes with `defineHTTPRoutes` + `wireHTTPRoutes`:
304
309
 
305
310
  ```typescript
306
- import { wireHTTPRoutes, defineHTTPRoutes, ref } from '#pikku'
311
+ import { wireHTTPRoutes, defineHTTPRoutes } from '#pikku/http'
312
+ import { ref } from '#pikku/function'
307
313
 
308
314
  const todoRoutes = defineHTTPRoutes({
309
315
  tags: ['todos'],
@@ -321,7 +327,7 @@ wireHTTPRoutes({ basePath: '/api', routes: { todos: todoRoutes } })
321
327
 
322
328
  ```typescript
323
329
  import { pikkuAgent } from '#pikku/agent/pikku-agent-types.gen.js'
324
- import { ref } from '#pikku'
330
+ import { ref } from '#pikku/function'
325
331
 
326
332
  export const todoAgent = pikkuAgent({
327
333
  name: 'todo-agent',
@@ -40,8 +40,8 @@ my-addon/
40
40
  {
41
41
  "name": "@my-org/addon-todos",
42
42
  "imports": {
43
- "#pikku": "./.pikku/pikku-types.gen.ts",
44
- "#pikku/*": "./.pikku/*"
43
+ "#pikku/*.js": "./.pikku/*.ts",
44
+ "#pikku/*": "./.pikku/*/index.ts"
45
45
  },
46
46
  "exports": {
47
47
  ".": { "types": "./dist/src/index.d.ts", "import": "./dist/src/index.js" },
@@ -41,7 +41,7 @@ Import it from the generated agent types file — `#pikku` does not re-export it
41
41
 
42
42
  ```typescript
43
43
  import { pikkuAgent } from '#pikku/agent/pikku-agent-types.gen.js'
44
- import { ref } from '#pikku/pikku-types.gen.js'
44
+ import { ref } from '#pikku/function'
45
45
 
46
46
  pikkuAgent({
47
47
  name: string, // Unique agent identifier
@@ -179,7 +179,7 @@ than folding it into the parent's transcript.
179
179
 
180
180
  ```typescript
181
181
  import { pikkuAgent } from '#pikku/agent/pikku-agent-types.gen.js'
182
- import { ref } from '#pikku/pikku-types.gen.js'
182
+ import { ref } from '#pikku/function'
183
183
 
184
184
  export const todoAgent = pikkuAgent({
185
185
  name: 'todo-agent',
@@ -283,7 +283,7 @@ export const completeTodo = pikkuFunc({
283
283
 
284
284
  // agents/todo-assistant.agent.ts
285
285
  import { pikkuAgent } from '#pikku/agent/pikku-agent-types.gen.js'
286
- import { ref } from '#pikku/pikku-types.gen.js'
286
+ import { ref } from '#pikku/function'
287
287
 
288
288
  export const todoAssistant = pikkuAgent({
289
289
  name: 'todo-assistant',
@@ -38,11 +38,13 @@ An event only persists when the function opts in with **`audit: true`** — othe
38
38
  ```typescript
39
39
  import { NoopAuditService, createInvocationAudit } from '@pikku/core/services'
40
40
 
41
- export const createSingletonServices = pikkuServices(async (config, existing) => {
42
- // Prod platforms may inject a queue-backed sink as existing.audit.
43
- const audit = existing?.audit ?? new NoopAuditService()
44
- return { ...existing, config, /* ... */ audit }
45
- })
41
+ export const createSingletonServices = pikkuServices(
42
+ async (config, existing) => {
43
+ // Prod platforms may inject a queue-backed sink as existing.audit.
44
+ const audit = existing?.audit ?? new NoopAuditService()
45
+ return { ...existing, config, /* ... */ audit }
46
+ }
47
+ )
46
48
 
47
49
  // auditLog is created per invocation from the sink. Returned unconditionally so
48
50
  // a write from a function that forgot `audit: true` warns instead of vanishing.
@@ -67,12 +69,17 @@ Mark the function `audit: true` and call `auditLog?.write(...)`. Domain history
67
69
 
68
70
  ```typescript
69
71
  export const cancelInvoice = pikkuFunc({
70
- audit: true, // REQUIRED — else write() is a no-op
72
+ audit: true, // REQUIRED — else write() is a no-op
71
73
  input: CancelInvoiceInput,
72
74
  output: CancelInvoiceOutput,
73
75
  func: async ({ kysely, auditLog }, { invoiceId }, { session }) => {
74
- const inv = await kysely.selectFrom('invoice')/* ... */.executeTakeFirstOrThrow()
75
- await kysely.updateTable('invoice').set({ status: 'cancelled' })/* ... */.execute()
76
+ const inv = await kysely
77
+ .selectFrom('invoice') /* ... */
78
+ .executeTakeFirstOrThrow()
79
+ await kysely
80
+ .updateTable('invoice')
81
+ .set({ status: 'cancelled' }) /* ... */
82
+ .execute()
76
83
 
77
84
  await auditLog?.write({
78
85
  type: 'invoice.update',
@@ -108,7 +115,10 @@ import { createAuditedKysely } from '@pikku/kysely'
108
115
  export const createWireServices = pikkuWireServices(async (services, wire) => {
109
116
  if (!services.audit) return {}
110
117
  const auditLog = createInvocationAudit(services.audit, wire)
111
- return { auditLog, kysely: createAuditedKysely(services.kysely, { audit: auditLog }) }
118
+ return {
119
+ auditLog,
120
+ kysely: createAuditedKysely(services.kysely, { audit: auditLog }),
121
+ }
112
122
  })
113
123
  ```
114
124
 
@@ -172,15 +182,20 @@ const rows = await kysely
172
182
 
173
183
  ```typescript
174
184
  type AuditEvent = {
175
- type: string // e.g. 'invoice.update'
185
+ type: string // e.g. 'invoice.update'
176
186
  source: 'auto' | 'explicit'
177
- occurredAt: string // auto-filled by auditLog
187
+ occurredAt: string // auto-filled by auditLog
178
188
  eventId?: string
179
189
  outcome?: 'success' | 'failed' | 'denied'
180
- functionId?; wireType?; wireId?; traceId?; transactionId?; queryId? // auto
190
+ functionId?
191
+ wireType?
192
+ wireId?
193
+ traceId?
194
+ transactionId?
195
+ queryId? // auto
181
196
  userIdentity?: { userId?; orgId?; pikkuUserId? } // auto from wire session
182
197
  input?: unknown
183
- metadata?: Record<string, unknown> // your domain payload
198
+ metadata?: Record<string, unknown> // your domain payload
184
199
  }
185
200
  ```
186
201
 
@@ -64,12 +64,12 @@ provision an S3 bucket per logical bucket — the config takes only one.
64
64
 
65
65
  ### Behaviours worth knowing before you rely on them
66
66
 
67
- - **`signURL` fails open.** A signing error is logged and the *unsigned* URL is
67
+ - **`signURL` fails open.** A signing error is logged and the _unsigned_ URL is
68
68
  returned rather than thrown. If your CloudFront distribution is private the
69
69
  client then gets a 403; if it isn't, you have just handed out an unrestricted
70
70
  link. Check that `signConfig` is a valid CloudFront key pair at boot.
71
71
  - **`signContentKey` builds `https://<bucketName>/<bucket>/<contentKey>`** — it
72
- uses `bucketName` as the *host*. For signed content the value must therefore be
72
+ uses `bucketName` as the _host_. For signed content the value must therefore be
73
73
  your CloudFront domain, not a plain bucket name, which also means the same
74
74
  config field is doing two jobs.
75
75
  - **Presigned upload URLs expire after a fixed 3600s.** It is not configurable
@@ -5,7 +5,8 @@ description: >-
5
5
  the generated catch-all auth routes, betterAuthSession middleware, OAuth/social providers,
6
6
  email+password credentials, database adapters, and session mapping. TRIGGER when: code uses
7
7
  pikkuBetterAuth, betterAuth, betterAuthSession, createAuthHandler, user asks about Better Auth,
8
- OAuth/social providers, MFA, organizations, login/logout, or @pikku/better-auth. TRIGGER when:
8
+ OAuth/social providers, MFA, organizations, login/logout, or @pikku/better-auth. TRIGGER when: user asks
9
+ about the actor plugin, /sign-in/actor, signing in as a scenario persona, or SCENARIO_ACTOR_SECRET. TRIGGER when:
9
10
  user asks about ANY form of authentication, login, logout, sessions, or user identity — always
10
11
  answer with this skill. DO NOT TRIGGER when: user asks about JWT middleware (use pikku-security)
11
12
  or custom session services (use pikku-services).
@@ -146,6 +147,11 @@ strings):
146
147
  | delete a user and their data | `admin:users:remove` |
147
148
  | revoke a user's sessions | `admin:users:sessions` |
148
149
  | set a user's password | `admin:users:password` |
150
+ | read credential values and who holds them | `admin:credentials:read` |
151
+ | set and delete credentials | `admin:credentials:manage` |
152
+ | view declared scopes, roles, and who holds them | `admin:scopes:read` |
153
+ | create roles, change their scopes, grant them | `admin:scopes:manage` |
154
+ | read the audit trail | `admin:audit:read` |
149
155
 
150
156
  Holding the bare `admin` scope satisfies all of them — a parent grant covers
151
157
  everything nested beneath it — so `admin` is the direct replacement for the old
@@ -166,6 +172,8 @@ defineScope({
166
172
  description: 'Application-wide credentials',
167
173
  scopes: {
168
174
  link: { description: 'Bind a shared credential for every user' },
175
+ read: { description: 'Read credential values and who holds them' },
176
+ manage: { description: 'Set and delete credentials' },
169
177
  },
170
178
  },
171
179
  users: {
@@ -179,6 +187,27 @@ defineScope({
179
187
  password: { description: "Set a user's password" },
180
188
  },
181
189
  },
190
+ scopes: {
191
+ description: 'Authorization management',
192
+ scopes: {
193
+ read: {
194
+ description: 'View declared scopes, roles, and who holds them',
195
+ },
196
+ manage: {
197
+ description:
198
+ 'Create and delete roles, change their scopes, and grant roles to users',
199
+ },
200
+ },
201
+ },
202
+ audit: {
203
+ description: 'The audit trail',
204
+ scopes: {
205
+ read: {
206
+ description:
207
+ 'Read the audit trail — every recorded action, and which user took it',
208
+ },
209
+ },
210
+ },
182
211
  },
183
212
  },
184
213
  })
@@ -191,22 +220,32 @@ a scope, so nothing is authorized, and the denial is logged at `warn` because
191
220
  that is a configuration bug rather than a permissions decision. Pass your own
192
221
  `canImpersonate` / `canLinkSingleton` to override the default entirely.
193
222
 
194
- ### If you do wire better-auth's `admin()` plugin
223
+ ### Do not wire better-auth's `admin()` plugin
224
+
225
+ Every capability in the table is pikku's own, gated by the scope next to it and
226
+ implemented against better-auth's internal adapter — `createAuthUser`,
227
+ `setAuthUserPassword`, `setAuthUserBanned`, `deleteAuthUser` and
228
+ `revokeAuthUserSessions`, all exported from `@pikku/better-auth`.
195
229
 
196
- The last five capabilities in the table are implemented by better-auth's own
197
- `admin()` endpoints, which authorize against `user.role` — a column pikku
198
- otherwise ignores. Rather than making you maintain two grant systems,
199
- `syncProjectedAdminRole` keeps that column as a *projection* of the scope set:
200
- at the session boundary it writes `role = 'admin'` when the user holds any of
201
- `admin:users:{create,ban,remove,sessions,password}`, and the plugin's
202
- `defaultRole` otherwise. `projectedAdminRole(scopes, defaultRole)` computes the
203
- value if you need it yourself.
230
+ `admin()` would add a second gate on a `user.role` column that pikku otherwise
231
+ ignores, which means maintaining two grant systems that have to agree — and the
232
+ column loses, since the scope store is what the rest of the framework reads.
233
+ Pikku used to project scopes onto it for exactly that reason; dropping the
234
+ plugin dropped the projection with it.
204
235
 
205
- The projection is deliberately not "any `admin:*` scope": `impersonate` and
206
- `users:list` are pikku's own gates, and rolling them in would hand ban and delete
207
- rights to someone granted only the ability to look. The plugin is auto-detected
208
- from the live instance, so an app without it never writes to a column that does
209
- not exist.
236
+ Banning is the one capability with a schema requirement, and it has its own
237
+ small plugin:
238
+
239
+ ```typescript
240
+ import { ban } from '@pikku/better-auth'
241
+
242
+ betterAuth({ plugins: [ban()] })
243
+ ```
244
+
245
+ `ban()` adds `banned`, `banReason` and `banExpires` to `user` and refuses to
246
+ create a session for a banned user, lapsing an expired ban as it goes. It makes
247
+ no authorization decision — who may ban is decided by `admin:users:ban` — so it
248
+ never needs to know about scopes or roles.
210
249
 
211
250
  ### 2. Production database adapter
212
251
 
@@ -273,7 +312,7 @@ export const auth = pikkuBetterAuth(async ({ secrets, variables }) => {
273
312
  Functions that require a session use `pikkuFunc` — anonymous callers are rejected automatically. `betterAuthSession` has already bridged better-auth's session into `session`:
274
313
 
275
314
  ```typescript
276
- import { pikkuFunc } from '#pikku'
315
+ import { pikkuFunc } from '#pikku/function'
277
316
 
278
317
  export const me = pikkuFunc({
279
318
  expose: true,
@@ -311,11 +350,52 @@ The session cookie is `better-auth.session_token` (dev) / `__Secure-better-auth.
311
350
 
312
351
  Set `PIKKU_DEV_QUICK_LOGIN=true` and `${basePath}/dev/quick-login` signs in a
313
352
  fixed dev admin (`admin@pikku.dev`), creating the user idempotently and granting
314
- it the bare `admin` scope. It is guarded twice — the env var *and* a localhost
353
+ it the bare `admin` scope. It is guarded twice — the env var _and_ a localhost
315
354
  hostname check — because a one-request path to an admin session is exactly the
316
355
  thing that must not survive a deploy. An app that has not declared the `admin`
317
356
  scope still gets a session, with a warning, since a scopeless dev user is useful.
318
357
 
358
+ ### Actor sign-in (`actor` plugin)
359
+
360
+ A different thing from dev quick login, and the one to reach for when "sign in as
361
+ someone" means **a particular kind of user** rather than one fixed admin.
362
+ Register it explicitly — it is not automatic:
363
+
364
+ ```typescript
365
+ import { actor } from '@pikku/better-auth'
366
+
367
+ plugins: [actor({ secret: SCENARIO_ACTOR_SECRET })]
368
+ ```
369
+
370
+ `POST ${basePath}/sign-in/actor` `{ email, secret, name? }` → 200 + the normal
371
+ session cookie. `secret` may also be a (possibly async) function, so it can come
372
+ off the secrets service instead of a captured value.
373
+
374
+ **`SCENARIO_ACTOR_SECRET` is a credential as powerful as the most privileged
375
+ persona.** `pikku persona sync` grants declared roles to actor accounts, so an
376
+ `admin` persona is an actor holding real admin — anyone with the secret can take
377
+ a session as one. Keep the endpoint **off outside development and sandbox
378
+ deployments**: leave the secret unset on any stage that should not run scenarios,
379
+ which is the supported switch (`Actor sign-in is not configured`), rather than
380
+ conditionally registering the plugin. Do not treat "actors only" as a licence to
381
+ enable it in production.
382
+
383
+ Within that boundary, three properties bound the damage:
384
+
385
+ - **It only ever signs in actors.** The plugin adds a `user.actor` boolean
386
+ column; an email matching a row without it is refused with `User is not an
387
+ actor`. So the secret cannot take over a **real user's** account — the blast
388
+ radius is the actor accounts and whatever roles they were granted.
389
+ - **Unknown emails are created**, flagged `actor: true`, so a scenario that
390
+ declares a new persona needs no seed step. Note the flip side: the secret
391
+ mints accounts, it does not merely use existing ones.
392
+ - **The comparison is constant-time and length-hiding**, so a wrong secret leaks
393
+ neither the length nor a prefix of the right one.
394
+
395
+ This is the endpoint `pikku scenario` signs its actors in through, and the one
396
+ the frontend dev switcher posts to — see `pikku-scenario` for declaring the
397
+ actors and `pikku-react` for `useDevActors()`.
398
+
319
399
  ---
320
400
 
321
401
  ## Secret Management