@pikku/core 0.12.98 → 0.12.100

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 (38) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/dist/services/http-personas.d.ts +16 -5
  3. package/dist/services/http-personas.js +7 -3
  4. package/dist/services/index.d.ts +1 -0
  5. package/dist/services/index.js +1 -0
  6. package/dist/services/persona-actor-secret.d.ts +38 -0
  7. package/dist/services/persona-actor-secret.js +39 -0
  8. package/dist/services/persona-sign-in.d.ts +23 -1
  9. package/dist/services/persona-sign-in.js +25 -1
  10. package/dist/services/typed-secret-service.js +4 -1
  11. package/dist/wirings/agent-scorer/agent-scorer.d.ts +14 -0
  12. package/dist/wirings/gateway/gateway.types.d.ts +13 -0
  13. package/dist/wirings/persona/index.d.ts +2 -1
  14. package/dist/wirings/persona/index.js +2 -1
  15. package/dist/wirings/secret/secret.types.d.ts +8 -0
  16. package/knowledge/decisions/internals/a-virtual-user-cadence-is-a-row-not-a-timer.md +1 -1
  17. package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-but-it-needs-a-trigger.md +65 -0
  18. package/knowledge/decisions/internals/index.md +1 -1
  19. package/knowledge/decisions/security/actor-sign-in-only-works-for-actor-flagged-users.md +19 -15
  20. package/knowledge/decisions/security/an-actor-credential-is-derived-per-persona.md +41 -0
  21. package/knowledge/decisions/security/index.md +2 -1
  22. package/package.json +4 -4
  23. package/src/public-surface.json +13 -0
  24. package/src/services/http-personas-converse.test.ts +3 -3
  25. package/src/services/http-personas.test.ts +92 -5
  26. package/src/services/http-personas.ts +27 -6
  27. package/src/services/index.ts +8 -0
  28. package/src/services/persona-actor-secret.test.ts +68 -0
  29. package/src/services/persona-actor-secret.ts +70 -0
  30. package/src/services/persona-sign-in.ts +36 -2
  31. package/src/services/typed-secret-service.test.ts +26 -1
  32. package/src/services/typed-secret-service.ts +4 -1
  33. package/src/wirings/agent-scorer/agent-scorer.ts +14 -0
  34. package/src/wirings/gateway/gateway.types.ts +20 -1
  35. package/src/wirings/persona/index.ts +10 -0
  36. package/src/wirings/secret/secret.types.ts +8 -0
  37. package/tsconfig.tsbuildinfo +1 -1
  38. package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +0 -53
@@ -1,27 +1,31 @@
1
1
  ---
2
2
  type: decision
3
3
  title: Actor sign-in only works for actor-flagged users
4
- description: The scenario actor secret mints sessions for user rows flagged actor and nothing else, so holding it never impersonates a real user
4
+ description: An actor credential mints sessions for user rows flagged actor and nothing else, so holding one never impersonates a real user
5
5
  tags: services
6
6
  ---
7
7
 
8
8
  # Actor sign-in only works for actor-flagged users
9
9
 
10
- `HttpScenarioActorsConfig.secret`
11
- (`packages/core/src/services/http-scenario-actors.ts`) is a shared impersonation
12
- secret: `HttpScenarioActor.login` POSTs `{ email, name, secret }` to
10
+ `HttpPersonasConfig.secret` (`packages/core/src/services/http-personas.ts`) is
11
+ what `ActorSignIn.login` presents: it POSTs `{ email, name, secret }` to
13
12
  `/auth/sign-in/actor` and gets back a session. That looks like a master key, and
14
- it deliberately is not one.
13
+ it deliberately is not one — for two independent reasons.
15
14
 
16
- The Better Auth actor plugin on the other end upserts and signs in only user rows
17
- flagged `actor: true`. Presenting the secret with a real customer's email does not
18
- mint that customer's session — it is refused. The `actor` flag also flows into the
19
- minted session, so audits and analytics can tell scenario traffic from human
20
- traffic after the fact. The blast radius of a leaked actor secret is therefore the
21
- synthetic actor population, not the user table.
15
+ The first is the flag. The Better Auth actor plugin on the other end upserts and
16
+ signs in only user rows flagged `actor: true`. Presenting a credential with a
17
+ real customer's email does not mint that customer's session — it is refused. The
18
+ `actor` flag also flows into the minted session, so audits and analytics can tell
19
+ scenario traffic from human traffic after the fact.
20
+
21
+ The second is that a credential is not shared. What is presented is derived from
22
+ the root `SCENARIO_ACTOR_SECRET` and the address it signs in as — see
23
+ [an actor credential is derived per persona](an-actor-credential-is-derived-per-persona.md) —
24
+ so the blast radius of a leaked credential is one synthetic account, not the
25
+ synthetic actor population.
22
26
 
