@pikku/core 0.12.94 → 0.12.96

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 (62) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/dist/dev/hot-reload.js +24 -4
  3. package/dist/dev/module-runner.d.ts +20 -3
  4. package/dist/dev/module-runner.js +17 -4
  5. package/dist/services/email-template.d.ts +43 -0
  6. package/dist/services/email-template.js +139 -0
  7. package/dist/services/http-personas.d.ts +6 -1
  8. package/dist/services/http-personas.js +4 -1
  9. package/dist/services/index.d.ts +1 -0
  10. package/dist/services/index.js +1 -0
  11. package/dist/wirings/agent/agent-prepare.d.ts +14 -0
  12. package/dist/wirings/agent/agent-prepare.js +24 -0
  13. package/dist/wirings/agent/index.d.ts +1 -1
  14. package/dist/wirings/agent/index.js +1 -1
  15. package/dist/wirings/scheduler/scheduler-runner.js +0 -1
  16. package/dist/wirings/virtual-user/index.d.ts +1 -0
  17. package/dist/wirings/virtual-user/index.js +1 -0
  18. package/dist/wirings/virtual-user/virtual-user-derive.js +9 -0
  19. package/dist/wirings/virtual-user/virtual-user-scaffold.d.ts +267 -0
  20. package/dist/wirings/virtual-user/virtual-user-scaffold.js +400 -0
  21. package/dist/wirings/workflow/index.d.ts +1 -0
  22. package/dist/wirings/workflow/index.js +1 -0
  23. package/dist/wirings/workflow/pikku-workflow-service.js +3 -9
  24. package/dist/wirings/workflow/scenario-prose.d.ts +23 -1
  25. package/dist/wirings/workflow/scenario-prose.js +12 -3
  26. package/dist/wirings/workflow/scenario-run.types.d.ts +7 -0
  27. package/dist/wirings/workflow/workflow-queue-routing.d.ts +18 -0
  28. package/dist/wirings/workflow/workflow-queue-routing.js +35 -0
  29. package/dist/wirings/workflow/workflow-status-stream.d.ts +28 -0
  30. package/dist/wirings/workflow/workflow-status-stream.js +105 -0
  31. package/package.json +1 -1
  32. package/src/dev/hot-reload.test.ts +42 -0
  33. package/src/dev/hot-reload.ts +30 -4
  34. package/src/dev/module-runner.test.ts +56 -13
  35. package/src/dev/module-runner.ts +32 -10
  36. package/src/public-surface.json +17 -1
  37. package/src/services/email-template.test.ts +311 -0
  38. package/src/services/email-template.ts +254 -0
  39. package/src/services/http-personas.ts +10 -2
  40. package/src/services/index.ts +8 -0
  41. package/src/services/persona-sign-in.test.ts +22 -0
  42. package/src/wirings/agent/agent-helpers.test.ts +63 -0
  43. package/src/wirings/agent/agent-prepare.ts +25 -0
  44. package/src/wirings/agent/index.ts +1 -0
  45. package/src/wirings/scheduler/scheduler-runner.test.ts +178 -0
  46. package/src/wirings/scheduler/scheduler-runner.ts +0 -1
  47. package/src/wirings/virtual-user/index.ts +20 -0
  48. package/src/wirings/virtual-user/virtual-user-derive.test.ts +33 -5
  49. package/src/wirings/virtual-user/virtual-user-derive.ts +9 -0
  50. package/src/wirings/virtual-user/virtual-user-scaffold.test.ts +795 -0
  51. package/src/wirings/virtual-user/virtual-user-scaffold.ts +634 -0
  52. package/src/wirings/workflow/index.ts +4 -0
  53. package/src/wirings/workflow/pikku-workflow-service.test.ts +71 -2
  54. package/src/wirings/workflow/pikku-workflow-service.ts +5 -11
  55. package/src/wirings/workflow/scenario-prose.test.ts +134 -9
  56. package/src/wirings/workflow/scenario-prose.ts +37 -2
  57. package/src/wirings/workflow/scenario-run.types.ts +7 -0
  58. package/src/wirings/workflow/workflow-child-run-session.test.ts +79 -0
  59. package/src/wirings/workflow/workflow-queue-routing.ts +44 -0
  60. package/src/wirings/workflow/workflow-status-stream.test.ts +354 -0
  61. package/src/wirings/workflow/workflow-status-stream.ts +144 -0
  62. package/tsconfig.tsbuildinfo +1 -1
