@pikku/core 0.12.93 → 0.12.95
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.
- package/CHANGELOG.md +157 -0
- package/dist/services/email-template.d.ts +43 -0
- package/dist/services/email-template.js +139 -0
- package/dist/services/http-personas.d.ts +6 -1
- package/dist/services/http-personas.js +4 -1
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/wirings/agent/agent-prepare.d.ts +14 -0
- package/dist/wirings/agent/agent-prepare.js +24 -0
- package/dist/wirings/agent/index.d.ts +1 -1
- package/dist/wirings/agent/index.js +1 -1
- package/dist/wirings/persona/index.d.ts +1 -0
- package/dist/wirings/persona/index.js +1 -0
- package/dist/wirings/persona/persona-app-scopes.d.ts +41 -0
- package/dist/wirings/persona/persona-app-scopes.js +61 -0
- package/dist/wirings/scheduler/scheduler-runner.js +0 -1
- package/dist/wirings/virtual-user/index.d.ts +1 -0
- package/dist/wirings/virtual-user/index.js +1 -0
- package/dist/wirings/virtual-user/virtual-user-derive.js +9 -0
- package/dist/wirings/virtual-user/virtual-user-scaffold.d.ts +267 -0
- package/dist/wirings/virtual-user/virtual-user-scaffold.js +400 -0
- package/dist/wirings/workflow/index.d.ts +1 -0
- package/dist/wirings/workflow/index.js +1 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +3 -9
- package/dist/wirings/workflow/workflow-queue-routing.d.ts +18 -0
- package/dist/wirings/workflow/workflow-queue-routing.js +35 -0
- package/dist/wirings/workflow/workflow-status-stream.d.ts +28 -0
- package/dist/wirings/workflow/workflow-status-stream.js +105 -0
- package/package.json +1 -1
- package/src/public-surface.json +20 -1
- package/src/services/email-template.test.ts +311 -0
- package/src/services/email-template.ts +254 -0
- package/src/services/http-personas.ts +10 -2
- package/src/services/index.ts +8 -0
- package/src/services/persona-sign-in.test.ts +22 -0
- package/src/wirings/agent/agent-helpers.test.ts +63 -0
- package/src/wirings/agent/agent-prepare.ts +25 -0
- package/src/wirings/agent/index.ts +1 -0
- package/src/wirings/persona/index.ts +5 -0
- package/src/wirings/persona/persona-app-scopes.test.ts +47 -0
- package/src/wirings/persona/persona-app-scopes.ts +74 -0
- package/src/wirings/scheduler/scheduler-runner.test.ts +178 -0
- package/src/wirings/scheduler/scheduler-runner.ts +0 -1
- package/src/wirings/virtual-user/index.ts +20 -0
- package/src/wirings/virtual-user/virtual-user-derive.test.ts +28 -0
- package/src/wirings/virtual-user/virtual-user-derive.ts +9 -0
- package/src/wirings/virtual-user/virtual-user-scaffold.test.ts +795 -0
- package/src/wirings/virtual-user/virtual-user-scaffold.ts +634 -0
- package/src/wirings/workflow/index.ts +4 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +71 -2
- package/src/wirings/workflow/pikku-workflow-service.ts +5 -11
- package/src/wirings/workflow/workflow-child-run-session.test.ts +79 -0
- package/src/wirings/workflow/workflow-queue-routing.ts +44 -0
- package/src/wirings/workflow/workflow-status-stream.test.ts +354 -0
- package/src/wirings/workflow/workflow-status-stream.ts +144 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
+
'&': '&',
|
|
51
|
+
'<': '<',
|
|
52
|
+
'>': '>',
|
|
53
|
+
'"': '"',
|
|
54
|
+
"'": ''',
|
|
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
|
-
/**
|
|
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,
|
|
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,
|
package/src/services/index.ts
CHANGED
|
@@ -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)
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
agentResume,
|
|
7
7
|
agentApprove,
|
|
8
8
|
} from './agent-helpers.js'
|
|
9
|
+
import { agentCallOptions } from './agent-prepare.js'
|
|
9
10
|
|
|
10
11
|
describe('agent helpers', () => {
|
|
11
12
|
describe('agent', () => {
|
|
@@ -150,3 +151,65 @@ describe('agent helpers', () => {
|
|
|
150
151
|
})
|
|
151
152
|
})
|
|
152
153
|
})
|
|
154
|
+
|
|
155
|
+
describe('agentCallOptions', () => {
|
|
156
|
+
// An explicit `undefined` overrides the agent's own declared default with
|
|
157
|
+
// nothing, so a request that names no model would silently unset the one the
|
|
158
|
+
// agent declares.
|
|
159
|
+
test('a field nobody supplied is left out rather than sent as undefined', () => {
|
|
160
|
+
const options = agentCallOptions({
|
|
161
|
+
message: 'hello',
|
|
162
|
+
threadId: 't1',
|
|
163
|
+
resourceId: 'r1',
|
|
164
|
+
})
|
|
165
|
+
assert.equal('model' in options, false)
|
|
166
|
+
assert.equal('temperature' in options, false)
|
|
167
|
+
assert.equal('context' in options, false)
|
|
168
|
+
assert.equal('attachments' in options, false)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('what was supplied is carried through unchanged', () => {
|
|
172
|
+
const options = agentCallOptions({
|
|
173
|
+
message: 'hello',
|
|
174
|
+
threadId: 't1',
|
|
175
|
+
resourceId: 'user-1',
|
|
176
|
+
model: 'a-model',
|
|
177
|
+
temperature: 0.2,
|
|
178
|
+
context: 'some context',
|
|
179
|
+
attachments: [{ type: 'image' as const, url: 'a.png' }],
|
|
180
|
+
})
|
|
181
|
+
assert.deepEqual(options, {
|
|
182
|
+
message: 'hello',
|
|
183
|
+
threadId: 't1',
|
|
184
|
+
resourceId: 'user-1',
|
|
185
|
+
attachments: [{ type: 'image' as const, url: 'a.png' }],
|
|
186
|
+
model: 'a-model',
|
|
187
|
+
temperature: 0.2,
|
|
188
|
+
context: 'some context',
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// Zero is a temperature a caller can mean, and the falsy check every other
|
|
193
|
+
// field uses would drop it.
|
|
194
|
+
test('a temperature of zero survives, unlike an empty string', () => {
|
|
195
|
+
assert.equal(
|
|
196
|
+
agentCallOptions({
|
|
197
|
+
message: 'x',
|
|
198
|
+
threadId: 't',
|
|
199
|
+
resourceId: 'r',
|
|
200
|
+
temperature: 0,
|
|
201
|
+
}).temperature,
|
|
202
|
+
0
|
|
203
|
+
)
|
|
204
|
+
assert.equal(
|
|
205
|
+
'context' in
|
|
206
|
+
agentCallOptions({
|
|
207
|
+
message: 'x',
|
|
208
|
+
threadId: 't',
|
|
209
|
+
resourceId: 'r',
|
|
210
|
+
context: '',
|
|
211
|
+
}),
|
|
212
|
+
false
|
|
213
|
+
)
|
|
214
|
+
})
|
|
215
|
+
})
|
|
@@ -149,6 +149,31 @@ export function canAccessThread(
|
|
|
149
149
|
)
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/**
|
|
153
|
+
* An agent call with the fields nobody supplied left out.
|
|
154
|
+
*
|
|
155
|
+
* Omitted rather than passed as `undefined`, because an explicit `undefined`
|
|
156
|
+
* overrides the agent's own declared default with nothing — a request that
|
|
157
|
+
* names no model would silently unset the one the agent declares.
|
|
158
|
+
*
|
|
159
|
+
* Shared by the scaffolded `run` and `stream` routes, which receive the same
|
|
160
|
+
* input and differ only in what they do with the reply. `agentName` is not part
|
|
161
|
+
* of it: both callers pass that separately, because `rpc.agent.run` and
|
|
162
|
+
* `rpc.agent.stream` take it as their first argument and type the rest
|
|
163
|
+
* against it.
|
|
164
|
+
*/
|
|
165
|
+
export const agentCallOptions = (input: AgentInput): AgentInput => ({
|
|
166
|
+
message: input.message,
|
|
167
|
+
threadId: input.threadId,
|
|
168
|
+
resourceId: input.resourceId,
|
|
169
|
+
...(input.attachments ? { attachments: input.attachments } : {}),
|
|
170
|
+
...(input.model ? { model: input.model } : {}),
|
|
171
|
+
...(input.temperature !== undefined
|
|
172
|
+
? { temperature: input.temperature }
|
|
173
|
+
: {}),
|
|
174
|
+
...(input.context ? { context: input.context } : {}),
|
|
175
|
+
})
|
|
176
|
+
|
|
152
177
|
export type StreamAgentOptions = {
|
|
153
178
|
requiresToolApproval?: 'all' | 'explicit' | false
|
|
154
179
|
onRunCreated?: (runId: string) => void
|
|
@@ -16,6 +16,11 @@ export type {
|
|
|
16
16
|
PersonaEnvironmentSubject,
|
|
17
17
|
} from './persona-environments.js'
|
|
18
18
|
export { personaEmail, personaEmails } from './persona-email.js'
|
|
19
|
+
export {
|
|
20
|
+
APP_SCOPE_ROOT,
|
|
21
|
+
appScopeId,
|
|
22
|
+
buildAppScopeDefinition,
|
|
23
|
+
} from './persona-app-scopes.js'
|
|
19
24
|
export type {
|
|
20
25
|
MailboxAllowlist,
|
|
21
26
|
PersonaMailbox,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import {
|
|
4
|
+
appScopeId,
|
|
5
|
+
buildAppScopeDefinition,
|
|
6
|
+
declaredApps,
|
|
7
|
+
} from './persona-app-scopes.js'
|
|
8
|
+
|
|
9
|
+
const persona = (id: string, app?: string) =>
|
|
10
|
+
({ id, name: id, roles: [], goals: [], tags: [], runnable: true, app }) as any
|
|
11
|
+
|
|
12
|
+
describe('declaredApps', () => {
|
|
13
|
+
test('deduplicates and sorts the apps the personas name', () => {
|
|
14
|
+
assert.deepEqual(
|
|
15
|
+
declaredApps([
|
|
16
|
+
persona('a', 'staff'),
|
|
17
|
+
persona('b', 'portal'),
|
|
18
|
+
persona('c', 'staff'),
|
|
19
|
+
]),
|
|
20
|
+
['portal', 'staff']
|
|
21
|
+
)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('ignores personas that name no app', () => {
|
|
25
|
+
assert.deepEqual(declaredApps([persona('a'), persona('b', '')]), [])
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('buildAppScopeDefinition', () => {
|
|
30
|
+
test('renders one grantable child per app under the app root', () => {
|
|
31
|
+
const definition = buildAppScopeDefinition([
|
|
32
|
+
persona('a', 'staff'),
|
|
33
|
+
persona('b', 'portal'),
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
assert.equal(definition?.name, 'app')
|
|
37
|
+
assert.deepEqual(Object.keys(definition?.scopes ?? {}).sort(), [
|
|
38
|
+
'portal',
|
|
39
|
+
'staff',
|
|
40
|
+
])
|
|
41
|
+
assert.equal(appScopeId('staff'), 'app:staff')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('is null for a single-frontend product', () => {
|
|
45
|
+
assert.equal(buildAppScopeDefinition([persona('a')]), null)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ScopeDefinitionMeta, ScopeNodeMeta } from '../scope/scope.types.js'
|
|
2
|
+
import type { PersonaMeta } from './persona.types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The root every app grant hangs off. One segment, so a grant of `app` alone
|
|
6
|
+
* means "may use all of them" under the same parent-grant rule the admin tree
|
|
7
|
+
* relies on — which is what a support operator or an internal tool wants, and
|
|
8
|
+
* what a per-app boolean column could never express.
|
|
9
|
+
*/
|
|
10
|
+
export const APP_SCOPE_ROOT = 'app'
|
|
11
|
+
|
|
12
|
+
/** `app:staff` for `staff`. */
|
|
13
|
+
export const appScopeId = (app: string) => `${APP_SCOPE_ROOT}:${app}`
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The apps named across the declared personas, sorted and deduplicated.
|
|
17
|
+
*
|
|
18
|
+
* The personas *are* the registry. There is no separate list of apps to keep in
|
|
19
|
+
* step, because a frontend nobody signs into is not a thing the auth layer has
|
|
20
|
+
* an opinion about — and the moment somebody does sign into it, they are a
|
|
21
|
+
* persona and they name it.
|
|
22
|
+
*/
|
|
23
|
+
export const declaredApps = (personas: PersonaMeta[]): string[] =>
|
|
24
|
+
[
|
|
25
|
+
...new Set(
|
|
26
|
+
personas
|
|
27
|
+
.map((persona) => persona.app)
|
|
28
|
+
.filter((app): app is string => typeof app === 'string' && app !== '')
|
|
29
|
+
),
|
|
30
|
+
].sort()
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The `app` scope tree, synthesised from the personas that name an app.
|
|
34
|
+
*
|
|
35
|
+
* Which frontend a person may sign into is a different question from what they
|
|
36
|
+
* may do once inside it, but it is the same *kind* of question — a grant that
|
|
37
|
+
* an admin can make and revoke at runtime, that resolves at the session
|
|
38
|
+
* boundary, and that a restricted API key can decline to inherit. So it is
|
|
39
|
+
* carried as a scope rather than a `can_access_<app>` column: no migration per
|
|
40
|
+
* app, one query for "which apps may this user reach", and no second
|
|
41
|
+
* authorization mechanism to keep honest.
|
|
42
|
+
*
|
|
43
|
+
* Synthesised rather than written by hand because the declaration already
|
|
44
|
+
* exists. Asking an app to also spell out `defineScope({ app: { staff: {} } })`
|
|
45
|
+
* beside its personas invites the two to drift, and the failure that produces —
|
|
46
|
+
* a persona provisioned with a grant the vocabulary no longer declares — is one
|
|
47
|
+
* `pikku scopes prune` away from silently revoking sign-in.
|
|
48
|
+
*
|
|
49
|
+
* Null when no persona names an app, which is the single-frontend case: nothing
|
|
50
|
+
* to declare, and no empty root cluttering the console's grant list.
|
|
51
|
+
*/
|
|
52
|
+
export const buildAppScopeDefinition = (
|
|
53
|
+
personas: PersonaMeta[]
|
|
54
|
+
): ScopeDefinitionMeta | null => {
|
|
55
|
+
const apps = declaredApps(personas)
|
|
56
|
+
if (apps.length === 0) {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const scopes: Record<string, ScopeNodeMeta> = {}
|
|
61
|
+
for (const app of apps) {
|
|
62
|
+
scopes[app] = {
|
|
63
|
+
displayName: app,
|
|
64
|
+
description: `May sign in to the ${app} app`,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
name: APP_SCOPE_ROOT,
|
|
70
|
+
displayName: 'Apps',
|
|
71
|
+
description: 'Which frontend a person may sign in to',
|
|
72
|
+
scopes,
|
|
73
|
+
}
|
|
74
|
+
}
|