23
27
  **What this rules out:** widening the sign-in endpoint to accept any email "so
24
- scenarios can test as a real user", and treating the actor secret as equivalent to
25
- a session-signing key. It also rules out dropping the `actor` flag from the minted
26
- session — the audit trail's ability to separate synthetic from real activity
27
- depends on it.
28
+ scenarios can test as a real user", and treating an actor credential as
29
+ equivalent to a session-signing key. It also rules out dropping the `actor` flag
30
+ from the minted session — the audit trail's ability to separate synthetic from
31
+ real activity depends on it.
@@ -0,0 +1,41 @@
1
+ ---
2
+ type: decision
3
+ title: An actor credential is derived per persona
4
+ description: What a caller presents to the actor endpoint is HKDF-derived from the root secret and the address it signs in as, so one credential opens one persona
5
+ tags: services
6
+ ---
7
+
8
+ # An actor credential is derived per persona
9
+
10
+ `SCENARIO_ACTOR_SECRET` is a root, not a password. What a caller presents to
11
+ `/auth/sign-in/actor` is `deriveActorSecret(root, email)`
12
+ (`packages/core/src/services/persona-actor-secret.ts`) — an HKDF-expanded
13
+ HMAC-SHA256 over the lowercased address, on the same key-material primitives
14
+ everything else in core signs with. The endpoint does not store or look anything
15
+ up: it re-derives the expected value for whichever address is being signed in as
16
+ and compares. A credential minted for one persona is refused for every other.
17
+
18
+ The root itself is not accepted as a credential, and a root shorter than 32
19
+ characters refuses the endpoint outright rather than deriving weak credentials
20
+ from it. The server-side warning names the problem; what the client is told does
21
+ not.
22
+
23
+ Derivation rather than a per-persona secrets table because there is then nothing
24
+ to store, provision, or keep in sync — the target already holds the root, and
25
+ rotating it invalidates every credential at once.
26
+
27
+ This is what lets a holder be handed less than everything:
28
+
29
+ - The browser switcher gets `VITE_DEV_ACTOR_SECRETS`, one credential per
30
+ declared persona. The root stays on the dev server, so a bundle can no longer
31
+ hold the thing that is entitled to every persona.
32
+ - A run can be given `PIKKU_PERSONA_SECRETS` (`id=secret,…`, minted with
33
+ `pikku persona secret`) instead of the root, and then it can sign in as those
34
+ personas and no others. Asking for one outside the list throws naming the
35
+ persona rather than falling back to the root.
36
+
37
+ **What this rules out:** accepting the root as a credential at the endpoint,
38
+ putting the root in any client bundle, and comparing a presented credential
39
+ against a stored one. It also rules out per-persona secrets that are generated
40
+ randomly and written down — the derivation is the reason there is nothing to
41
+ provision.
@@ -19,7 +19,8 @@ A rule about who may do what, and which way it fails when it is unsure.
19
19
  - [A workflow run is read and approved by its owner](a-workflow-run-is-read-and-approved-by-its-owner.md) — A run started through a session records that user and only that user may read it or answer its approval gates; a run with no recorded owner has no ownership to enforce