@@ -67,6 +67,7 @@
67
67
  "pikkuWorkflowGraph",
68
68
  "reconstructFinalState",
69
69
  "reconstructStateAt",
70
+ "streamWorkflowRunStatus",
70
71
  "template",
71
72
  "uuidv5"
72
73
  ],
@@ -103,22 +104,35 @@
103
104
  "IntentStack",
104
105
  "PRODUCTION_DISPOSITION",
105
106
  "STALE_RUN_AFTER_MS",
107
+ "VIRTUAL_USER_VARIABLES",
106
108
  "catalogueClassification",
107
109
  "catalogueLookup",
108
110
  "deriveCatalogue",
109
111
  "deriveIntents",
110
112
  "dispositionProfile",
113
+ "executeVirtualUserRun",
111
114
  "intentsForPersona",
112
115
  "isDue",
113
116
  "isReadOnly",
117
+ "logVirtualUserTick",
114
118
  "nextRunAt",
115
119
  "personaScopes",
116
120
  "personaVirtualUserTarget",
117
121
  "prepareVirtualUserRun",
118
122
  "reachableCatalogue",
123
+ "requireVirtualUserRunStore",
124
+ "requireVirtualUserScheduleStore",
119
125
  "runVirtualUser",
126
+ "runnablePersona",
127
+ "serializeVirtualUserRun",
128
+ "serializeVirtualUserSchedule",
129
+ "serializeVirtualUserSteps",
130
+ "signInPathFor",
131
+ "startVirtualUserRun",
120
132
  "tickVirtualUserSchedules",
121
- "unreachableCatalogue"
133
+ "unreachableCatalogue",
134
+ "virtualUserScheduleRunInput",
135
+ "writeVirtualUserSchedule"
122
136
  ],
