@pikku/core 0.12.100 → 0.12.102

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 (33) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/services/email-service.d.ts +13 -0
  3. package/dist/services/http-personas.d.ts +36 -6
  4. package/dist/services/http-personas.js +128 -9
  5. package/dist/services/index.d.ts +1 -1
  6. package/dist/services/local-email-service.js +9 -0
  7. package/dist/services/persona-sign-in.d.ts +0 -10
  8. package/dist/services/persona-sign-in.js +11 -9
  9. package/dist/wirings/cli/cli-runner.js +2 -11
  10. package/dist/wirings/cli/format-cli-error.d.ts +24 -0
  11. package/dist/wirings/cli/format-cli-error.js +68 -0
  12. package/dist/wirings/virtual-user/virtual-user-scaffold.d.ts +0 -1
  13. package/dist/wirings/virtual-user/virtual-user-scaffold.js +0 -4
  14. package/package.json +1 -1
  15. package/src/app-leaf-surface.test.ts +5 -0
  16. package/src/no-root-barrel.test.ts +9 -1
  17. package/src/services/email-service.test.ts +100 -0
  18. package/src/services/email-service.ts +14 -0
  19. package/src/services/http-personas-converse.test.ts +29 -20
  20. package/src/services/http-personas.test.ts +66 -0
  21. package/src/services/http-personas.ts +138 -9
  22. package/src/services/index.ts +1 -0
  23. package/src/services/local-email-service.test.ts +74 -0
  24. package/src/services/local-email-service.ts +9 -0
  25. package/src/services/persona-sign-in.test.ts +22 -28
  26. package/src/services/persona-sign-in.ts +11 -19
  27. package/src/wirings/cli/cli-runner.test.ts +104 -0
  28. package/src/wirings/cli/cli-runner.ts +2 -11
  29. package/src/wirings/cli/format-cli-error.test.ts +91 -0
  30. package/src/wirings/cli/format-cli-error.ts +96 -0
  31. package/src/wirings/virtual-user/virtual-user-scaffold.ts +0 -5
  32. package/tsconfig.tsbuildinfo +1 -1
  33. package/tsconfig.type-tests.json +1 -0
@@ -0,0 +1,100 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+
4
+ import type {
5
+ EmailAttachment,
6
+ EmailService,
7
+ SendEmailInput,
8
+ SendEmailResult,
9
+ SendHTMLEmailInput,
10
+ SendTemplateEmailInput,
11
+ SendTextEmailInput,
12
+ } from './email-service.js'
13
+
14
+ const receipt: EmailAttachment = {
15
+ filename: 'receipt.pdf',
16
+ content: new Uint8Array([1, 2, 3]),
17
+ contentType: 'application/pdf',
18
+ }
19
+
20
+ const logo: EmailAttachment = {
21
+ filename: 'logo.png',
22
+ content: 'aGVsbG8=',
23
+ contentType: 'image/png',
24
+ contentId: 'logo',
25
+ disposition: 'inline',
26
+ }
27
+
28
+ test('a text email carries attachments', () => {
29
+ const input: SendTextEmailInput = {
30
+ to: 'user@example.com',
31
+ text: 'Your receipt is attached',
32
+ attachments: [receipt],
33
+ }
34
+
35
+ assert.deepEqual(input.attachments, [receipt])
36
+ })
37
+
38
+ test('an html email carries attachments', () => {
39
+ const input: SendHTMLEmailInput = {
40
+ to: 'user@example.com',
41
+ html: '<img src="cid:logo" />',
42
+ attachments: [logo],
43
+ }
44
+
45
+ assert.equal(input.attachments?.[0]?.disposition, 'inline')
46
+ assert.equal(input.attachments?.[0]?.contentId, 'logo')
47
+ })
48
+
49
+ test('a template email carries attachments', () => {
50
+ const input: SendTemplateEmailInput = {
51
+ to: 'user@example.com',
52
+ template: { name: 'receipt', locale: 'en', data: { total: 10 } },
53
+ attachments: [receipt, logo],
54
+ }
55
+
56
+ assert.equal(input.attachments?.length, 2)
57
+ })
58
+
59
+ test('attachments are optional on every variant', () => {
60
+ const inputs: SendEmailInput[] = [
61
+ { to: 'user@example.com', text: 'hello' },
62
+ { to: 'user@example.com', html: '<p>hello</p>' },
63
+ { to: 'user@example.com', template: { name: 'welcome' } },
64
+ ]
65
+
66
+ for (const input of inputs) {
67
+ assert.equal(input.attachments, undefined)
68
+ }
69
+ })
70
+
71
+ test('a wrapping service forwards attachments to its delegate', async () => {
72
+ const sent: SendEmailInput[] = []
73
+ const delegate: EmailService = {
74
+ async send(input): Promise<SendEmailResult> {
75
+ sent.push(input as SendEmailInput)
76
+ return { messageId: 'delegated' }
77
+ },
78
+ }
79
+
80
+ const wrapper: EmailService = {
81
+ async send(input): Promise<SendEmailResult> {
82
+ const { template, ...rest } = input as SendTemplateEmailInput
83
+ return delegate.send({
84
+ ...rest,
85
+ subject: `rendered:${template.name}`,
86
+ html: '<p>rendered</p>',
87
+ })
88
+ },
89
+ }
90
+
91
+ const result = await wrapper.send({
92
+ to: 'user@example.com',
93
+ template: { name: 'receipt' },
94
+ attachments: [receipt],
95
+ })
96
+
97
+ assert.equal(result.messageId, 'delegated')
98
+ assert.equal(sent.length, 1)
99
+ assert.deepEqual(sent[0]!.attachments, [receipt])
100
+ })
@@ -6,6 +6,19 @@ export interface EmailTemplateReference {
6
6
  data?: Record<string, unknown>
7
7
  }