20
20
  - [An actor's missing approval decision defaults to denied](actor-flow-missing-approval-decisions-default-to-denied.md) — Every pending tool call gets an explicit decision; an id the persona LLM omitted is denied, so a dropped field can never read as consent
21
21
  - [Actor sign-in is proven by Set-Cookie, not a non-empty jar](actor-sign-in-is-proven-by-set-cookie-not-a-non-empty-jar.md) — HttpScenarioActor tracks its own signedIn flag and requires the sign-in response itself to set a cookie, because a populated jar proves nothing
22
- - [Actor sign-in only works for actor-flagged users](actor-sign-in-only-works-for-actor-flagged-users.md) — The scenario actor secret mints sessions for user rows flagged actor and nothing else, so holding it never impersonates a real user
22
+ - [Actor sign-in only works for actor-flagged users](actor-sign-in-only-works-for-actor-flagged-users.md) — An actor credential mints sessions for user rows flagged actor and nothing else, so holding one never impersonates a real user
23
+ - [An actor credential is derived per persona](an-actor-credential-is-derived-per-persona.md) — What a caller presents to the actor endpoint is HKDF-derived from the root secret and the address it signs in as, so one credential opens one persona
23
24
  - [Addon auth and tags only tighten, and resolve where the function runs](addon-auth-and-tags-only-tighten.md) — wireAddon auth and tags are applied in runPikkuFunc like scopes, but auth:false is ignored and tags resolve against the consuming app's tag groups rather than the addon package's
24
25
  - [Addon auth and tag gates apply wherever the function runs, including inside the addon](addon-config-gates-apply-only-at-the-namespaced-rpc-boundary.md) — wireAddon's auth and tags moved from the namespaced RPC boundary into runPikkuFunc, so they also apply to direct wirings and to bare intra-addon calls
25
26
  - [Addon scopes are resolved where the function runs](addon-scopes-are-resolved-where-the-function-runs.md) — wireAddon scopes are merged inside runPikkuFunc rather than at namespace resolution, because most wirings reach an addon function without ever resolving a namespace
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.98",
3
+ "version": "0.12.100",
4
4
  "description": "The Pikku runtime — functions, wirings, services, middleware and types",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -18,12 +18,12 @@
18
18
  },
19
19
  "sideEffects": [
20
20
  "./dist/errors/errors.js",
21
- "./dist/wirings/rpc/rpc-runner.js",
22
21
  "./dist/wirings/addon/remote-addon-auth.js",
22
+ "./dist/wirings/rpc/rpc-runner.js",
23
+ "./dist/wirings/workflow/pikku-scenario-service.js",
23
24
  "./dist/wirings/workflow/workflow-approval-policy.js",
24
25
  "./dist/wirings/workflow/workflow-errors.js",
25
- "./dist/wirings/workflow/workflow-run-ownership.js",
26
- "./dist/wirings/workflow/pikku-scenario-service.js"
26
+ "./dist/wirings/workflow/workflow-run-ownership.js"
27
27
  ],
28
28
  "exports": {
29
29
  ".": "./dist/bootstrap-compat/root.js",
@@ -289,15 +289,21 @@
289
289
  "validateAndBuildSystemRoleDefinitionsMeta"
290
290
  ],
291
291
  "./persona": [
292
+ "ACTOR_ROOT_SECRET_MIN_LENGTH",
293
+ "ACTOR_SECRET_INFO",
294
+ "ACTOR_SECRET_NAME",
292
295
  "APP_SCOPE_ROOT",
293
296
  "ActorSignIn",
294
297
  "HttpPersona",
295
298
  "IMPERSONATE_USER_ID_HEADER",
296
299
  "OperatorSignIn",
300
+ "actorSecretSubject",
297
301
  "appScopeId",
302
+ "authMount",
298
303
  "buildAppScopeDefinition",
299
304
  "createHttpPersonas",
300
305
  "definePersonas",
306
+ "deriveActorSecret",
301
307
  "establishOperatorSession",
302
308
  "isRunnablePersona",
303
309
  "personaEmail",
@@ -309,6 +315,7 @@
309
315
  "roleMismatchMessage",
310
316
  "runnablePersonas",
311
317
  "validateAndBuildPersonasMeta",
318
+ "verifyActorSecret",
312
319
  "verifyPersonaRoles"
313
320
  ],
314
321
  "./secret": ["defineSecret", "validateAndBuildSecretDefinitionsMeta"],
@@ -364,6 +371,9 @@
364
371
  "isExpectedError"
365
372
  ],
366
373
  "./services": [
374
+ "ACTOR_ROOT_SECRET_MIN_LENGTH",
375
+ "ACTOR_SECRET_INFO",
376
+ "ACTOR_SECRET_NAME",
367
377
  "ConsoleLogger",
368
378
  "DEFAULT_WEBHOOK_RETRIES",
369
379
  "FileScenarioRunStore",
@@ -394,10 +404,12 @@
394
404
  "TypedSecretService",
395
405
  "TypedVariablesService",
396
406
  "WebhookService",
407
+ "actorSecretSubject",
397
408
  "assertSecretAllowedForHost",
398
409
  "createInvocationAudit",
399
410
  "createMiddlewareSessionWireProps",
400
411
  "createStubProxy",
412
+ "deriveActorSecret",
401
413
  "getStubTracker",
402
414
  "isTestRun",
403
415
  "pikkuWebhookWorkerFunc",
@@ -406,6 +418,7 @@
406
418
  "scenarioRunSummary",
407
419
  "spy",
408
420
  "stub",
421
+ "verifyActorSecret",
409
422
  "withoutSecrets"
410
423
  ],
