@pikku/core 0.12.99 → 0.12.101

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.
@@ -57,28 +57,37 @@ const startAgentTarget = async () => {
57
57
  json({ runId: body.runId, text: 'Created it.', status: 'completed' })
58
58
  return
59
59
  }
60
- if (req.url === '/api/rpc/agent/todoBot') {
60
+ // The SSE route, because that is the one a persona's turn goes to — the
61
+ // plain route buffers the whole run and dies on undici's headers timeout.
62
+ if (req.url === '/api/rpc/agent/todoBot/stream') {
61
63
  agentRuns++
62
- if (agentRuns === 1) {
63
- json({
64
- runId: 'run-1',
65
- text: 'Let me do that.',
66
- status: 'suspended',
67
- pendingApprovals: [
68
- {
69
- toolCallId: 'tc1',
70
- toolName: 'createTodo',
71
- args: { title: 'x' },
72
- },
73
- ],
74
- })
75
- return
64
+ const runId = `run-${agentRuns}`
65
+ const events: unknown[] =
66
+ agentRuns === 1
67
+ ? [
68
+ { type: 'RUN_STARTED', runId },
69
+ { type: 'TEXT_MESSAGE_CONTENT', delta: 'Let me ' },
70
+ { type: 'TEXT_MESSAGE_CONTENT', delta: 'do that.' },
71
+ {
72
+ type: 'approval-request',
73
+ runId,
74
+ toolCallId: 'tc1',
75
+ toolName: 'createTodo',
76
+ args: { title: 'x' },
77
+ },
78
+ { type: 'done' },
79
+ ]
80
+ : [
81
+ { type: 'RUN_STARTED', runId },
82
+ { type: 'TEXT_MESSAGE_CONTENT', delta: 'All set.' },
83
+ { type: 'RUN_FINISHED', runId },
84
+ { type: 'done' },
85
+ ]
86
+ res.writeHead(200, { 'content-type': 'text/event-stream' })
87
+ for (const event of events) {
88
+ res.write(`data: ${JSON.stringify(event)}\n\n`)
76
89
  }
77
- json({
78
- runId: `run-${agentRuns}`,
79
- text: 'All set.',
80
- status: 'completed',
81
- })
90
+ res.end()
82
91
  return
83
92
  }
84
93
  res.writeHead(404).end()
@@ -35,6 +35,29 @@ const startTarget = async () => {
35
35
  res.writeHead(200).end(JSON.stringify({ ok: true, email: body.email }))
36
36
  return
37
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
+ }
38
61
  if (req.url?.startsWith('/api/rpc/')) {
39
62
  const cookie = req.headers.cookie ?? ''
40
63
  if (!cookie.includes('session=') || expireNext) {
@@ -43,6 +66,16 @@ const startTarget = async () => {
43
66
  return
44
67
  }
45
68
  const rpcName = req.url.slice('/api/rpc/'.length)
69
+ if (rpcName === 'getMyScopes') {
70
+ res
71
+ .writeHead(200, { 'content-type': 'application/json' })
72
+ .end(JSON.stringify({ scopes: ['stays:read'], roles: ['guest'] }))
73
+ return
74
+ }
75
+ if (rpcName === 'noSuchRpc') {
76
+ res.writeHead(404).end()
77
+ return
78
+ }
46
79
  if (rpcName === 'html-error') {
47
80
  res
48
81
  .writeHead(500, { 'content-type': 'text/html' })
@@ -63,6 +96,7 @@ const startTarget = async () => {
63
96
  echoed: body.data,
64
97
  cookie,
65
98
  userHeader: req.headers['x-user-id'] ?? null,
99
+ impersonated: req.headers['x-pikku-impersonate-user-id'] ?? null,
66
100
  })
67
101
  )
68
102
  return
@@ -74,6 +108,7 @@ const startTarget = async () => {
74
108
  const { port } = server.address() as { port: number }
75
109
  return {
76
110
  server,
111
+ origin: `http://127.0.0.1:${port}`,
77
112
  apiUrl: `http://127.0.0.1:${port}/api`,
78
113
  loginCount: () => logins,
79
114
  expireSession: () => {
@@ -231,4 +266,114 @@ describe('HttpPersona', async () => {
231
266
  /persona sign-in failed for 'customer' \(401\).*bad actor secret/
232
267
  )
233
268
  })
269
+
270
+ // An app whose auth is under `/api` but whose RPCs are not cannot put the
271
+ // mount in `apiUrl`, so it moves `signInPath` instead. The session read has
272
+ // to follow it: a 404 there reads as 'this stage does not report roles' and
273
+ // silently turns the role check off.
274
+ //
275
+ // `rolesRpc: false` because this is about the better-auth path specifically —
276
+ // leaving the RPC in would answer first and the mount would go untested.
277
+ test('reads the session from the mount the sign-in path names', async () => {
278
+ const actors = createHttpPersonas({
279
+ apiUrl: target.origin,
280
+ secret: ROOT,
281
+ signInPath: '/api/auth/sign-in/actor',
282
+ rpcPath: '/api/rpc',
283
+ rolesRpc: false,
284
+ personas: {
285
+ manager: {
286
+ id: 'manager',
287
+ name: 'Manager',
288
+ email: 'manager@personas.invalid',
289
+ roles: ['admin'],
290
+ goals: [],
291
+ tags: [],
292
+ runnable: true,
293
+ },
294
+ },
295
+ })
296
+
297
+ assert.deepEqual(await actors.manager!.sessionRoles(), ['admin', 'support'])
298
+ })
299
+
300
+ // Scopes are the model in an app that authorizes on them; better-auth's
301
+ // `user.role` is a projection kept in step for its own admin endpoints, and
302
+ // is absent entirely from an app that declares no such field. Reading the
303
+ // projection is how a guest holding exactly what it should got refused for
304
+ // "roles drifted".
305
+ test('reads roles from the app RPC in preference to better-auth', async () => {
306
+ const actors = createHttpPersonas({
307
+ apiUrl: target.origin,
308
+ secret: ROOT,
309
+ signInPath: '/api/auth/sign-in/actor',
310
+ rpcPath: '/api/rpc',
311
+ personas: {
312
+ manager: {
313
+ id: 'manager',
314
+ name: 'Manager',
315
+ email: 'manager@personas.invalid',
316
+ roles: ['guest'],
317
+ goals: [],
318
+ tags: [],
319
+ runnable: true,
320
+ },
321
+ },
322
+ })
323
+
324
+ assert.deepEqual(await actors.manager!.sessionRoles(), ['guest'])
325
+ })
326
+
327
+ // An app without the RPC is not an app reporting "no roles" — going quiet
328
+ // there would turn every persona into a mismatch against an empty list.
329
+ test('falls back to better-auth when the roles RPC is absent', async () => {
330
+ const actors = createHttpPersonas({
331
+ apiUrl: target.origin,
332
+ secret: ROOT,
333
+ signInPath: '/api/auth/sign-in/actor',
334
+ rpcPath: '/api/rpc',
335
+ rolesRpc: 'noSuchRpc',
336
+ personas: {
337
+ manager: {
338
+ id: 'manager',
339
+ name: 'Manager',
340
+ email: 'manager@personas.invalid',
341
+ roles: ['admin'],
342
+ goals: [],
343
+ tags: [],
344
+ runnable: true,
345
+ },
346
+ },
347
+ })
348
+
349
+ assert.deepEqual(await actors.manager!.sessionRoles(), ['admin', 'support'])
350
+ })
351
+
352
+ // The operator handshake sits under the same mount and must be derived from
353
+ // it, not inherited verbatim: posting an operator token to the ACTOR path is
354
+ // a validation error about a missing email, which reads like a broken
355
+ // persona rather than a wrong URL.
356
+ test('derives the operator sign-in path from the same auth mount', async () => {
357
+ const actors = createHttpPersonas({
358
+ apiUrl: target.origin,
359
+ signInPath: '/api/auth/sign-in/actor',
360
+ rpcPath: '/api/rpc',
361
+ operator: { token: 'operator-token' },
362
+ personas: {
363
+ manager: {
364
+ id: 'manager',
365
+ name: 'Manager',
366
+ email: 'manager@personas.invalid',
367
+ roles: ['admin'],
368
+ goals: [],
369
+ tags: [],
370
+ runnable: true,
371
+ },
372
+ },
373
+ })
374
+
375
+ const result = (await actors.manager!.invoke('ping', {})) as any
376
+ assert.equal(result.rpcName, 'ping')
377
+ assert.equal(result.impersonated, 'u-manager@personas.invalid')
378
+ })
234
379
  })
@@ -10,6 +10,7 @@ import type {
10
10
  ConverseOptions,
11
11
  ActorFlowVerdict,
12
12
  TargetAgentReply,
13
+ TargetPendingApproval,
13
14
  } from '../wirings/actor-flow/actor-flow.types.js'
14
15
  import { runConversation } from '../wirings/actor-flow/run-conversation.js'
15
16
  import {
@@ -22,6 +23,7 @@ import {
22
23
  OperatorSignIn,
23
24
  type OperatorSignInOptions,
24
25
  type PersonaSignIn,
26
+ authMount,
25
27
  } from './persona-sign-in.js'
26
28
  import { getSingletonServices } from '../pikku-state.js'
27
29
  import { AIProviderNotConfiguredError } from '../errors/errors.js'
@@ -65,10 +67,26 @@ export interface HttpPersonasConfig {
65
67
  * {@link OperatorSignInOptions.signInPath} overrides it.
66
68
  */
67
69
  signInPath?: string
68
- /** Where the session (and its roles) is read back. Default `/auth/get-session`. */
70
+ /**
71
+ * Where the session (and its roles) is read back. Defaults to `get-session`
72
+ * under the same auth mount as {@link HttpPersonasConfig.signInPath}, so an
73
+ * app that moved auth under `/api` moves this with it and does not have to
74
+ * say so twice.
75
+ */
69
76
  sessionPath?: string
70
77
  /** Exposed-RPC path prefix under apiUrl. Default `/rpc`. */
71
78
  rpcPath?: string
79
+ /**
80
+ * Exposed RPC that reports the CALLER's own roles, as `{ roles: string[] }`.
81
+ * Default `getMyScopes`. Pass `false` to skip it and read better-auth only.
82
+ *
83
+ * Asked before better-auth's `user.role`, because in an app that authorizes
84
+ * on scopes that column is a projection rather than the model — it exists so
85
+ * better-auth's own admin endpoints have something to read, is written by
86
+ * whatever keeps it in step, and is absent entirely from an app that declares
87
+ * no such field. A persona verified against it is verified against a copy.
88
+ */
89
+ rolesRpc?: string | false
72
90
  /**
73
91
  * Default model a persona thinks with when `converse(...)` is called without
74
92
  * an explicit `model`. Its own turns/approvals/evaluation run in-process via
@@ -106,7 +124,11 @@ export class HttpPersona implements ScenarioPersona {
106
124
  if (config.operator) {
107
125
  this.signIn = new OperatorSignIn(config.apiUrl, {
108
126
  ...config.operator,
109
- signInPath: config.operator.signInPath ?? config.signInPath,
127
+ signInPath:
128
+ config.operator.signInPath ??
129
+ (authMount(config.signInPath)
130
+ ? `${authMount(config.signInPath)}/sign-in/fabric`
131
+ : undefined),
110
132
  })
111
133
  } else if (config.secret) {
112
134
  this.signIn = new ActorSignIn(
@@ -197,17 +219,51 @@ export class HttpPersona implements ScenarioPersona {
197
219
  /**
198
220
  * The roles the stage says this session holds.
199
221
  *
200
- * Read from better-auth's `get-session`, which is what most pikku apps are
201
- * running and where its admin plugin puts `role` on the user, as a
202
- * comma-separated list. A target that answers something else returns `null`
203
- * rather than an empty list, because "this stage does not report roles" and
204
- * "this person has none" call for opposite responses from the caller.
222
+ * {@link HttpPersonasConfig.rolesRpc} first, then better-auth's
223
+ * `get-session` where its admin plugin puts `role` on the user, as a
224
+ * comma-separated list. A target that answers neither returns `null` rather
225
+ * than an empty list, because "this stage does not report roles" and "this
226
+ * person has none" call for opposite responses from the caller.
205
227
  */
206
228
  async sessionRoles(): Promise<string[] | null> {
207
229
  if (!this.signedIn) {
208
230
  await this.login()
209
231
  }
210
- const sessionPath = this.config.sessionPath ?? '/auth/get-session'
232
+ const fromRpc = await this.rolesFromRpc()
233
+ if (fromRpc) return fromRpc
234
+ return await this.rolesFromSession()
235
+ }
236
+
237
+ /**
238
+ * The caller's own roles, from the app's own RPC. `null` for every answer
239
+ * that is not a role list — an app without the RPC 404s here, which is a
240
+ * reason to go on and ask better-auth, not a reason to report "none".
241
+ */
242
+ private async rolesFromRpc(): Promise<string[] | null> {
243
+ const rpcName = this.config.rolesRpc ?? 'getMyScopes'
244
+ if (rpcName === false) return null
245
+ let res: Response
246
+ try {
247
+ res = await this.postRpc(rpcName, {})
248
+ } catch {
249
+ return null
250
+ }
251
+ if (!res.ok) return null
252
+ const { body } = await readScenarioHttpResponse<{
253
+ roles?: unknown
254
+ data?: { roles?: unknown }
255
+ }>(res)
256
+ const roles = body?.roles ?? body?.data?.roles
257
+ if (!Array.isArray(roles)) return null
258
+ return roles.filter((name): name is string => typeof name === 'string')
259
+ }
260
+
261
+ private async rolesFromSession(): Promise<string[] | null> {
262
+ const mount = authMount(
263
+ this.config.operator?.signInPath ?? this.config.signInPath
264
+ )
265
+ const sessionPath =
266
+ this.config.sessionPath ?? `${mount ?? '/auth'}/get-session`
211
267
  const res = await this.jar.fetch(`${this.config.apiUrl}${sessionPath}`, {
212
268
  headers: this.signIn.headers(),
213
269
  })
@@ -240,19 +296,30 @@ export class HttpPersona implements ScenarioPersona {
240
296
  return user ? [] : null
241
297
  }
242
298
 
243
- /** Start/continue the target agent's run over HTTP as this persona. */
299
+ /**
300
+ * Start/continue the target agent's run over HTTP as this persona.
301
+ *
302
+ * The SSE route, not the plain one. `POST /rpc/agent/:name` buffers the whole
303
+ * run before it sends a single byte, so a run longer than the client's
304
+ * headers timeout — 300s in undici, which is what Node and Bun both use —
305
+ * fails with `UND_ERR_HEADERS_TIMEOUT` and no way to tell it apart from a
306
+ * stage that is down. An agent that talks for several minutes, which is the
307
+ * normal case for anything conversational, cannot be driven that way at all.
308
+ * The stream sends its first event immediately and the run's length stops
309
+ * mattering.
310
+ */
244
311
  private async agentRun(
245
312
  agentName: string,
246
313
  message: string,
247
314
  threadId: string,
248
315
  resourceId: string
249
316
  ): Promise<TargetAgentReply> {
250
- const raw = await this.postAgent(`agent/${agentName}`, {
317
+ const res = await this.sendAgent(`agent/${agentName}/stream`, {
251
318
  message,
252
319
  threadId,
253
320
  resourceId,
254
321
  })
255
- return normalizeAgentReply(raw)
322
+ return await collectAgentStream(res)
256
323
  }
257
324
 
258
325
  /** Answer the target agent's pending approvals over HTTP and continue. */
@@ -269,7 +336,7 @@ export class HttpPersona implements ScenarioPersona {
269
336
  }
270
337
 
271
338
  // knowledge: decisions/internals/scenario-agent-calls-sign-in-on-401-only.md
272
- private async postAgent(subPath: string, body: unknown): Promise<unknown> {
339
+ private async sendAgent(subPath: string, body: unknown): Promise<Response> {
273
340
  const rpcPath = this.config.rpcPath ?? '/rpc'
274
341
  const url = `${this.config.apiUrl}${rpcPath}/${subPath}`
275
342
  const send = () =>
@@ -294,6 +361,11 @@ export class HttpPersona implements ScenarioPersona {
294
361
  `[scenario] agent call '${subPath}' as '${this.name}' returned ${res.status}: ${text}`
295
362
  )
296
363
  }
364
+ return res
365
+ }
366
+
367
+ private async postAgent(subPath: string, body: unknown): Promise<unknown> {
368
+ const res = await this.sendAgent(subPath, body)
297
369
  if (res.status === 204) return undefined
298
370
  const text = await res.text()
299
371
  return text ? JSON.parse(text) : undefined
@@ -328,6 +400,77 @@ export class HttpPersona implements ScenarioPersona {
328
400
  }
329
401
  }
330
402
 
403
+ /**
404
+ * Reduce the agent's SSE run into the same reply shape the plain route returns.
405
+ *
406
+ * `RUN_ERROR` is raised rather than returned: the plain route answers a failed
407
+ * run with a non-2xx, and a scenario that read an error as an empty transcript
408
+ * would score the agent on silence it never produced.
409
+ */
410
+ async function collectAgentStream(res: Response): Promise<TargetAgentReply> {
411
+ const body = res.body
412
+ if (!body) {
413
+ throw new Error('[scenario] the agent stream carried no body')
414
+ }
415
+ const decoder = new TextDecoder()
416
+ const reader = body.getReader()
417
+ let buffer = ''
418
+ let text = ''
419
+ let runId = ''
420
+ const pendingApprovals: TargetPendingApproval[] = []
421
+
422
+ const consume = (line: string) => {
423
+ if (!line.startsWith('data:')) return
424
+ const payload = line.slice(5).trim()
425
+ if (!payload) return
426
+ let event: Record<string, unknown>
427
+ try {
428
+ event = JSON.parse(payload)
429
+ } catch {
430
+ return
431
+ }
432
+ if (typeof event.runId === 'string' && event.runId) runId = event.runId
433
+ if (
434
+ event.type === 'TEXT_MESSAGE_CONTENT' &&
435
+ typeof event.delta === 'string'
436
+ ) {
437
+ text += event.delta
438
+ } else if (event.type === 'approval-request') {
439
+ pendingApprovals.push({
440
+ toolCallId: String(event.toolCallId),
441
+ toolName: String(event.toolName),
442
+ args: event.args,
443
+ reason: typeof event.reason === 'string' ? event.reason : undefined,
444
+ })
445
+ } else if (event.type === 'RUN_ERROR' || event.type === 'error') {
446
+ const message = event.message ?? event.errorText ?? 'the agent run failed'
447
+ throw new Error(`[scenario] agent run failed: ${String(message)}`)
448
+ }
449
+ }
450
+
451
+ try {
452
+ for (;;) {
453
+ const { done, value } = await reader.read()
454
+ if (done) break
455
+ buffer += decoder.decode(value, { stream: true })
456
+ const lines = buffer.split('\n')
457
+ buffer = lines.pop() ?? ''
458
+ for (const line of lines) consume(line)
459
+ }
460
+ if (buffer) consume(buffer)
461
+ } finally {
462
+ await reader.cancel().catch(() => {})
463
+ }
464
+
465
+ return {
466
+ text,
467
+ runId,
468
+ status: pendingApprovals.length > 0 ? 'suspended' : 'completed',
469
+ pendingApprovals:
470
+ pendingApprovals.length > 0 ? pendingApprovals : undefined,
471
+ }
472
+ }
473
+
331
474
  /** Normalize an agentRun/agentApprove HTTP response into a TargetAgentReply. */
332
475
  function normalizeAgentReply(raw: unknown): TargetAgentReply {
333
476
  const r = (raw ?? {}) as Record<string, unknown>
@@ -14,7 +14,7 @@ const OPERATOR_TOKEN = 'operator.jwt.token'
14
14
  */
15
15
  const startStage = async (seeded: Array<{ id: string; email: string }>) => {
16
16
  const users = [...seeded]
17
- let created = 0
17
+ const sentBodies: unknown[] = []
18
18
  const server: Server = createServer((req, res) => {
19
19
  const chunks: Buffer[] = []
20
20
  req.on('data', (c) => chunks.push(c))
@@ -31,21 +31,15 @@ const startStage = async (seeded: Array<{ id: string; email: string }>) => {
31
31
  }
32
32
  let actAs: { userId: string } | undefined
33
33
  if (body.actAs) {
34
- let user = users.find((u) => u.email === body.actAs.email)
34
+ sentBodies.push(body.actAs)
35
+ const user = users.find((u) => u.email === body.actAs.email)
35
36
  if (!user) {
36
- if (!body.actAs.create) {
37
- res
38
- .writeHead(404)
39
- .end(
40
- JSON.stringify({
41
- message: `No account on this stage for ${body.actAs.email}`,
42
- })
43
- )
44
- return
45
- }
46
- created++
47
- user = { id: `made-${created}`, email: body.actAs.email }
48
- users.push(user)
37
+ res.writeHead(404).end(
38
+ JSON.stringify({
39
+ message: `No account on this stage for ${body.actAs.email}`,
40
+ })
41
+ )
42
+ return
49
43
  }
50
44
  actAs = { userId: user.id }
51
45
  }
@@ -74,8 +68,8 @@ const startStage = async (seeded: Array<{ id: string; email: string }>) => {
74
68
  return {
75
69
  apiUrl: `http://127.0.0.1:${port}/api`,
76
70
  server,
77
- get createdCount() {
78
- return created
71
+ get sentBodies() {
72
+ return sentBodies
79
73
  },
80
74
  }
81
75
  }
@@ -112,7 +106,6 @@ describe('operator persona sign-in', () => {
112
106
  }
113
107
  assert.equal(result.actingAs, 'user-7')
114
108
  assert.match(result.cookie, /session=operator/)
115
- assert.equal(stage.createdCount, 0)
116
109
  })
117
110
 
118
111
  // An app that mounts auth somewhere other than the root moves both sign-in
@@ -151,24 +144,25 @@ describe('operator persona sign-in', () => {
151
144
  () => personas.customer!.invoke('whoami', {}),
152
145
  /operator sign-in failed for 'customer' \(404\)/
153
146
  )
154
- assert.equal(stage.createdCount, 0)
155
147
  })
156
148
 
157
- test('provisions the account only when told to', async () => {
158
- const stage = await startStage([])
149
+ // Provisioning moved into the stage, so the handshake carries an address and
150
+ // nothing else. A `create` flag or a role list here would be the caller
151
+ // deciding what the stage holds, which is the arrangement this replaced.
152
+ test('asks only to act as an address', async () => {
153
+ const stage = await startStage([
154
+ { id: 'user-3', email: 'customer@personas.invalid' },
155
+ ])
159
156
  servers.push(stage.server)
160
157
 
161
158
  const personas = createHttpPersonas({
162
159
  apiUrl: stage.apiUrl,
163
- operator: { token: OPERATOR_TOKEN, createMissing: true },
164
- personas: { customer: persona('fresh@personas.invalid') },
160
+ operator: { token: OPERATOR_TOKEN },
161
+ personas: { customer: persona('customer@personas.invalid') },
165
162
  })
166
163
 
167
- const result = (await personas.customer!.invoke('whoami', {})) as {
168
- actingAs: string | null
169
- }
170
- assert.equal(result.actingAs, 'made-1')
171
- assert.equal(stage.createdCount, 1)
164
+ await personas.customer!.invoke('whoami', {})
165
+ assert.deepEqual(stage.sentBodies, [{ email: 'customer@personas.invalid' }])
172
166
  })
173
167
 
174
168
  test('mints the operator session from a token factory', async () => {
@@ -1,4 +1,5 @@
1
1
  import { deriveActorSecret } from './persona-actor-secret.js'
2
+ import { PikkuError } from '../errors/error-handler.js'
2
3
  import type { ResolvedPersona } from './personas-service.js'
3
4
  import type { ScenarioCookieJar } from '../wirings/workflow/scenario-cookie-jar.js'
4
5
 
@@ -30,13 +31,19 @@ export interface PersonaSignIn {
30
31
  headers(): Record<string, string>
31
32
  }
32
33
 
34
+ /**
35
+ * A sign-in the target refused. `PikkuError`, not `Error`, so the CLI prints
36
+ * this message alone: an expired token or a persona the stage has never seen is
37
+ * something to go and fix, and a stack trace through the fetch internals only
38
+ * buries the status and the body that say which one it is.
39
+ */
33
40
  const failed = async (
34
41
  what: string,
35
42
  personaId: string,
36
43
  res: Response
37
44
  ): Promise<Error> => {
38
45
  const body = (await res.text().catch(() => '')).slice(0, 300)
39
- return new Error(
46
+ return new PikkuError(
40
47
  `[scenario] ${what} failed for '${personaId}' (${res.status}): ${body}`
41
48
  )
42
49
  }
@@ -101,22 +108,28 @@ export class ActorSignIn implements PersonaSignIn {
101
108
  }
102
109
  }
103
110
 
111
+ /**
112
+ * The auth mount a configured sign-in path sits under, or `undefined` when it
113
+ * names nothing recognisable.
114
+ *
115
+ * better-auth serves sign-in, operator sign-in and `get-session` from one
116
+ * prefix, so an app that mounts it at `/api/auth` moves all three together and
117
+ * says so once through `signInPath`. Reading the other two from a hardcoded
118
+ * `/auth` on such an app 404s — and for `get-session` a 404 reads as "this
119
+ * stage does not report roles", which silently turns off the check that tells a
120
+ * permissions finding from seed drift.
121
+ */
122
+ export const authMount = (signInPath?: string): string | undefined => {
123
+ const mount = signInPath ? signInPath.lastIndexOf('/sign-in/') : -1
124
+ return !signInPath || mount === -1 ? undefined : signInPath.slice(0, mount)
125
+ }
126
+
104
127
  export interface OperatorSignInOptions {
105
128
  /**
106
129
  * The short-lived RS256 operator token, or a function that mints one. Prefer
107
130
  * the function: tokens expire, and a long run re-logs-in after a 401.
108
131
  */
109
132
  token: string | (() => string | Promise<string>)
110
- /**
111
- * Create the persona's user row when the target has no account for that
112
- * address.
113
- *
114
- * Off by default, which is the whole point of the deployed path: a persona is
115
- * meant to be a real account somebody provisioned, and a test run that
116
- * silently writes users into a live database is a side effect nobody asked
117
- * for. Turn it on for throwaway stages.
118
- */
119
- createMissing?: boolean
120
133
  /** Fabric operator sign-in path under apiUrl. Default `/auth/sign-in/fabric`. */
121
134
  signInPath?: string
122
135
  }
@@ -155,12 +168,7 @@ export const establishOperatorSession = async (
155
168
  headers: { 'content-type': 'application/json', ...extraHeaders },
156
169
  body: JSON.stringify({
157
170
  token,
158
- actAs: {
159
- email: persona.email,
160
- name: persona.name,
161
- create: options.createMissing ?? false,
162
- ...(persona.roles[0] ? { role: persona.roles[0] } : {}),
163
- },
171
+ actAs: { email: persona.email },
164
172
  }),
165
173
  })
166
174
  if (!res.ok) {
@@ -168,7 +176,7 @@ export const establishOperatorSession = async (
168
176
  }
169
177
  const setCookies = res.headers.getSetCookie?.() ?? []
170
178
  if (setCookies.length === 0) {
171
- throw new Error(
179
+ throw new PikkuError(
172
180
  `[scenario] operator sign-in for '${persona.id}' returned no session cookie`
173
181
  )
174
182
  }
@@ -178,7 +186,7 @@ export const establishOperatorSession = async (
178
186
  } | null
179
187
  const userId = body?.actAs?.userId
180
188
  if (!userId) {
181
- throw new Error(
189
+ throw new PikkuError(
182
190
  `[scenario] operator sign-in for '${persona.id}' returned no user to act as — ` +
183
191
  'the target is running a @pikku/better-auth too old to resolve one'
184
192
  )