123
137
  "./channel/local": [
124
138
  "LocalEventHubService",
@@ -203,6 +217,7 @@
203
217
  "addAgent",
204
218
  "agent",
205
219
  "agentApprove",
220
+ "agentCallOptions",
206
221
  "agentInterrupt",
207
222
  "agentResume",
208
223
  "agentStream",
@@ -384,6 +399,7 @@
384
399
  "getStubTracker",
385
400
  "isTestRun",
386
401
  "pikkuWebhookWorkerFunc",
402
+ "renderEmail",
387
403
  "scenarioArtifactContentType",
388
404
  "scenarioRunSummary",
389
405
  "spy",
@@ -0,0 +1,311 @@
1
+ import assert from 'node:assert'
2
+ import { describe, test } from 'node:test'
3
+ import { renderEmail, type EmailAssets } from './email-template.js'
4
+
5
+ const THEME = {
6
+ appName: 'Pikku App',
7
+ colors: {
8
+ background: '#f5f7fb',
9
+ surface: '#ffffff',
10
+ text: '#101828',
11
+ border: '#d0d5dd',
12
+ primary: '#7c3aed',
13
+ },
14
+ fonts: {
15
+ body: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
16
+ },
17
+ }
18
+
19
+ const LOCALES = {
20
+ en: {
21
+ passwordReset: {
22
+ subject: 'Reset your {{appName}} password',
23
+ note: 'If you did not ask for this, you can ignore it.',
24
+ cta: 'Choose a new password',
25
+ },
26
+ },
27
+ }
28
+
29
+ const PARTIALS = {
30
+ layout: `<!doctype html>
31
+ <html lang="{{locale}}">
32
+ <head><title>{{subject}}</title></head>
33
+ <body style="font-family:{{theme.fonts.body}};color:{{theme.colors.text}};">
34
+ <div>{{content}}</div>
35
+ </body>
36
+ </html>`,
37
+ footer: `<div class="footer" style="color:{{theme.colors.border}};">{{t.passwordReset.note}}</div>`,
38
+ }
39
+
40
+ const HTML = `<div>
41
+ <p>Hello {{userName}}.</p>
42
+ <a href="{{resetUrl}}" style="background:{{theme.colors.primary}};">{{t.passwordReset.cta}}</a>
43
+ <p>{{{rawNotice}}}</p>
44
+ </div>
45
+ {{> footer}}`
46
+
47
+ const SUBJECT = `{{t.passwordReset.subject}}\n`
48
+
49
+ const TEXT = `{{t.passwordReset.subject}}
50
+
51
+ Hello {{userName}}.
52
+
53
+ {{t.passwordReset.cta}}: {{resetUrl}}
54
+ `
55
+
56
+ const ASSETS: EmailAssets = {
57
+ theme: THEME,
58
+ locales: LOCALES,
59
+ partials: PARTIALS,
60
+ templates: {
61
+ 'password-reset': {
62
+ html: HTML,
63
+ subject: SUBJECT,
64
+ text: TEXT,
65
+ variables: ['appName', 'rawNotice', 'resetUrl', 'userName'],
66
+ hashes: {
67
+ en: {
68
+ contentHash: 'content-hash',
69
+ htmlHash: 'html-hash',
70
+ subjectHash: 'subject-hash',
71
+ textHash: 'text-hash',
72
+ },
73
+ },
74
+ },
75
+ },
76
+ }
77
+
78
+ const render = (data: Record<string, unknown>) =>
79
+ renderEmail(ASSETS, { name: 'password-reset', locale: 'en', data })
80
+
81
+ describe('renderEmail escaping', () => {
82
+ test('a value containing a double quote cannot break out of an attribute', () => {
83
+ const { html } = render({
84
+ resetUrl: 'https://x.test/" onmouseover="alert(1)',
85
+ })
86
+ assert.ok(
87
+ !html.includes('onmouseover="alert(1)"'),
88
+ `attribute broke out:\n${html}`
89
+ )
90
+ assert.ok(
91
+ html.includes('href="https://x.test/&quot; onmouseover=&quot;alert(1)"'),
92
+ `quote was not escaped:\n${html}`
93
+ )
94
+ })
95
+
96
+ test('a value containing markup renders escaped, with no raw script tag', () => {
97
+ const { html } = render({
98
+ resetUrl: '"><script>alert(1)</script><a href="',
99
+ })
100
+ assert.ok(!html.includes('<script>'), `raw <script> present:\n${html}`)
101
+ assert.ok(html.includes('&lt;script&gt;alert(1)&lt;/script&gt;'))
102
+ })
103
+
104
+ test('a value containing handlebars is not re-expanded', () => {
105
+ const { html } = render({ resetUrl: '{{t.passwordReset.note}}' })
106
+ assert.ok(
107
+ !html.includes('If you did not ask for this, you can ignore it.</a>'),
108
+ `caller value was re-expanded as a template:\n${html}`
109
+ )
110
+ assert.ok(html.includes('href="{{t.passwordReset.note}}"'))
111
+ })
112
+
113
+ test('a value cannot forge a partial reference', () => {
114
+ const { html } = render({ resetUrl: '{{> footer}}' })
115
+ assert.ok(html.includes('href="{{&gt; footer}}"'), html)
116
+ })
117
+
118
+ test('a font stack with embedded quotes renders a valid style attribute', () => {
119
+ const { html } = render({ resetUrl: 'https://x.test/reset' })
120
+ assert.ok(
121
+ html.includes(
122
+ '<body style="font-family:-apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, Roboto, sans-serif;color:#101828;">'
123
+ ),
124
+ `theme font stack corrupted the style attribute:\n${html}`
125
+ )
126
+ })
127
+
128
+ test('an app name with an apostrophe and an ampersand stays inside the tag', () => {
129
+ const { html, subject } = render({
130
+ appName: "Peet's & Co",
131
+ resetUrl: 'https://x.test/reset',
132
+ })
133
+ assert.ok(
134
+ html.includes('<title>Reset your Peet&#39;s &amp; Co password</title>'),
135
+ html
136
+ )
137
+ assert.strictEqual(subject, "Reset your Peet's & Co password")
138
+ })
139
+
140
+ test('{{content}} and partials still render raw', () => {
141
+ const { html } = render({ resetUrl: 'https://x.test/reset' })
142
+ assert.ok(html.includes('<a href="https://x.test/reset"'), html)
143
+ assert.ok(html.includes('<div class="footer"'), html)
144
+ assert.ok(
145
+ html.includes('>If you did not ask for this, you can ignore it.</div>'),
146
+ html
147
+ )
148
+ })
149
+
150
+ test('the triple-brace form is an opt-in raw escape hatch', () => {
151
+ const { html } = render({
152
+ resetUrl: 'https://x.test/reset',
153
+ rawNotice: '<strong>read me</strong>',
154
+ })
155
+ assert.ok(html.includes('<p><strong>read me</strong></p>'), html)
156
+ })
157
+
158
+ test('locale strings that reference caller variables still expand', () => {
159
+ const { subject, text } = render({
160
+ userName: 'Ada',
161
+ resetUrl: 'https://x.test/reset',
162
+ })
163
+ assert.strictEqual(subject, 'Reset your Pikku App password')
164
+ assert.ok(text?.includes('Hello Ada.'), text)
165
+ assert.ok(
166
+ text?.includes('Choose a new password: https://x.test/reset'),
167
+ text
168
+ )
169
+ })
170
+
171
+ test('plain-text outputs are not html escaped', () => {
172
+ const { text } = render({
173
+ userName: 'Ada & Bob',
174
+ resetUrl: 'https://x.test/reset?a=1&b=2',
175
+ })
176
+ assert.ok(text?.includes('Hello Ada & Bob.'), text)
177
+ assert.ok(text?.includes('https://x.test/reset?a=1&b=2'), text)
178
+ })
179
+
180
+ // The recursion bounds are why a malicious partial or locale string cannot
181
+ // hang the render, and neither is reachable from the generated wrapper's
182
+ // types — only a template author can trip them.
183
+ test('a partial that includes itself terminates instead of hanging', () => {
184
+ const { html } = renderEmail(
185
+ {
186
+ ...ASSETS,
187
+ partials: { loop: '<i>{{> loop}}</i>' },
188
+ templates: {
189
+ ...ASSETS.templates,
190
+ 'password-reset': {
191
+ ...ASSETS.templates['password-reset']!,
192
+ html: '{{> loop}}',
193
+ },
194
+ },
195
+ },
196
+ { name: 'password-reset', locale: 'en', data: {} }
197
+ )
198
+ assert.equal(html.match(/<i>/g)?.length, 5)
199
+ })
200
+
201
+ test('a locale string that references itself terminates instead of hanging', () => {
202
+ const { subject } = renderEmail(
203
+ {
204
+ ...ASSETS,
205
+ locales: { en: { loop: 'a{{t.loop}}' } },
206
+ templates: {
207
+ ...ASSETS.templates,
208
+ 'password-reset': {
209
+ ...ASSETS.templates['password-reset']!,
210
+ subject: '{{t.loop}}',
211
+ },
212
+ },
213
+ },
214
+ { name: 'password-reset', locale: 'en', data: {} }
215
+ )
216
+ // Five trusted passes plus the final substitute pass, and the token the
217
+ // last one produced is left as text rather than expanded again.
218
+ assert.equal(subject, 'aaaaaa{{t.loop}}')
219
+ })
220
+ })
221
+
222
+ describe('renderEmail lookups', () => {
223
+ test('an unknown template names itself rather than rendering an empty mail', () => {
224
+ assert.throws(
225
+ () => renderEmail(ASSETS, { name: 'nope' }),
226
+ /Unknown email template: nope/
227
+ )
228
+ })
229
+
230
+ test('an unknown locale names itself', () => {
231
+ assert.throws(
232
+ () => renderEmail(ASSETS, { name: 'password-reset', locale: 'de' }),
233
+ /Unknown email locale: de/
234
+ )
235
+ })
236
+
237
+ test('the locale defaults to en', () => {
238
+ assert.equal(renderEmail(ASSETS, { name: 'password-reset' }).locale, 'en')
239
+ })
240
+
241
+ test('the content hash comes from the rendered locale', () => {
242
+ assert.equal(render({}).hash, 'content-hash')
243
+ })
244
+
245
+ test('a template with no text produces no text field at all', () => {
246
+ const result = renderEmail(
247
+ {
248
+ ...ASSETS,
249
+ templates: {
250
+ 'password-reset': {
251
+ ...ASSETS.templates['password-reset']!,
252
+ text: '',
253
+ },
254
+ },
255
+ },
256
+ { name: 'password-reset' }
257
+ )
258
+ assert.equal('text' in result, false)
259
+ })
260
+ })
261
+
262
+ /**
263
+ * `content` is the layout's slot, not a name a caller may fill. It is the one
264
+ * value written without escaping, because by then it holds a body that has
265
+ * already been rendered and escaped.
266
+ */
267
+ describe('renderEmail treats content as the layout slot only', () => {
268
+ const assets = (html: string, layout?: string): EmailAssets => ({
269
+ theme: { appName: 'Shop' },
270
+ locales: { en: {} },
271
+ partials: layout ? { layout } : {},
272
+ templates: {
273
+ welcome: {
274
+ html,
275
+ subject: 'Hi',
276
+ text: '',
277
+ variables: [],
278
+ hashes: {
279
+ en: {
280
+ contentHash: 'h',
281
+ htmlHash: 'h',
282
+ subjectHash: 'h',
283
+ textHash: 'h',
284
+ },
285
+ },
286
+ },
287
+ },
288
+ })
289
+
290
+ test('caller data named content is escaped like any other value', () => {
291
+ const { html } = renderEmail(assets('<p>{{ content }}</p>'), {
292
+ name: 'welcome',
293
+ data: { content: '<img src=x onerror=alert(1)>' },
294
+ })
295
+
296
+ assert.ok(
297
+ !html.includes('<img src=x'),
298
+ `caller content must not be written raw, got: ${html}`
299
+ )
300
+ assert.match(html, /&lt;img src=x/)
301
+ })
302
+
303
+ test('the layout still receives the rendered body unescaped', () => {
304
+ const { html } = renderEmail(
305
+ assets('<p>hello</p>', '<main>{{ content }}</main>'),
306
+ { name: 'welcome' }
307
+ )
308
+
309
+ assert.equal(html, '<main><p>hello</p></main>')
310
+ })
311
+ })
@@ -0,0 +1,254 @@
1
+ /**
2
+ * The renderer behind a generated `pikku-emails.gen.ts`.
3
+ *
4
+ * The generated module supplies the assets — theme, locale strings, partials and
5
+ * the templates themselves — and a typed wrapper over `renderEmail`. Everything
6
+ * here is the same for every application, which is why it lives in core rather
7
+ * than in the string the CLI writes: this is HTML escaping, and code inside a
8
+ * template literal is never compiled, never linted, and testable only by
9
+ * matching the text it emits.
10
+ */
11
+
12
+ export interface EmailTemplateHashes {
13
+ contentHash: string
14
+ htmlHash: string
15
+ subjectHash: string
16
+ textHash: string
17
+ }
18
+
19
+ export interface EmailTemplateAssets {
20
+ html: string
21
+ subject: string
22
+ text: string
23
+ variables: ReadonlyArray<string>
24
+ hashes: Record<string, EmailTemplateHashes>
25
+ }
26
+
27
+ export interface EmailAssets {
28
+ theme: Record<string, unknown>
29
+ locales: Record<string, Record<string, unknown>>
30
+ partials: Record<string, string>
31
+ templates: Record<string, EmailTemplateAssets>
32
+ }
33
+
34
+ export interface RenderEmailRequest {
35
+ name: string
36
+ locale?: string
37
+ data?: Record<string, unknown>
38
+ }
39
+
40
+ export interface RenderedEmailResult {
41
+ locale: string
42
+ subject: string
43
+ html: string
44
+ text?: string
45
+ variables: ReadonlyArray<string>
46
+ hash: string
47
+ }
48
+
49
+ const HTML_ESCAPES: Record<string, string> = {
50
+ '&': '&amp;',
51
+ '<': '&lt;',
52
+ '>': '&gt;',
53
+ '"': '&quot;',
54
+ "'": '&#39;',
55
+ }
56
+
57
+ const escapeHtml = (value: string): string =>
58
+ value.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char] ?? char)
59
+
60
+ // Matches the raw {{{ value }}} form before the escaped {{ value }} form, so the
61
+ // opt-in escape hatch is never mistaken for a normal substitution.
62
+ const TEMPLATE_TOKEN = /\{\{\{\s*([^{}]+?)\s*\}\}\}|\{\{\s*([^}]+?)\s*\}\}/g
63
+
64
+ const PARTIAL_TOKEN = /\{\{\s*>\s*([a-zA-Z0-9-_/.]+)\s*\}\}/g
65
+
66
+ const MAX_TEMPLATE_DEPTH = 5
67
+
68
+ // theme.json and the locale files ship with the templates, so they are treated as
69
+ // template-author input: expanded before caller data and allowed to contain their
70
+ // own placeholders. Everything else is caller-supplied.
71
+ const TRUSTED_ROOTS = ['theme', 't']
72
+
73
+ const isTrustedKey = (key: string): boolean =>
74
+ TRUSTED_ROOTS.includes(String(key.split('.')[0]))
75
+
76
+ const getNestedValue = (
77
+ source: Record<string, unknown>,
78
+ path: string
79
+ ): string => {
80
+ const segments = path.split('.')
81
+ let current: unknown = source
82
+ for (const segment of segments) {
83
+ // `hasOwn`, not `in`: `in` walks the prototype chain, so a path is answered
84
+ // by what an object inherits rather than only by what it carries. Nothing
85
+ // inherited reaches the output today — every step past a prototype hit lands
86
+ // on a function, which is neither traversed nor written — so this closes the
87
+ // lookup rather than fixing a value that escapes through it.
88
+ if (
89
+ !current ||
90
+ typeof current !== 'object' ||
91
+ !Object.hasOwn(current, segment)
92
+ ) {
93
+ return ''
94
+ }
95
+ current = (current as Record<string, unknown>)[segment]
96
+ }
97
+ return typeof current === 'string' || typeof current === 'number'
98
+ ? String(current)
99
+ : ''
100
+ }
101
+
102
+ const readToken = (rawTriple: unknown, rawDouble: unknown) => {
103
+ const raw = typeof rawTriple === 'string'
104
+ return { raw, key: String(raw ? rawTriple : rawDouble).trim() }
105
+ }
106
+
107
+ const expandPartials = (
108
+ source: string,
109
+ partials: Record<string, string>,
110
+ depth = 0
111
+ ): string => {
112
+ if (depth >= MAX_TEMPLATE_DEPTH) return source
113
+ let found = false
114
+ const expanded = source.replace(PARTIAL_TOKEN, (_match, partialName) => {
115
+ found = true
116
+ const partial = partials[String(partialName).trim()]
117
+ return typeof partial === 'string' ? partial : ''
118
+ })
119
+ return found ? expandPartials(expanded, partials, depth + 1) : expanded
120
+ }
121
+
122
+ const expandTrusted = (
123
+ source: string,
124
+ context: Record<string, unknown>,
125
+ escape: boolean
126
+ ): string => {
127
+ let rendered = source
128
+ for (let i = 0; i < MAX_TEMPLATE_DEPTH; i += 1) {
129
+ let found = false
130
+ const next = rendered.replace(
131
+ TEMPLATE_TOKEN,
132
+ (match, rawTriple, rawDouble) => {
133
+ const { raw, key } = readToken(rawTriple, rawDouble)
134
+ if (!isTrustedKey(key)) return match
135
+ found = true
136
+ const value = getNestedValue(context, key)
137
+ return raw || !escape ? value : escapeHtml(value)
138
+ }
139
+ )
140
+ if (!found || next === rendered) break
141
+ rendered = next
142
+ }
143
+ return rendered
144
+ }
145
+
146
+ // A single substitution pass — the replacement text is never rescanned, so a
147
+ // caller-supplied value can never be reinterpreted as a template.
148
+ const substitute = (
149
+ source: string,
150
+ context: Record<string, unknown>,
151
+ escape: boolean,
152
+ slot?: string
153
+ ): string =>
154
+ source.replace(TEMPLATE_TOKEN, (_match, rawTriple, rawDouble) => {
155
+ const { raw, key } = readToken(rawTriple, rawDouble)
156
+ // `content` is the layout's slot for the body that was already rendered and
157
+ // escaped, so it is the one value written in raw. Only the layout gets it:
158
+ // honouring it everywhere would let a caller pass `data.content` into a
159
+ // template that happens to name it and have it emitted unescaped.
160
+ if (slot !== undefined && key === slot) {
161
+ return typeof context[slot] === 'string' ? (context[slot] as string) : ''
162
+ }
163
+ if (key.startsWith('>')) {
164
+ return ''
165
+ }
166
+ const value = getNestedValue(context, key)
167
+ return raw || !escape ? value : escapeHtml(value)
168
+ })
169
+
170
+ const renderTemplate = (
171
+ source: string,
172
+ partials: Record<string, string>,
173
+ context: Record<string, unknown>,
174
+ escape: boolean,
175
+ slot?: string
176
+ ): string => {
177
+ const composed = expandTrusted(
178
+ expandPartials(source, partials),
179
+ context,
180
+ escape
181
+ )
182
+ return substitute(composed, context, escape, slot)
183
+ }
184
+
185
+ export const renderEmail = (
186
+ { theme, locales, partials, templates }: EmailAssets,
187
+ { name, locale: requestedLocale, data }: RenderEmailRequest
188
+ ): RenderedEmailResult => {
189
+ const locale = requestedLocale ?? 'en'
190
+ const template = templates[name]
191
+ if (!template) {
192
+ throw new Error(`Unknown email template: ${name}`)
193
+ }
194
+
195
+ const strings = locales[locale]
196
+ if (!strings) {
197
+ throw new Error(`Unknown email locale: ${locale}`)
198
+ }
199
+
200
+ const values = data ?? {}
201
+ const appName =
202
+ (typeof values.appName === 'string' && values.appName) ||
203
+ getNestedValue(theme, 'appName')
204
+
205
+ const baseContext = {
206
+ ...values,
207
+ locale,
208
+ theme,
209
+ t: strings,
210
+ appName,
211
+ }
212
+
213
+ const subject = renderTemplate(
214
+ template.subject,
215
+ partials,
216
+ baseContext,
217
+ false
218
+ ).trim()
219
+
220
+ const htmlBody = renderTemplate(
221
+ template.html,
222
+ partials,
223
+ { ...baseContext, subject },
224
+ true
225
+ )
226
+
227
+ const html = partials.layout
228
+ ? renderTemplate(
229
+ partials.layout,
230
+ partials,
231
+ { ...baseContext, subject, content: htmlBody },
232
+ true,
233
+ 'content'
234
+ )
235
+ : htmlBody
236
+
237
+ const text = template.text
238
+ ? renderTemplate(
239
+ template.text,
240
+ partials,
241
+ { ...baseContext, subject },
242
+ false
243
+ ).trim()
244
+ : undefined
245
+
246
+ return {
247
+ locale,
248
+ subject,
249
+ html,
250
+ ...(text ? { text } : {}),
251
+ variables: template.variables,
252
+ hash: template.hashes[locale]?.contentHash ?? '',
253
+ }
254
+ }
@@ -51,7 +51,12 @@ export interface HttpPersonasConfig {
51
51
  operator?: OperatorSignInOptions
52
52
  /** Persona id → the declaration with its address filled in. */
53
53
  personas: Record<string, ResolvedPersona>
54
- /** Sign-in path under apiUrl. Default: the actor plugin's `/auth/sign-in/actor`. */
54
+ /**
55
+ * Sign-in path under apiUrl, for whichever of the two paths is in use — an
56
+ * app that mounts auth under `/api` moves both. Default: the actor plugin's
57
+ * `/auth/sign-in/actor`, or `/auth/sign-in/fabric` for an operator.
58
+ * {@link OperatorSignInOptions.signInPath} overrides it.
59
+ */
55
60
  signInPath?: string
56
61
  /** Where the session (and its roles) is read back. Default `/auth/get-session`. */
57
62
  sessionPath?: string
@@ -92,7 +97,10 @@ export class HttpPersona implements ScenarioPersona {
92
97
  ) {
93
98
  this.jar = createCookieJar(config.apiUrl)
94
99
  if (config.operator) {
95
- this.signIn = new OperatorSignIn(config.apiUrl, config.operator)
100
+ this.signIn = new OperatorSignIn(config.apiUrl, {
101
+ ...config.operator,
102
+ signInPath: config.operator.signInPath ?? config.signInPath,
103
+ })
96
104
  } else if (config.secret) {
97
105
  this.signIn = new ActorSignIn(
98
106
  config.apiUrl,
@@ -53,6 +53,14 @@ export type {
53
53
  SendTemplateEmailInput,
54
54
  SendTextEmailInput,
55
55
  } from './email-service.js'
56
+ export {
57
+ renderEmail,
58
+ type EmailAssets,
59
+ type EmailTemplateAssets,
60
+ type EmailTemplateHashes,
61
+ type RenderEmailRequest,
62
+ type RenderedEmailResult,
63
+ } from './email-template.js'
56
64
  export {
57
65
  DEFAULT_WEBHOOK_RETRIES,
58
66
  PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME,
@@ -115,6 +115,28 @@ describe('operator persona sign-in', () => {
115
115
  assert.equal(stage.createdCount, 0)
116
116
  })
117
117
 
118
+ // An app that mounts auth somewhere other than the root moves both sign-in
119
+ // paths together, and `SCENARIO_SIGN_IN_PATH` is the only place it can say so.
120
+ test('honours a moved sign-in path the same way the actor path does', async () => {
121
+ const stage = await startStage([
122
+ { id: 'user-7', email: 'customer@personas.invalid' },
123
+ ])
124
+ servers.push(stage.server)
125
+
126
+ const personas = createHttpPersonas({
127
+ apiUrl: stage.apiUrl.replace(/\/api$/, ''),
128
+ operator: { token: OPERATOR_TOKEN },
129
+ signInPath: '/api/auth/sign-in/fabric',
130
+ rpcPath: '/api/rpc',
131
+ personas: { customer: persona('customer@personas.invalid') },
132
+ })
133
+
134
+ const result = (await personas.customer!.invoke('whoami', {})) as {
135
+ actingAs: string | null
136
+ }
137
+ assert.equal(result.actingAs, 'user-7')
138
+ })
139
+
118
140
  test('refuses a persona the stage has no account for', async () => {
119
141
  const stage = await startStage([])
120
142
  servers.push(stage.server)