411
424
  "./services/local-meta": ["LocalMetaService"],
@@ -161,7 +161,7 @@ describe('HttpPersona.converse', async () => {
161
161
 
162
162
  const actors = createHttpPersonas({
163
163
  apiUrl: target.apiUrl,
164
- secret: 'impersonation-secret',
164
+ secret: 'impersonation-secret-impersonation',
165
165
  model: 'test/test-model',
166
166
  personas: {
167
167
  pm: {
@@ -204,7 +204,7 @@ describe('HttpPersona.converse', async () => {
204
204
 
205
205
  const actors = createHttpPersonas({
206
206
  apiUrl: target.apiUrl,
207
- secret: 'impersonation-secret',
207
+ secret: 'impersonation-secret-impersonation',
208
208
  model: 'test/test-model',
209
209
  personas: {
210
210
  pm: {
@@ -243,7 +243,7 @@ describe('HttpPersona.converse', async () => {
243
243
 
244
244
  const actors = createHttpPersonas({
245
245
  apiUrl: target.apiUrl,
246
- secret: 'impersonation-secret',
246
+ secret: 'impersonation-secret-impersonation',
247
247
  model: 'test/test-model',
248
248
  personas: {
249
249
  pm: {
@@ -3,6 +3,10 @@ import assert from 'node:assert/strict'
3
3
  import { createServer, type Server } from 'node:http'
4
4
 
5
5
  import { createHttpPersonas } from './http-personas.js'
6
+ import { verifyActorSecret } from './persona-actor-secret.js'
7
+
8
+ /** The root the personas derive from; the target verifies against the same one. */
9
+ const ROOT = 'impersonation-secret-impersonation'
6
10
 
7
11
  // Minimal target app mirroring the Better Auth actor plugin's contract:
8
12
  // sign-in endpoint, exposed RPC endpoint, session by cookie.
@@ -12,12 +16,12 @@ const startTarget = async () => {
12
16
  const server: Server = createServer((req, res) => {
13
17
  const chunks: Buffer[] = []
14
18
  req.on('data', (c) => chunks.push(c))
15
- req.on('end', () => {
19
+ req.on('end', async () => {
16
20
  const body = chunks.length
17
21
  ? JSON.parse(Buffer.concat(chunks).toString())
18
22
  : {}
19
23
  if (req.url === '/api/auth/sign-in/actor') {
20
- if (body.secret !== 'impersonation-secret') {
24
+ if (!(await verifyActorSecret(ROOT, body.email, body.secret))) {
21
25
  res
22
26
  .writeHead(401)
23
27
  .end(JSON.stringify({ message: 'bad actor secret' }))
@@ -31,6 +35,29 @@ const startTarget = async () => {
31
35
  res.writeHead(200).end(JSON.stringify({ ok: true, email: body.email }))
32
36
  return
33
37
  }
38
+ if (req.url === '/api/auth/sign-in/fabric') {
39
+ if (body.token !== 'operator-token') {
40
+ res.writeHead(401).end(JSON.stringify({ message: 'bad operator' }))
41
+ return
42
+ }
43
+ logins++
44
+ res.setHeader('set-cookie', [`session=s${logins}; Path=/; HttpOnly`])
45
+ res
46
+ .writeHead(200)
47
+ .end(JSON.stringify({ actAs: { userId: `u-${body.actAs.email}` } }))
48
+ return
49
+ }
50
+ if (req.url === '/api/auth/get-session') {
51
+ const cookie = req.headers.cookie ?? ''
52
+ if (!cookie.includes('session=')) {
53
+ res.writeHead(200).end('null')
54
+ return
55
+ }
56
+ res
57
+ .writeHead(200, { 'content-type': 'application/json' })
58
+ .end(JSON.stringify({ user: { role: 'admin,support' } }))
59
+ return
60
+ }
34
61
  if (req.url?.startsWith('/api/rpc/')) {
35
62
  const cookie = req.headers.cookie ?? ''
36
63
  if (!cookie.includes('session=') || expireNext) {
@@ -59,6 +86,7 @@ const startTarget = async () => {
59
86
  echoed: body.data,
60
87
  cookie,
61
88
  userHeader: req.headers['x-user-id'] ?? null,
89
+ impersonated: req.headers['x-pikku-impersonate-user-id'] ?? null,
62
90
  })
63
91
  )
64
92
  return
@@ -70,6 +98,7 @@ const startTarget = async () => {
70
98
  const { port } = server.address() as { port: number }
71
99
  return {
72
100
  server,
101
+ origin: `http://127.0.0.1:${port}`,
73
102
  apiUrl: `http://127.0.0.1:${port}/api`,
74
103
  loginCount: () => logins,
75
104
  expireSession: () => {
@@ -82,7 +111,9 @@ describe('HttpPersona', async () => {
82
111
  const target = await startTarget()
83
112
  after(() => target.server.close())
84
113
 
85
- const makePersonas = (secret = 'impersonation-secret') =>
114
+ const makePersonas = (
115
+ secret: Parameters<typeof createHttpPersonas>[0]['secret'] = ROOT
116
+ ) =>
86
117
  createHttpPersonas({
87
118
  apiUrl: target.apiUrl,
88
119
  secret,
@@ -216,11 +247,67 @@ describe('HttpPersona', async () => {
216
247
  )
217
248
  })
218
249
 
219
- test('a wrong impersonation secret surfaces status and body', async () => {
220
- const actors = makePersonas('wrong-secret')
250
+ // A caller holding one persona's credential and asking for another gets the
251
+ // target's refusal, not a client-side guess about whether it would have worked.
252
+ test('a credential the target will not accept surfaces status and body', async () => {
253
+ const actors = makePersonas(() => 'not-this-personas-credential')
221
254
  await assert.rejects(
222
255
  actors.customer!.invoke('ping', {}),
223
256
  /persona sign-in failed for 'customer' \(401\).*bad actor secret/
224
257
  )
225
258
  })
259
+
260
+ // An app whose auth is under `/api` but whose RPCs are not cannot put the
261
+ // mount in `apiUrl`, so it moves `signInPath` instead. The session read has
262
+ // to follow it: a 404 there reads as 'this stage does not report roles' and
263
+ // silently turns the role check off.
264
+ test('reads the session from the mount the sign-in path names', async () => {
265
+ const actors = createHttpPersonas({
266
+ apiUrl: target.origin,
267
+ secret: ROOT,
268
+ signInPath: '/api/auth/sign-in/actor',
269
+ rpcPath: '/api/rpc',
270
+ personas: {
271
+ manager: {
272
+ id: 'manager',
273
+ name: 'Manager',
274
+ email: 'manager@personas.invalid',
275
+ roles: ['admin'],
276
+ goals: [],
277
+ tags: [],
278
+ runnable: true,
279
+ },
280
+ },
281
+ })
282
+
283
+ assert.deepEqual(await actors.manager!.sessionRoles(), ['admin', 'support'])
284
+ })
285
+
286
+ // The operator handshake sits under the same mount and must be derived from
287
+ // it, not inherited verbatim: posting an operator token to the ACTOR path is
288
+ // a validation error about a missing email, which reads like a broken
289
+ // persona rather than a wrong URL.
290
+ test('derives the operator sign-in path from the same auth mount', async () => {
291
+ const actors = createHttpPersonas({
292
+ apiUrl: target.origin,
293
+ signInPath: '/api/auth/sign-in/actor',
294
+ rpcPath: '/api/rpc',
295
+ operator: { token: 'operator-token' },
296
+ personas: {
297
+ manager: {
298
+ id: 'manager',
299
+ name: 'Manager',
300
+ email: 'manager@personas.invalid',
301
+ roles: ['admin'],
302
+ goals: [],
303
+ tags: [],
304
+ runnable: true,
305
+ },
306
+ },
307
+ })
308
+
309
+ const result = (await actors.manager!.invoke('ping', {})) as any
310
+ assert.equal(result.rpcName, 'ping')
311
+ assert.equal(result.impersonated, 'u-manager@personas.invalid')
312
+ })
226
313
  })
@@ -18,9 +18,11 @@ import {
18
18
  } from '../wirings/workflow/scenario-cookie-jar.js'
19
19
  import {
20
20
  ActorSignIn,
21
+ type ActorSecretResolver,
21
22
  OperatorSignIn,
22
23
  type OperatorSignInOptions,
23
24
  type PersonaSignIn,
25
+ authMount,
24
26
  } from './persona-sign-in.js'
25
27
  import { getSingletonServices } from '../pikku-state.js'
26
28
  import { AIProviderNotConfiguredError } from '../errors/errors.js'
@@ -34,13 +36,19 @@ export interface HttpPersonasConfig {
34
36
  */
35
37
  apiUrl: string
36
38
  /**
37
- * The impersonation secret. Sign-in only ever works for user rows flagged
38
- * `actor: true` knowing the secret never impersonates real users.
39
+ * The ROOT actor secret, from which each persona's own credential is derived
40
+ * and bound to their address. Sign-in only ever works for user rows flagged
41
+ * `actor: true`, and a derived credential only ever works for the one address
42
+ * it was derived for.
43
+ *
44
+ * Pass an {@link ActorSecretResolver} instead to drive personas whose
45
+ * credentials were minted elsewhere — a caller entitled to one persona then
46
+ * never holds the root.
39
47
  *
40
48
  * The local-development credential. A deployed stage has none, and passes
41
49
  * {@link HttpPersonasConfig.operator} instead.
42
50
  */
43
- secret?: string
51
+ secret?: string | ActorSecretResolver
44
52
  /**
45
53
  * Fabric operator credentials, for signing personas into a DEPLOYED stage.
46
54
  *
@@ -58,7 +66,12 @@ export interface HttpPersonasConfig {
58
66
  * {@link OperatorSignInOptions.signInPath} overrides it.
59
67
  */
60
68
  signInPath?: string
61
- /** Where the session (and its roles) is read back. Default `/auth/get-session`. */
69
+ /**
70
+ * Where the session (and its roles) is read back. Defaults to `get-session`
71
+ * under the same auth mount as {@link HttpPersonasConfig.signInPath}, so an
72
+ * app that moved auth under `/api` moves this with it and does not have to
73
+ * say so twice.
74
+ */
62
75
  sessionPath?: string
63
76
  /** Exposed-RPC path prefix under apiUrl. Default `/rpc`. */
64
77
  rpcPath?: string
@@ -99,7 +112,11 @@ export class HttpPersona implements ScenarioPersona {
99
112
  if (config.operator) {
100
113
  this.signIn = new OperatorSignIn(config.apiUrl, {
101
114
  ...config.operator,
102
- signInPath: config.operator.signInPath ?? config.signInPath,
115
+ signInPath:
116
+ config.operator.signInPath ??
117
+ (authMount(config.signInPath)
118
+ ? `${authMount(config.signInPath)}/sign-in/fabric`
119
+ : undefined),
103
120
  })
104
121
  } else if (config.secret) {
105
122
  this.signIn = new ActorSignIn(
@@ -200,7 +217,11 @@ export class HttpPersona implements ScenarioPersona {
200
217
  if (!this.signedIn) {
201
218
  await this.login()
202
219
  }
203
- const sessionPath = this.config.sessionPath ?? '/auth/get-session'
220
+ const mount = authMount(
221
+ this.config.operator?.signInPath ?? this.config.signInPath
222
+ )
223
+ const sessionPath =
224
+ this.config.sessionPath ?? `${mount ?? '/auth'}/get-session`
204
225
  const res = await this.jar.fetch(`${this.config.apiUrl}${sessionPath}`, {
205
226
  headers: this.signIn.headers(),
206
227
  })
@@ -73,6 +73,14 @@ export {
73
73
  type WebhookJobData,
74
74
  type WebhookServiceConfig,
75
75
  } from './webhook-service.js'
76
+ export {
77
+ ACTOR_ROOT_SECRET_MIN_LENGTH,
78
+ ACTOR_SECRET_INFO,
79
+ ACTOR_SECRET_NAME,
80
+ actorSecretSubject,
81
+ deriveActorSecret,
82
+ verifyActorSecret,
83
+ } from './persona-actor-secret.js'
76
84
  export type { Logger } from './logger.js'
77
85
  export type { SecretService, SecretValues } from './secret-service.js'
78
86
  export type { VariablesService } from './variables-service.js'
@@ -0,0 +1,68 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, test } from 'node:test'
3
+
4
+ import {
5
+ ACTOR_ROOT_SECRET_MIN_LENGTH,
6
+ deriveActorSecret,
7
+ verifyActorSecret,
8
+ } from './persona-actor-secret.js'
9
+
10
+ const ROOT = 'root-secret-root-secret-root-secret'
11
+ const OTHER_ROOT = 'other-secret-other-secret-other-sec'
12
+
13
+ describe('actor credentials', () => {
14
+ test('a credential verifies for its own address', async () => {
15
+ const secret = await deriveActorSecret(ROOT, 'susan@actors.local')
16
+ assert.equal(
17
+ await verifyActorSecret(ROOT, 'susan@actors.local', secret),
18
+ true
19
+ )
20
+ })
21
+
22
+ test('and for no other address', async () => {
23
+ const secret = await deriveActorSecret(ROOT, 'susan@actors.local')
24
+ assert.equal(
25
+ await verifyActorSecret(ROOT, 'yasser@actors.local', secret),
26
+ false
27
+ )
28
+ })
29
+
30
+ test('the address is matched the way the row is looked up', async () => {
31
+ const secret = await deriveActorSecret(ROOT, ' Susan@Actors.Local ')
32
+ assert.equal(
33
+ await verifyActorSecret(ROOT, 'susan@actors.local', secret),
34
+ true
35
+ )
36
+ })
37
+
38
+ test('rotating the root invalidates every credential at once', async () => {
39
+ const secret = await deriveActorSecret(ROOT, 'susan@actors.local')
40
+ assert.equal(
41
+ await verifyActorSecret(OTHER_ROOT, 'susan@actors.local', secret),
42
+ false
43
+ )
44
+ })
45
+
46
+ test('the root is not a credential for anybody', async () => {
47
+ assert.equal(
48
+ await verifyActorSecret(ROOT, 'susan@actors.local', ROOT),
49
+ false
50
+ )
51
+ })
52
+
53
+ test('a malformed value is false rather than a throw', async () => {
54
+ assert.equal(await verifyActorSecret(ROOT, 'susan@actors.local', ''), false)
55
+ assert.equal(
56
+ await verifyActorSecret(ROOT, 'susan@actors.local', 'not base64url!!'),
57
+ false
58
+ )
59
+ })
60
+
61
+ test('key material shorter than the minimum is refused, not silently used', async () => {
62
+ await assert.rejects(
63
+ () => deriveActorSecret('short', 'susan@actors.local'),
64
+ /SCENARIO_ACTOR_SECRET/
65
+ )
66
+ assert.ok(ACTOR_ROOT_SECRET_MIN_LENGTH >= 32)
67
+ })
68
+ })
@@ -0,0 +1,70 @@
1
+ import {
2
+ MIN_KEY_MATERIAL_LENGTH,
3
+ signWithKeyMaterial,
4
+ verifyWithKeyMaterial,
5
+ } from '../crypto-utils.js'
6
+
7
+ /** The name the root secret is held under, used only in error messages. */
8
+ export const ACTOR_SECRET_NAME = 'SCENARIO_ACTOR_SECRET'
9
+
10
+ /**
11
+ * Namespaces the derivation so the same root secret used for anything else
12
+ * produces different values. See knowledge/crypto.md.
13
+ */
14
+ export const ACTOR_SECRET_INFO = 'pikku:actor-sign-in'
15
+
16
+ /** The root must be strong: every persona's credential is derived from it. */
17
+ export const ACTOR_ROOT_SECRET_MIN_LENGTH = MIN_KEY_MATERIAL_LENGTH
18
+
19
+ /**
20
+ * What the derivation is bound to. Lowercased because the sign-in endpoint
21
+ * looks the user up by lowercased address, and a credential that verified
22
+ * against a different string than the row it opens is a credential for nothing.
23
+ */
24
+ export const actorSecretSubject = (email: string): string =>
25
+ email.trim().toLowerCase()
26
+
27
+ /**
28
+ * One persona's actor credential: `HMAC-SHA256(root, email)`, base64url.
29
+ *
30
+ * The root secret is not itself a valid credential and never travels: what a
31
+ * scenario run, a CI job or a virtual user is handed is the derived value for
32
+ * the one address it is entitled to. Presenting it for any other address fails,
33
+ * so a leaked credential is worth exactly one synthetic account rather than the
34
+ * whole actor population.
35
+ *
36
+ * Deterministic, so nothing is stored and nothing is provisioned — the target
37
+ * re-derives the expected value from the address being signed in as. Rotating
38
+ * the root invalidates every derived credential at once, which is the property
39
+ * a per-persona secret table would have to implement by hand.
40
+ */
41
+ export const deriveActorSecret = async (
42
+ rootSecret: string,
43
+ email: string
44
+ ): Promise<string> =>
45
+ signWithKeyMaterial(
46
+ ACTOR_SECRET_NAME,
47
+ rootSecret,
48
+ ACTOR_SECRET_INFO,
49
+ actorSecretSubject(email)
50
+ )
51
+
52
+ /**
53
+ * Whether `presented` is the credential for `email` under `rootSecret`.
54
+ *
55
+ * False — never throws — for a malformed, truncated or mismatched value, and
56
+ * the comparison is WebCrypto's own HMAC verify, so it does not exit early on
57
+ * the first differing byte.
58
+ */
59
+ export const verifyActorSecret = async (
60
+ rootSecret: string,
61
+ email: string,
62
+ presented: string
63
+ ): Promise<boolean> =>
64
+ verifyWithKeyMaterial(
65
+ ACTOR_SECRET_NAME,
66
+ rootSecret,
67
+ ACTOR_SECRET_INFO,
68
+ actorSecretSubject(email),
69
+ presented
70
+ )
@@ -1,3 +1,4 @@
1
+ import { deriveActorSecret } from './persona-actor-secret.js'
1
2
  import type { ResolvedPersona } from './personas-service.js'
2
3
  import type { ScenarioCookieJar } from '../wirings/workflow/scenario-cookie-jar.js'
3
4
 
@@ -40,6 +41,14 @@ const failed = async (
40
41
  )
41
42
  }
42
43
 
44
+ /**
45
+ * Yields the credential for one persona, for a caller that holds that persona's
46
+ * derived secret and not the root it came from.
47
+ */
48
+ export type ActorSecretResolver = (
49
+ persona: ResolvedPersona
50
+ ) => string | Promise<string>
51
+
43
52
  /**
44
53
  * Sign a persona in through the Better Auth actor plugin — the local-development
45
54
  * path.
@@ -48,22 +57,31 @@ const failed = async (
48
57
  * for it. Passwordless by design and refused for any row not carrying that flag,
49
58
  * so the secret can never reach a real user's account; the plugin still declines
50
59
  * to serve the endpoint at all outside `pikku dev`.
60
+ *
61
+ * What is presented is the persona's own credential, derived from the root and
62
+ * bound to their address. A run driving many personas holds the root and
63
+ * derives as it goes; a run entitled to one persona is handed that one value
64
+ * through a resolver and can sign in as nobody else.
51
65
  */
52
66
  export class ActorSignIn implements PersonaSignIn {
53
67
  constructor(
54
68
  private readonly apiUrl: string,
55
- private readonly secret: string,
69
+ private readonly secret: string | ActorSecretResolver,
56
70
  private readonly signInPath: string
57
71
  ) {}
58
72
 
59
73
  async login(jar: ScenarioCookieJar, persona: ResolvedPersona): Promise<void> {
74
+ const secret =
75
+ typeof this.secret === 'function'
76
+ ? await this.secret(persona)
77
+ : await deriveActorSecret(this.secret, persona.email)
60
78
  const res = await jar.fetch(`${this.apiUrl}${this.signInPath}`, {
61
79
  method: 'POST',
62
80
  headers: { 'content-type': 'application/json' },
63
81
  body: JSON.stringify({
64
82
  email: persona.email,
65
83
  name: persona.name,
66
- secret: this.secret,
84
+ secret,
67
85
  }),
68
86
  })
69
87
  if (!res.ok) {
@@ -83,6 +101,22 @@ export class ActorSignIn implements PersonaSignIn {
83
101
  }
84
102
  }
85
103
 
104
+ /**
105
+ * The auth mount a configured sign-in path sits under, or `undefined` when it
106
+ * names nothing recognisable.
107
+ *
108
+ * better-auth serves sign-in, operator sign-in and `get-session` from one
109
+ * prefix, so an app that mounts it at `/api/auth` moves all three together and
110
+ * says so once through `signInPath`. Reading the other two from a hardcoded
111
+ * `/auth` on such an app 404s — and for `get-session` a 404 reads as "this
112
+ * stage does not report roles", which silently turns off the check that tells a
113
+ * permissions finding from seed drift.
114
+ */
115
+ export const authMount = (signInPath?: string): string | undefined => {
116
+ const mount = signInPath ? signInPath.lastIndexOf('/sign-in/') : -1
117
+ return !signInPath || mount === -1 ? undefined : signInPath.slice(0, mount)
118
+ }
119
+
86
120
  export interface OperatorSignInOptions {
87
121
  /**
88
122
  * The short-lived RS256 operator token, or a function that mints one. Prefer