8
8
 
9
+ export interface EmailAttachment {
10
+ filename: string
11
+ /**
12
+ * Raw bytes, or the content already base64-encoded. A `string` is always
13
+ * read as base64 — never as a plain-text body — so text attachments must be
14
+ * encoded by the caller.
15
+ */
16
+ content: Uint8Array | string
17
+ contentType?: string
18
+ contentId?: string
19
+ disposition?: 'attachment' | 'inline'
20
+ }
21
+
9
22
  export interface BaseSendEmailInput {
10
23
  to: string | string[]
11
24
  from?: string
@@ -14,6 +27,7 @@ export interface BaseSendEmailInput {
14
27
  replyTo?: string | string[]
15
28
  headers?: Record<string, string>
16
29
  subject?: string
30
+ attachments?: EmailAttachment[]
17
31
  }
18
32
 
19
33
  export interface SendTextEmailInput extends BaseSendEmailInput {
@@ -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()
@@ -66,6 +66,16 @@ const startTarget = async () => {
66
66
  return
67
67
  }
68
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
+ }
69
79
  if (rpcName === 'html-error') {
70
80
  res
71
81
  .writeHead(500, { 'content-type': 'text/html' })
@@ -261,12 +271,68 @@ describe('HttpPersona', async () => {
261
271
  // mount in `apiUrl`, so it moves `signInPath` instead. The session read has
262
272
  // to follow it: a 404 there reads as 'this stage does not report roles' and
263
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.
264
277
  test('reads the session from the mount the sign-in path names', async () => {
265
278
  const actors = createHttpPersonas({
266
279
  apiUrl: target.origin,
267
280
  secret: ROOT,
268
281
  signInPath: '/api/auth/sign-in/actor',
269
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',
270
336
  personas: {
271
337
  manager: {
272
338
  id: 'manager',
@@ -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 {
@@ -75,6 +76,17 @@ export interface HttpPersonasConfig {
75
76
  sessionPath?: string
76
77
  /** Exposed-RPC path prefix under apiUrl. Default `/rpc`. */
77
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
78
90
  /**
79
91
  * Default model a persona thinks with when `converse(...)` is called without
80
92
  * an explicit `model`. Its own turns/approvals/evaluation run in-process via
@@ -207,16 +219,46 @@ export class HttpPersona implements ScenarioPersona {
207
219
  /**
208
220
  * The roles the stage says this session holds.
209
221
  *
210
- * Read from better-auth's `get-session`, which is what most pikku apps are
211
- * running and where its admin plugin puts `role` on the user, as a
212
- * comma-separated list. A target that answers something else returns `null`
213
- * rather than an empty list, because "this stage does not report roles" and
214
- * "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.
215
227
  */
216
228
  async sessionRoles(): Promise<string[] | null> {
217
229
  if (!this.signedIn) {
218
230
  await this.login()
219
231
  }
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> {
220
262
  const mount = authMount(
221
263
  this.config.operator?.signInPath ?? this.config.signInPath
222
264
  )
@@ -254,19 +296,30 @@ export class HttpPersona implements ScenarioPersona {
254
296
  return user ? [] : null
255
297
  }
256
298
 
257
- /** 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
+ */
258
311
  private async agentRun(
259
312
  agentName: string,
260
313
  message: string,
261
314
  threadId: string,
262
315
  resourceId: string
263
316
  ): Promise<TargetAgentReply> {
264
- const raw = await this.postAgent(`agent/${agentName}`, {
317
+ const res = await this.sendAgent(`agent/${agentName}/stream`, {
265
318
  message,
266
319
  threadId,
267
320
  resourceId,
268
321
  })
269
- return normalizeAgentReply(raw)
322
+ return await collectAgentStream(res)
270
323
  }
271
324
 
272
325
  /** Answer the target agent's pending approvals over HTTP and continue. */
@@ -283,7 +336,7 @@ export class HttpPersona implements ScenarioPersona {
283
336
  }
284
337
 
285
338
  // knowledge: decisions/internals/scenario-agent-calls-sign-in-on-401-only.md
286
- private async postAgent(subPath: string, body: unknown): Promise<unknown> {
339
+ private async sendAgent(subPath: string, body: unknown): Promise<Response> {
287
340
  const rpcPath = this.config.rpcPath ?? '/rpc'
288
341
  const url = `${this.config.apiUrl}${rpcPath}/${subPath}`
289
342
  const send = () =>
@@ -308,6 +361,11 @@ export class HttpPersona implements ScenarioPersona {
308
361
  `[scenario] agent call '${subPath}' as '${this.name}' returned ${res.status}: ${text}`
309
362
  )
310
363
  }
364
+ return res
365
+ }
366
+
367
+ private async postAgent(subPath: string, body: unknown): Promise<unknown> {
368
+ const res = await this.sendAgent(subPath, body)
311
369
  if (res.status === 204) return undefined
312
370
  const text = await res.text()
313
371
  return text ? JSON.parse(text) : undefined
@@ -342,6 +400,77 @@ export class HttpPersona implements ScenarioPersona {
342
400
  }
343
401
  }
344
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
+
345
474
  /** Normalize an agentRun/agentApprove HTTP response into a TargetAgentReply. */
346
475
  function normalizeAgentReply(raw: unknown): TargetAgentReply {
347
476
  const r = (raw ?? {}) as Record<string, unknown>
@@ -46,6 +46,7 @@ export type {
46
46
  // knowledge: decisions/internals/the-persona-runtime-is-exported-from-the-persona-entry-point.md
47
47
  export type { JWTService } from './jwt-service.js'
48
48
  export type {
49
+ EmailAttachment,
49
50
  EmailService,
50
51
  SendEmailInput,
51
52
  SendEmailResult,
@@ -106,3 +106,77 @@ test('LocalEmailService logs null textLength for HTML email without plain text',
106
106
  assert.equal(payload.htmlLength, '<p>Hello</p>'.length)
107
107
  assert.equal(payload.textLength, null)
108
108
  })
109
+
110
+ test('LocalEmailService logs attachment metadata without the content', async () => {
111
+ const service = new LocalEmailService()
112
+ const writes: string[] = []
113
+ const originalInfo = console.info
114
+ console.info = (value?: unknown) => {
115
+ writes.push(String(value))
116
+ }
117
+
118
+ try {
119
+ await service.send({
120
+ to: 'user@example.com',
121
+ html: '<img src="cid:logo" />',
122
+ attachments: [
123
+ {
124
+ filename: 'receipt.pdf',
125
+ content: new Uint8Array([1, 2, 3, 4]),
126
+ contentType: 'application/pdf',
127
+ },
128
+ {
129
+ filename: 'logo.png',
130
+ content: 'aGVsbG8=',
131
+ contentType: 'image/png',
132
+ contentId: 'logo',
133
+ disposition: 'inline',
134
+ },
135
+ ],
136
+ })
137
+ } finally {
138
+ console.info = originalInfo
139
+ }
140
+
141
+ const payload = JSON.parse(writes[0])
142
+ assert.deepEqual(payload.attachments, [
143
+ {
144
+ filename: 'receipt.pdf',
145
+ contentType: 'application/pdf',
146
+ contentId: null,
147
+ disposition: 'attachment',
148
+ contentLength: 4,
149
+ },
150
+ {
151
+ filename: 'logo.png',
152
+ contentType: 'image/png',
153
+ contentId: 'logo',
154
+ disposition: 'inline',
155
+ contentLength: 8,
156
+ },
157
+ ])
158
+ assert.equal(writes[0].includes('aGVsbG8='), false)
159
+ })
160
+
161
+ test('LocalEmailService omits attachments when there are none', async () => {
162
+ const service = new LocalEmailService()
163
+ const writes: string[] = []
164
+ const originalInfo = console.info
165
+ console.info = (value?: unknown) => {
166
+ writes.push(String(value))
167
+ }
168
+
169
+ try {
170
+ await service.send({ to: 'user@example.com', text: 'no files' })
171
+ await service.send({
172
+ to: 'user@example.com',
173
+ text: 'no files',
174
+ attachments: [],
175
+ })
176
+ } finally {
177
+ console.info = originalInfo
178
+ }
179
+
180
+ assert.equal(JSON.parse(writes[0]).attachments, undefined)
181
+ assert.equal(JSON.parse(writes[1]).attachments, undefined)
182
+ })
@@ -27,6 +27,15 @@ export class LocalEmailService implements EmailService {
27
27
  if ('text' in input && typeof input.text === 'string') {
28
28
  payload.textLength = input.text.length
29
29
  }
30
+ if (input.attachments?.length) {
31
+ payload.attachments = input.attachments.map((attachment) => ({
32
+ filename: attachment.filename,
33
+ contentType: attachment.contentType ?? null,
34
+ contentId: attachment.contentId ?? null,
35
+ disposition: attachment.disposition ?? 'attachment',
36
+ contentLength: attachment.content.length,
37
+ }))
38
+ }
30
39
 
31
40
  console.info(JSON.stringify(payload))
32
41
  return {}
@@ -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 () => {