@pikku/core 0.12.90 → 0.12.91
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 +21 -0
- package/dist/services/http-personas.d.ts +21 -7
- package/dist/services/http-personas.js +31 -28
- package/dist/services/persona-sign-in.d.ts +107 -0
- package/dist/services/persona-sign-in.js +179 -0
- package/dist/wirings/persona/index.d.ts +1 -0
- package/dist/wirings/persona/index.js +1 -0
- package/dist/wirings/workflow/scenario-cookie-jar.js +10 -1
- package/package.json +1 -1
- package/src/public-surface.json +4 -0
- package/src/services/http-personas.ts +52 -33
- package/src/services/persona-sign-in.test.ts +209 -0
- package/src/services/persona-sign-in.ts +284 -0
- package/src/wirings/persona/index.ts +9 -0
- package/src/wirings/workflow/scenario-cookie-jar.ts +12 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -16,6 +16,12 @@ import {
|
|
|
16
16
|
createCookieJar,
|
|
17
17
|
type ScenarioCookieJar,
|
|
18
18
|
} from '../wirings/workflow/scenario-cookie-jar.js'
|
|
19
|
+
import {
|
|
20
|
+
ActorSignIn,
|
|
21
|
+
OperatorSignIn,
|
|
22
|
+
type OperatorSignInOptions,
|
|
23
|
+
type PersonaSignIn,
|
|
24
|
+
} from './persona-sign-in.js'
|
|
19
25
|
import { getSingletonServices } from '../pikku-state.js'
|
|
20
26
|
import { AIProviderNotConfiguredError } from '../errors/errors.js'
|
|
21
27
|
|
|
@@ -30,8 +36,19 @@ export interface HttpPersonasConfig {
|
|
|
30
36
|
/**
|
|
31
37
|
* The impersonation secret. Sign-in only ever works for user rows flagged
|
|
32
38
|
* `actor: true` — knowing the secret never impersonates real users.
|
|
39
|
+
*
|
|
40
|
+
* The local-development credential. A deployed stage has none, and passes
|
|
41
|
+
* {@link HttpPersonasConfig.operator} instead.
|
|
42
|
+
*/
|
|
43
|
+
secret?: string
|
|
44
|
+
/**
|
|
45
|
+
* Fabric operator credentials, for signing personas into a DEPLOYED stage.
|
|
46
|
+
*
|
|
47
|
+
* Mutually exclusive with {@link HttpPersonasConfig.secret}: the operator
|
|
48
|
+
* path acts as the persona through an admin session rather than logging in as
|
|
49
|
+
* them, so no test credential has to exist on the target at all.
|
|
33
50
|
*/
|
|
34
|
-
|
|
51
|
+
operator?: OperatorSignInOptions
|
|
35
52
|
/** Persona id → the declaration with its address filled in. */
|
|
36
53
|
personas: Record<string, ResolvedPersona>
|
|
37
54
|
/** Sign-in path under apiUrl. Default: the actor plugin's `/auth/sign-in/actor`. */
|
|
@@ -49,12 +66,13 @@ export interface HttpPersonasConfig {
|
|
|
49
66
|
}
|
|
50
67
|
|
|
51
68
|
/**
|
|
52
|
-
* Default HTTP-backed persona. Signs in lazily on first invoke
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
69
|
+
* Default HTTP-backed persona. Signs in lazily on first invoke, holds the
|
|
70
|
+
* session cookies for its lifetime, and re-logs-in once on a 401 mid-run (long
|
|
71
|
+
* health-check runs can outlive a session).
|
|
72
|
+
*
|
|
73
|
+
* How it signs in depends on the target, and the two ways are not
|
|
74
|
+
* interchangeable — see {@link ActorSignIn} for local development and
|
|
75
|
+
* {@link OperatorSignIn} for a deployed stage.
|
|
58
76
|
*/
|
|
59
77
|
export class HttpPersona implements ScenarioPersona {
|
|
60
78
|
private jar: ScenarioCookieJar
|
|
@@ -65,6 +83,7 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
65
83
|
* established.
|
|
66
84
|
*/
|
|
67
85
|
private signedIn = false
|
|
86
|
+
private signIn: PersonaSignIn
|
|
68
87
|
|
|
69
88
|
constructor(
|
|
70
89
|
readonly name: string,
|
|
@@ -72,6 +91,19 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
72
91
|
private config: HttpPersonasConfig
|
|
73
92
|
) {
|
|
74
93
|
this.jar = createCookieJar(config.apiUrl)
|
|
94
|
+
if (config.operator) {
|
|
95
|
+
this.signIn = new OperatorSignIn(config.apiUrl, config.operator)
|
|
96
|
+
} else if (config.secret) {
|
|
97
|
+
this.signIn = new ActorSignIn(
|
|
98
|
+
config.apiUrl,
|
|
99
|
+
config.secret,
|
|
100
|
+
config.signInPath ?? '/auth/sign-in/actor'
|
|
101
|
+
)
|
|
102
|
+
} else {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`[scenario] persona '${name}' has no way to sign in — set 'secret' for a dev target or 'operator' for a deployed one`
|
|
105
|
+
)
|
|
106
|
+
}
|
|
75
107
|
}
|
|
76
108
|
|
|
77
109
|
get email(): string {
|
|
@@ -161,7 +193,9 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
161
193
|
await this.login()
|
|
162
194
|
}
|
|
163
195
|
const sessionPath = this.config.sessionPath ?? '/auth/get-session'
|
|
164
|
-
const res = await this.jar.fetch(`${this.config.apiUrl}${sessionPath}
|
|
196
|
+
const res = await this.jar.fetch(`${this.config.apiUrl}${sessionPath}`, {
|
|
197
|
+
headers: this.signIn.headers(),
|
|
198
|
+
})
|
|
165
199
|
if (!res.ok) {
|
|
166
200
|
return null
|
|
167
201
|
}
|
|
@@ -226,7 +260,10 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
226
260
|
const send = () =>
|
|
227
261
|
this.jar.fetch(url, {
|
|
228
262
|
method: 'POST',
|
|
229
|
-
headers: {
|
|
263
|
+
headers: {
|
|
264
|
+
'content-type': 'application/json',
|
|
265
|
+
...this.signIn.headers(),
|
|
266
|
+
},
|
|
230
267
|
body: JSON.stringify(body),
|
|
231
268
|
})
|
|
232
269
|
|
|
@@ -255,7 +292,11 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
255
292
|
const rpcPath = this.config.rpcPath ?? '/rpc'
|
|
256
293
|
return this.jar.fetch(`${this.config.apiUrl}${rpcPath}/${rpcName}`, {
|
|
257
294
|
method: 'POST',
|
|
258
|
-
headers: {
|
|
295
|
+
headers: {
|
|
296
|
+
'content-type': 'application/json',
|
|
297
|
+
...this.signIn.headers(),
|
|
298
|
+
...extraHeaders,
|
|
299
|
+
},
|
|
259
300
|
body: JSON.stringify({ data }),
|
|
260
301
|
})
|
|
261
302
|
}
|
|
@@ -267,29 +308,7 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
267
308
|
}
|
|
268
309
|
|
|
269
310
|
private async login(): Promise<void> {
|
|
270
|
-
|
|
271
|
-
const res = await this.jar.fetch(`${this.config.apiUrl}${signInPath}`, {
|
|
272
|
-
method: 'POST',
|
|
273
|
-
headers: { 'content-type': 'application/json' },
|
|
274
|
-
body: JSON.stringify({
|
|
275
|
-
email: this.persona.email,
|
|
276
|
-
name: this.persona.name,
|
|
277
|
-
secret: this.config.secret,
|
|
278
|
-
}),
|
|
279
|
-
})
|
|
280
|
-
if (!res.ok) {
|
|
281
|
-
const body = (await res.text().catch(() => '')).slice(0, 300)
|
|
282
|
-
throw new Error(
|
|
283
|
-
`[scenario] persona sign-in failed for '${this.name}' (${res.status}): ${body}`
|
|
284
|
-
)
|
|
285
|
-
}
|
|
286
|
-
// What proves a session was established is this response setting a cookie,
|
|
287
|
-
// not the jar being non-empty — the target may have set one earlier.
|
|
288
|
-
if (res.headers.getSetCookie().length === 0) {
|
|
289
|
-
throw new Error(
|
|
290
|
-
`[scenario] persona sign-in for '${this.name}' returned no session cookie`
|
|
291
|
-
)
|
|
292
|
-
}
|
|
311
|
+
await this.signIn.login(this.jar, this.persona)
|
|
293
312
|
this.signedIn = true
|
|
294
313
|
}
|
|
295
314
|
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { describe, test, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { createServer, type Server } from 'node:http'
|
|
4
|
+
|
|
5
|
+
import { createHttpPersonas } from './http-personas.js'
|
|
6
|
+
import { establishOperatorSession } from './persona-sign-in.js'
|
|
7
|
+
|
|
8
|
+
const OPERATOR_TOKEN = 'operator.jwt.token'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Minimal deployed stage: the fabric plugin's sign-in, the admin plugin's
|
|
12
|
+
* user endpoints, and an RPC route that reports back who it was addressed as.
|
|
13
|
+
*/
|
|
14
|
+
const startStage = async (seeded: Array<{ id: string; email: string }>) => {
|
|
15
|
+
const users = [...seeded]
|
|
16
|
+
let created = 0
|
|
17
|
+
const server: Server = createServer((req, res) => {
|
|
18
|
+
const chunks: Buffer[] = []
|
|
19
|
+
req.on('data', (c) => chunks.push(c))
|
|
20
|
+
req.on('end', () => {
|
|
21
|
+
const body = chunks.length
|
|
22
|
+
? JSON.parse(Buffer.concat(chunks).toString())
|
|
23
|
+
: {}
|
|
24
|
+
const url = new URL(req.url ?? '/', 'http://stage.invalid')
|
|
25
|
+
|
|
26
|
+
if (url.pathname === '/api/auth/sign-in/fabric') {
|
|
27
|
+
if (body.token !== OPERATOR_TOKEN) {
|
|
28
|
+
res.writeHead(401).end(JSON.stringify({ message: 'bad token' }))
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
res.setHeader('set-cookie', ['session=operator; Path=/; HttpOnly'])
|
|
32
|
+
res.writeHead(200).end(JSON.stringify({ token: 'operator' }))
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The admin plugin refuses an unauthenticated caller, and saying so here
|
|
37
|
+
// is the point: a handshake that dropped the operator session between
|
|
38
|
+
// sign-in and lookup would otherwise pass this stage happily.
|
|
39
|
+
if (url.pathname.startsWith('/api/auth/admin/')) {
|
|
40
|
+
if (!/(^|;\s*)session=operator(;|$)/.test(req.headers.cookie ?? '')) {
|
|
41
|
+
res.writeHead(401).end(JSON.stringify({ message: 'no session' }))
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (url.pathname === '/api/auth/admin/list-users') {
|
|
47
|
+
const wanted = url.searchParams.get('filterValue')
|
|
48
|
+
res
|
|
49
|
+
.writeHead(200, { 'content-type': 'application/json' })
|
|
50
|
+
.end(
|
|
51
|
+
JSON.stringify({ users: users.filter((u) => u.email === wanted) })
|
|
52
|
+
)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (url.pathname === '/api/auth/admin/create-user') {
|
|
57
|
+
created++
|
|
58
|
+
const user = { id: `made-${created}`, email: body.email }
|
|
59
|
+
users.push(user)
|
|
60
|
+
res
|
|
61
|
+
.writeHead(200, { 'content-type': 'application/json' })
|
|
62
|
+
.end(JSON.stringify({ user, sawPassword: Boolean(body.password) }))
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (url.pathname.startsWith('/api/rpc/')) {
|
|
67
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
68
|
+
JSON.stringify({
|
|
69
|
+
actingAs: req.headers['x-pikku-impersonate-user-id'] ?? null,
|
|
70
|
+
cookie: req.headers.cookie ?? '',
|
|
71
|
+
})
|
|
72
|
+
)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
res.writeHead(404).end()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
await new Promise<void>((resolve) => server.listen(0, resolve))
|
|
80
|
+
const port = (server.address() as { port: number }).port
|
|
81
|
+
return {
|
|
82
|
+
apiUrl: `http://127.0.0.1:${port}/api`,
|
|
83
|
+
server,
|
|
84
|
+
get createdCount() {
|
|
85
|
+
return created
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const persona = (email: string) => ({
|
|
91
|
+
id: 'customer',
|
|
92
|
+
name: 'Customer',
|
|
93
|
+
email,
|
|
94
|
+
roles: ['client'],
|
|
95
|
+
goals: [],
|
|
96
|
+
tags: [],
|
|
97
|
+
runnable: true,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('operator persona sign-in', () => {
|
|
101
|
+
const servers: Server[] = []
|
|
102
|
+
after(() => servers.forEach((s) => s.close()))
|
|
103
|
+
|
|
104
|
+
test('acts as an existing account rather than signing in as it', async () => {
|
|
105
|
+
const stage = await startStage([
|
|
106
|
+
{ id: 'user-7', email: 'customer@personas.invalid' },
|
|
107
|
+
])
|
|
108
|
+
servers.push(stage.server)
|
|
109
|
+
|
|
110
|
+
const personas = createHttpPersonas({
|
|
111
|
+
apiUrl: stage.apiUrl,
|
|
112
|
+
operator: { token: OPERATOR_TOKEN },
|
|
113
|
+
personas: { customer: persona('customer@personas.invalid') },
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const result = (await personas.customer!.invoke('whoami', {})) as {
|
|
117
|
+
actingAs: string | null
|
|
118
|
+
cookie: string
|
|
119
|
+
}
|
|
120
|
+
assert.equal(result.actingAs, 'user-7')
|
|
121
|
+
assert.match(result.cookie, /session=operator/)
|
|
122
|
+
assert.equal(stage.createdCount, 0)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('refuses a persona the stage has no account for', async () => {
|
|
126
|
+
const stage = await startStage([])
|
|
127
|
+
servers.push(stage.server)
|
|
128
|
+
|
|
129
|
+
const personas = createHttpPersonas({
|
|
130
|
+
apiUrl: stage.apiUrl,
|
|
131
|
+
operator: { token: OPERATOR_TOKEN },
|
|
132
|
+
personas: { customer: persona('ghost@personas.invalid') },
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
await assert.rejects(
|
|
136
|
+
() => personas.customer!.invoke('whoami', {}),
|
|
137
|
+
/no account on the target for persona 'customer'/
|
|
138
|
+
)
|
|
139
|
+
assert.equal(stage.createdCount, 0)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('provisions the account only when told to', async () => {
|
|
143
|
+
const stage = await startStage([])
|
|
144
|
+
servers.push(stage.server)
|
|
145
|
+
|
|
146
|
+
const personas = createHttpPersonas({
|
|
147
|
+
apiUrl: stage.apiUrl,
|
|
148
|
+
operator: { token: OPERATOR_TOKEN, createMissing: true },
|
|
149
|
+
personas: { customer: persona('fresh@personas.invalid') },
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const result = (await personas.customer!.invoke('whoami', {})) as {
|
|
153
|
+
actingAs: string | null
|
|
154
|
+
}
|
|
155
|
+
assert.equal(result.actingAs, 'made-1')
|
|
156
|
+
assert.equal(stage.createdCount, 1)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('mints the operator session from a token factory', async () => {
|
|
160
|
+
const stage = await startStage([
|
|
161
|
+
{ id: 'user-9', email: 'lazy@personas.invalid' },
|
|
162
|
+
])
|
|
163
|
+
servers.push(stage.server)
|
|
164
|
+
|
|
165
|
+
let minted = 0
|
|
166
|
+
const personas = createHttpPersonas({
|
|
167
|
+
apiUrl: stage.apiUrl,
|
|
168
|
+
operator: {
|
|
169
|
+
token: () => {
|
|
170
|
+
minted++
|
|
171
|
+
return OPERATOR_TOKEN
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
personas: { customer: persona('lazy@personas.invalid') },
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
await personas.customer!.invoke('whoami', {})
|
|
178
|
+
assert.equal(minted, 1)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('carries the operator session into the admin lookup', async () => {
|
|
182
|
+
const stage = await startStage([{ id: 'user-9', email: 'susan@acme.test' }])
|
|
183
|
+
servers.push(stage.server)
|
|
184
|
+
|
|
185
|
+
// Plain fetch keeps no cookies, which is the browser provider's situation:
|
|
186
|
+
// it plants them on a Playwright context only once this has returned.
|
|
187
|
+
const { userId } = await establishOperatorSession(
|
|
188
|
+
fetch,
|
|
189
|
+
stage.apiUrl,
|
|
190
|
+
persona('susan@acme.test'),
|
|
191
|
+
{ token: OPERATOR_TOKEN }
|
|
192
|
+
)
|
|
193
|
+
assert.equal(userId, 'user-9')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('a persona with neither credential is refused at construction', async () => {
|
|
197
|
+
const stage = await startStage([])
|
|
198
|
+
servers.push(stage.server)
|
|
199
|
+
|
|
200
|
+
assert.throws(
|
|
201
|
+
() =>
|
|
202
|
+
createHttpPersonas({
|
|
203
|
+
apiUrl: stage.apiUrl,
|
|
204
|
+
personas: { customer: persona('nobody@personas.invalid') },
|
|
205
|
+
}),
|
|
206
|
+
/has no way to sign in/
|
|
207
|
+
)
|
|
208
|
+
})
|
|
209
|
+
})
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import type { ResolvedPersona } from './personas-service.js'
|
|
2
|
+
import type { ScenarioCookieJar } from '../wirings/workflow/scenario-cookie-jar.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The header `resolveImpersonatedSession` reads the target user id from.
|
|
6
|
+
*
|
|
7
|
+
* A wire value rather than a shared import: the reader lives in
|
|
8
|
+
* `@pikku/services-better-auth`, which depends on core, so core cannot import
|
|
9
|
+
* it back. The two agree by protocol, the way an HTTP header always does.
|
|
10
|
+
*/
|
|
11
|
+
export const IMPERSONATE_USER_ID_HEADER = 'x-pikku-impersonate-user-id'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How a persona obtains a session on the target, and what every later request
|
|
15
|
+
* needs to carry to keep acting as them.
|
|
16
|
+
*
|
|
17
|
+
* Two answers exist because the two environments have opposite trust models,
|
|
18
|
+
* not because one is a fallback for the other. See {@link ActorSignIn} and
|
|
19
|
+
* {@link OperatorSignIn}.
|
|
20
|
+
*/
|
|
21
|
+
export interface PersonaSignIn {
|
|
22
|
+
/**
|
|
23
|
+
* Establish a session in `jar`. Throws on failure with a message naming the
|
|
24
|
+
* persona, since a run that continues unauthenticated fails later and
|
|
25
|
+
* somewhere less informative.
|
|
26
|
+
*/
|
|
27
|
+
login(jar: ScenarioCookieJar, persona: ResolvedPersona): Promise<void>
|
|
28
|
+
/** Headers every request after `login` must carry. */
|
|
29
|
+
headers(): Record<string, string>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const failed = async (
|
|
33
|
+
what: string,
|
|
34
|
+
personaId: string,
|
|
35
|
+
res: Response
|
|
36
|
+
): Promise<Error> => {
|
|
37
|
+
const body = (await res.text().catch(() => '')).slice(0, 300)
|
|
38
|
+
return new Error(
|
|
39
|
+
`[scenario] ${what} failed for '${personaId}' (${res.status}): ${body}`
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Sign a persona in through the Better Auth actor plugin — the local-development
|
|
45
|
+
* path.
|
|
46
|
+
*
|
|
47
|
+
* `POST /auth/sign-in/actor` upserts an `actor: true` row and mints a session
|
|
48
|
+
* for it. Passwordless by design and refused for any row not carrying that flag,
|
|
49
|
+
* so the secret can never reach a real user's account; the plugin still declines
|
|
50
|
+
* to serve the endpoint at all outside `pikku dev`.
|
|
51
|
+
*/
|
|
52
|
+
export class ActorSignIn implements PersonaSignIn {
|
|
53
|
+
constructor(
|
|
54
|
+
private readonly apiUrl: string,
|
|
55
|
+
private readonly secret: string,
|
|
56
|
+
private readonly signInPath: string
|
|
57
|
+
) {}
|
|
58
|
+
|
|
59
|
+
async login(jar: ScenarioCookieJar, persona: ResolvedPersona): Promise<void> {
|
|
60
|
+
const res = await jar.fetch(`${this.apiUrl}${this.signInPath}`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'content-type': 'application/json' },
|
|
63
|
+
body: JSON.stringify({
|
|
64
|
+
email: persona.email,
|
|
65
|
+
name: persona.name,
|
|
66
|
+
secret: this.secret,
|
|
67
|
+
}),
|
|
68
|
+
})
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
throw await failed('persona sign-in', persona.id, res)
|
|
71
|
+
}
|
|
72
|
+
// What proves a session was established is this response setting a cookie,
|
|
73
|
+
// not the jar being non-empty — the target may have set one earlier.
|
|
74
|
+
if (res.headers.getSetCookie().length === 0) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`[scenario] persona sign-in for '${persona.id}' returned no session cookie`
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
headers(): Record<string, string> {
|
|
82
|
+
return {}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface OperatorSignInOptions {
|
|
87
|
+
/**
|
|
88
|
+
* The short-lived RS256 operator token, or a function that mints one. Prefer
|
|
89
|
+
* the function: tokens expire, and a long run re-logs-in after a 401.
|
|
90
|
+
*/
|
|
91
|
+
token: string | (() => string | Promise<string>)
|
|
92
|
+
/**
|
|
93
|
+
* Create the persona's user row when the target has no account for that
|
|
94
|
+
* address.
|
|
95
|
+
*
|
|
96
|
+
* Off by default, which is the whole point of the deployed path: a persona is
|
|
97
|
+
* meant to be a real account somebody provisioned, and a test run that
|
|
98
|
+
* silently writes users into a live database is a side effect nobody asked
|
|
99
|
+
* for. Turn it on for throwaway stages.
|
|
100
|
+
*/
|
|
101
|
+
createMissing?: boolean
|
|
102
|
+
/** Admin endpoint prefix under apiUrl. Default `/auth/admin`. */
|
|
103
|
+
adminPath?: string
|
|
104
|
+
/** Fabric operator sign-in path under apiUrl. Default `/auth/sign-in/fabric`. */
|
|
105
|
+
signInPath?: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface AdminUser {
|
|
109
|
+
id?: unknown
|
|
110
|
+
email?: unknown
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** What an operator handshake yields: the session, and who to act as. */
|
|
114
|
+
export interface OperatorSessionResult {
|
|
115
|
+
/** `Set-Cookie` values the operator sign-in returned. */
|
|
116
|
+
setCookies: string[]
|
|
117
|
+
/** The target's own id for the persona, for the impersonation header. */
|
|
118
|
+
userId: string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Establish a Fabric operator session against `apiUrl` and resolve the target's
|
|
123
|
+
* own id for `persona`, which is what the impersonation header names.
|
|
124
|
+
*
|
|
125
|
+
* Takes the fetch to use rather than making one, because the two callers need
|
|
126
|
+
* the cookies to land in different places: an HTTP persona keeps them in its
|
|
127
|
+
* jar, a browser run plants them on a Playwright context. Both need the same
|
|
128
|
+
* handshake, and it is the kind of sequence that quietly diverges once it is
|
|
129
|
+
* written twice.
|
|
130
|
+
*/
|
|
131
|
+
export const establishOperatorSession = async (
|
|
132
|
+
fetchImpl: typeof fetch,
|
|
133
|
+
apiUrl: string,
|
|
134
|
+
persona: ResolvedPersona,
|
|
135
|
+
options: OperatorSignInOptions,
|
|
136
|
+
extraHeaders: Record<string, string> = {}
|
|
137
|
+
): Promise<OperatorSessionResult> => {
|
|
138
|
+
const signInPath = options.signInPath ?? '/auth/sign-in/fabric'
|
|
139
|
+
const token =
|
|
140
|
+
typeof options.token === 'function' ? await options.token() : options.token
|
|
141
|
+
|
|
142
|
+
const res = await fetchImpl(`${apiUrl}${signInPath}`, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: { 'content-type': 'application/json', ...extraHeaders },
|
|
145
|
+
body: JSON.stringify({ token }),
|
|
146
|
+
})
|
|
147
|
+
if (!res.ok) {
|
|
148
|
+
throw await failed('operator sign-in', persona.id, res)
|
|
149
|
+
}
|
|
150
|
+
const setCookies = res.headers.getSetCookie?.() ?? []
|
|
151
|
+
if (setCookies.length === 0) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`[scenario] operator sign-in for '${persona.id}' returned no session cookie`
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The lookup runs on the session this handshake just established, and a
|
|
158
|
+
// plain `fetch` keeps no cookies — the browser path in particular hands the
|
|
159
|
+
// jar's contents to Playwright only after this returns. Forwarding them
|
|
160
|
+
// explicitly is what keeps the admin calls authenticated for every caller.
|
|
161
|
+
const session = setCookies
|
|
162
|
+
.map((raw) => raw.split(';')[0])
|
|
163
|
+
.filter((pair): pair is string => Boolean(pair))
|
|
164
|
+
.join('; ')
|
|
165
|
+
|
|
166
|
+
const userId = await resolveUserId(fetchImpl, apiUrl, persona, options, {
|
|
167
|
+
...extraHeaders,
|
|
168
|
+
cookie: session,
|
|
169
|
+
})
|
|
170
|
+
return { setCookies, userId }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The target's own id for this persona's address, since impersonation names a
|
|
175
|
+
* user id and a persona only knows an email.
|
|
176
|
+
*
|
|
177
|
+
* Looked up before creating, so a persona that already exists is never
|
|
178
|
+
* duplicated and the run reads as "act as this person" rather than "make one".
|
|
179
|
+
*/
|
|
180
|
+
const resolveUserId = async (
|
|
181
|
+
fetchImpl: typeof fetch,
|
|
182
|
+
apiUrl: string,
|
|
183
|
+
persona: ResolvedPersona,
|
|
184
|
+
options: OperatorSignInOptions,
|
|
185
|
+
extraHeaders: Record<string, string>
|
|
186
|
+
): Promise<string> => {
|
|
187
|
+
const adminPath = options.adminPath ?? '/auth/admin'
|
|
188
|
+
const query = new URLSearchParams({
|
|
189
|
+
filterField: 'email',
|
|
190
|
+
filterValue: persona.email,
|
|
191
|
+
filterOperator: 'eq',
|
|
192
|
+
limit: '1',
|
|
193
|
+
})
|
|
194
|
+
const found = await fetchImpl(`${apiUrl}${adminPath}/list-users?${query}`, {
|
|
195
|
+
headers: { accept: 'application/json', ...extraHeaders },
|
|
196
|
+
})
|
|
197
|
+
if (!found.ok) {
|
|
198
|
+
throw await failed('persona lookup', persona.id, found)
|
|
199
|
+
}
|
|
200
|
+
const listed = (await found.json().catch(() => null)) as {
|
|
201
|
+
users?: AdminUser[]
|
|
202
|
+
} | null
|
|
203
|
+
const existing = listed?.users?.find((u) => u.email === persona.email)
|
|
204
|
+
if (existing?.id) {
|
|
205
|
+
return String(existing.id)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!options.createMissing) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`[scenario] no account on the target for persona '${persona.id}' (${persona.email}) — ` +
|
|
211
|
+
'provision it, or set createMissing on the operator credentials'
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const created = await fetchImpl(`${apiUrl}${adminPath}/create-user`, {
|
|
216
|
+
method: 'POST',
|
|
217
|
+
headers: { 'content-type': 'application/json', ...extraHeaders },
|
|
218
|
+
body: JSON.stringify({
|
|
219
|
+
email: persona.email,
|
|
220
|
+
name: persona.name,
|
|
221
|
+
// Never used and never returned: the run impersonates rather than signs
|
|
222
|
+
// in, so the account is reachable only by someone already holding an
|
|
223
|
+
// operator token. A derivable password would undo exactly that.
|
|
224
|
+
password: globalThis.crypto.randomUUID(),
|
|
225
|
+
...(persona.roles[0] ? { role: persona.roles[0] } : {}),
|
|
226
|
+
}),
|
|
227
|
+
})
|
|
228
|
+
if (!created.ok) {
|
|
229
|
+
throw await failed('persona creation', persona.id, created)
|
|
230
|
+
}
|
|
231
|
+
const body = (await created.json().catch(() => null)) as {
|
|
232
|
+
user?: AdminUser
|
|
233
|
+
} | null
|
|
234
|
+
const id = body?.user?.id
|
|
235
|
+
if (!id) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`[scenario] creating persona '${persona.id}' returned no user id`
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
return String(id)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Sign a persona in on a DEPLOYED stage, by having a Fabric operator act as
|
|
245
|
+
* them — the path that needs no test credential to exist anywhere.
|
|
246
|
+
*
|
|
247
|
+
* `POST /auth/sign-in/fabric` verifies an RS256 token against the stage's
|
|
248
|
+
* `FABRIC_AUTH_PUBLIC_KEY` and mints a session for a synthetic operator row
|
|
249
|
+
* granted the umbrella `admin` scope. Impersonation is then a header on each
|
|
250
|
+
* request rather than a second session, and its gate is that scope — not
|
|
251
|
+
* `user.role`, which is why this works without touching the app's roles.
|
|
252
|
+
*
|
|
253
|
+
* Asymmetric throughout: the stage can verify an operator token and never mint
|
|
254
|
+
* one, so nothing in a deployed environment is worth stealing. That is the
|
|
255
|
+
* property the actor secret cannot have, and the reason these are two classes
|
|
256
|
+
* instead of one with a flag.
|
|
257
|
+
*/
|
|
258
|
+
export class OperatorSignIn implements PersonaSignIn {
|
|
259
|
+
private userId: string | null = null
|
|
260
|
+
|
|
261
|
+
constructor(
|
|
262
|
+
private readonly apiUrl: string,
|
|
263
|
+
private readonly options: OperatorSignInOptions
|
|
264
|
+
) {}
|
|
265
|
+
|
|
266
|
+
async login(jar: ScenarioCookieJar, persona: ResolvedPersona): Promise<void> {
|
|
267
|
+
const { userId } = await establishOperatorSession(
|
|
268
|
+
jar.fetch,
|
|
269
|
+
this.apiUrl,
|
|
270
|
+
persona,
|
|
271
|
+
this.options
|
|
272
|
+
)
|
|
273
|
+
this.userId = userId
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
headers(): Record<string, string> {
|
|
277
|
+
if (!this.userId) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
'[scenario] operator session has no persona to act as — login() first'
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
return { [IMPERSONATE_USER_ID_HEADER]: this.userId }
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -45,6 +45,15 @@ export {
|
|
|
45
45
|
createHttpPersonas,
|
|
46
46
|
type HttpPersonasConfig,
|
|
47
47
|
} from '../../services/http-personas.js'
|
|
48
|
+
export {
|
|
49
|
+
ActorSignIn,
|
|
50
|
+
OperatorSignIn,
|
|
51
|
+
establishOperatorSession,
|
|
52
|
+
IMPERSONATE_USER_ID_HEADER,
|
|
53
|
+
type PersonaSignIn,
|
|
54
|
+
type OperatorSignInOptions,
|
|
55
|
+
type OperatorSessionResult,
|
|
56
|
+
} from '../../services/persona-sign-in.js'
|
|
48
57
|
export {
|
|
49
58
|
postScenarioJson,
|
|
50
59
|
readScenarioHttpResponse,
|
|
@@ -11,8 +11,19 @@ export const createCookieJar = (apiUrl: string): ScenarioCookieJar => {
|
|
|
11
11
|
fetch: async (input, init) => {
|
|
12
12
|
const headers = new Headers(init?.headers)
|
|
13
13
|
headers.set('origin', origin)
|
|
14
|
-
|
|
14
|
+
// The caller's own cookie header wins per name: a request that already
|
|
15
|
+
// carries a session is stating which one it means, and emitting the jar's
|
|
16
|
+
// copy alongside it sends the same name twice.
|
|
15
17
|
const caller = headers.get('cookie')
|
|
18
|
+
const named = new Set(
|
|
19
|
+
(caller ?? '')
|
|
20
|
+
.split(';')
|
|
21
|
+
.map((pair) => pair.split('=')[0]?.trim())
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
)
|
|
24
|
+
const held = [...jar]
|
|
25
|
+
.filter(([name]) => !named.has(name))
|
|
26
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
16
27
|
if (held.length > 0 || caller) {
|
|
17
28
|
headers.set('cookie', [caller, ...held].filter(Boolean).join('; '))
|
|
18
29
|
}
|