@pikku/core 0.12.46 → 0.12.49
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 +43 -0
- package/dist/function/function-runner.js +18 -2
- package/dist/services/audit-service.d.ts +2 -1
- package/dist/services/audit-service.js +15 -8
- package/dist/services/http-user-flow-actors.d.ts +46 -0
- package/dist/services/http-user-flow-actors.js +90 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +1 -0
- package/dist/services/user-flow-actors-service.d.ts +27 -0
- package/dist/services/user-flow-actors-service.js +1 -0
- package/dist/types/core.types.d.ts +5 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +23 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +3 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +51 -3
- package/dist/wirings/workflow/workflow.types.d.ts +9 -3
- package/package.json +1 -1
- package/src/function/function-runner.test.ts +108 -0
- package/src/function/function-runner.ts +28 -2
- package/src/services/audit-service.ts +16 -11
- package/src/services/http-user-flow-actors.test.ts +115 -0
- package/src/services/http-user-flow-actors.ts +128 -0
- package/src/services/index.ts +10 -0
- package/src/services/user-flow-actors-service.ts +29 -0
- package/src/types/core.types.ts +5 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +31 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +76 -11
- package/src/wirings/workflow/user-flow-step.test.ts +192 -0
- package/src/wirings/workflow/workflow.types.ts +9 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -12,6 +12,7 @@ import type { CoreServices, CorePikkuMiddleware } from '../types/core.types.js'
|
|
|
12
12
|
import type { CorePermissionGroup } from './functions.types.js'
|
|
13
13
|
import { PikkuSessionService } from '../services/user-session-service.js'
|
|
14
14
|
import { ReadonlySessionError } from '../errors/errors.js'
|
|
15
|
+
import { createInvocationAudit } from '../services/audit-service.js'
|
|
15
16
|
|
|
16
17
|
beforeEach(() => {
|
|
17
18
|
resetPikkuState()
|
|
@@ -1198,6 +1199,113 @@ describe('runPikkuFunc - Integration Tests', () => {
|
|
|
1198
1199
|
assert.equal(childFunctionId, 'rpcChildAudited')
|
|
1199
1200
|
})
|
|
1200
1201
|
|
|
1202
|
+
test('should audit writes from an audited function invoked via exposed rpc (stale outer auditLog)', async () => {
|
|
1203
|
+
// Repro of the exposed-RPC HTTP path: the generated rpcCaller has no
|
|
1204
|
+
// audit config, and createWireServices builds auditLog on ITS wire —
|
|
1205
|
+
// the audited inner function must not inherit that disabled instance.
|
|
1206
|
+
const events: any[] = []
|
|
1207
|
+
const singletonServices = {
|
|
1208
|
+
...mockSingletonServices,
|
|
1209
|
+
audit: { audit: async (event: any) => events.push(event) },
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
addTestFunction('auditedInner', {
|
|
1213
|
+
audit: true,
|
|
1214
|
+
func: async (services: any) => {
|
|
1215
|
+
await services.auditLog.write({ type: 'booking.update', source: 'explicit' })
|
|
1216
|
+
return 'inner-ok'
|
|
1217
|
+
},
|
|
1218
|
+
})
|
|
1219
|
+
|
|
1220
|
+
addTestFunction('rpcCallerLike', {
|
|
1221
|
+
func: async (_services: any, _data: any, wire: any) => {
|
|
1222
|
+
return wire.rpc.invoke('auditedInner', {})
|
|
1223
|
+
},
|
|
1224
|
+
})
|
|
1225
|
+
|
|
1226
|
+
const result = await runPikkuFunc('http', 'exposed-rpc-route', 'rpcCallerLike', {
|
|
1227
|
+
singletonServices,
|
|
1228
|
+
data: () => ({}),
|
|
1229
|
+
auth: false,
|
|
1230
|
+
wire: {},
|
|
1231
|
+
createWireServices: async (services: any, wire: any) => ({
|
|
1232
|
+
auditLog: createInvocationAudit(services.audit, wire),
|
|
1233
|
+
}),
|
|
1234
|
+
})
|
|
1235
|
+
|
|
1236
|
+
assert.equal(result, 'inner-ok')
|
|
1237
|
+
assert.equal(events.length, 1)
|
|
1238
|
+
assert.equal(events[0].type, 'booking.update')
|
|
1239
|
+
assert.equal(events[0].functionId, 'auditedInner')
|
|
1240
|
+
})
|
|
1241
|
+
|
|
1242
|
+
test('should give an audited rpc sub-call its own audit buffer with child attribution', async () => {
|
|
1243
|
+
const events: any[] = []
|
|
1244
|
+
const singletonServices = {
|
|
1245
|
+
...mockSingletonServices,
|
|
1246
|
+
audit: { audit: async (event: any) => events.push(event) },
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
addTestFunction('auditedChildWriter', {
|
|
1250
|
+
audit: true,
|
|
1251
|
+
func: async (services: any) => {
|
|
1252
|
+
await services.auditLog.write({ type: 'child.event', source: 'explicit' })
|
|
1253
|
+
return 'child-ok'
|
|
1254
|
+
},
|
|
1255
|
+
})
|
|
1256
|
+
|
|
1257
|
+
addTestFunction('auditedParentWriter', {
|
|
1258
|
+
audit: true,
|
|
1259
|
+
func: async (services: any, _data: any, wire: any) => {
|
|
1260
|
+
await services.auditLog.write({ type: 'parent.event', source: 'explicit' })
|
|
1261
|
+
return wire.rpc.invoke('auditedChildWriter', {})
|
|
1262
|
+
},
|
|
1263
|
+
})
|
|
1264
|
+
|
|
1265
|
+
const result = await runPikkuFunc('rpc', 'audited-parent-child', 'auditedParentWriter', {
|
|
1266
|
+
singletonServices,
|
|
1267
|
+
data: () => ({}),
|
|
1268
|
+
auth: false,
|
|
1269
|
+
wire: {},
|
|
1270
|
+
})
|
|
1271
|
+
|
|
1272
|
+
assert.equal(result, 'child-ok')
|
|
1273
|
+
const byType = Object.fromEntries(events.map((e) => [e.type, e.functionId]))
|
|
1274
|
+
assert.equal(byType['parent.event'], 'auditedParentWriter')
|
|
1275
|
+
assert.equal(byType['child.event'], 'auditedChildWriter')
|
|
1276
|
+
})
|
|
1277
|
+
|
|
1278
|
+
test('should warn via the singleton logger when audit writes are dropped', async () => {
|
|
1279
|
+
const warnings: any[] = []
|
|
1280
|
+
const singletonServices = {
|
|
1281
|
+
...mockSingletonServices,
|
|
1282
|
+
logger: { ...mockSingletonServices.logger, warn: (msg: any) => warnings.push(msg) },
|
|
1283
|
+
audit: { audit: async () => {} },
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
addTestFunction('unauditedWriter', {
|
|
1287
|
+
func: async (services: any) => {
|
|
1288
|
+
await services.auditLog.write({ type: 'dropped.event', source: 'explicit' })
|
|
1289
|
+
return 'ok'
|
|
1290
|
+
},
|
|
1291
|
+
})
|
|
1292
|
+
|
|
1293
|
+
const result = await runPikkuFunc('rpc', 'unaudited-writer', 'unauditedWriter', {
|
|
1294
|
+
singletonServices,
|
|
1295
|
+
data: () => ({}),
|
|
1296
|
+
auth: false,
|
|
1297
|
+
wire: {},
|
|
1298
|
+
createWireServices: async (services: any, wire: any) => ({
|
|
1299
|
+
auditLog: createInvocationAudit(services.audit, wire, services.logger),
|
|
1300
|
+
}),
|
|
1301
|
+
})
|
|
1302
|
+
|
|
1303
|
+
assert.equal(result, 'ok')
|
|
1304
|
+
assert.equal(warnings.length, 1)
|
|
1305
|
+
assert.match(String(warnings[0]), /dropped/)
|
|
1306
|
+
assert.match(String(warnings[0]), /unauditedWriter/)
|
|
1307
|
+
})
|
|
1308
|
+
|
|
1201
1309
|
test('should expose the resolved base function id after version fallback', async () => {
|
|
1202
1310
|
let receivedFunctionId: any
|
|
1203
1311
|
|
|
@@ -33,7 +33,11 @@ import {
|
|
|
33
33
|
createWireServicesCredentialWireProps,
|
|
34
34
|
} from '../services/credential-wire-service.js'
|
|
35
35
|
import { defaultPikkuUserIdResolver } from '../services/pikku-user-id.js'
|
|
36
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
createInvocationAudit,
|
|
38
|
+
resolveAuditConfig,
|
|
39
|
+
type AuditLog,
|
|
40
|
+
} from '../services/audit-service.js'
|
|
37
41
|
import { rpcService } from '../wirings/rpc/rpc-runner.js'
|
|
38
42
|
import { closeWireServices } from '../utils.js'
|
|
39
43
|
|
|
@@ -415,15 +419,35 @@ export const runPikkuFunc = async <In = any, Out = any>(
|
|
|
415
419
|
}
|
|
416
420
|
|
|
417
421
|
let wireServices: Record<string, unknown> | undefined
|
|
422
|
+
let invocationAuditLog: AuditLog | undefined
|
|
418
423
|
try {
|
|
419
424
|
wireServices = (await resolvedCreateWireServices?.(
|
|
420
425
|
resolvedSingletonServices,
|
|
421
426
|
invocationWire
|
|
422
427
|
)) as Record<string, unknown> | undefined
|
|
423
|
-
|
|
428
|
+
let services =
|
|
424
429
|
wireServices && Object.keys(wireServices).length > 0
|
|
425
430
|
? { ...resolvedSingletonServices, ...wireServices }
|
|
426
431
|
: resolvedSingletonServices
|
|
432
|
+
// The audit gate is per-function, but the auditLog wire service is
|
|
433
|
+
// created per-transport-invocation — a nested/exposed-RPC call would
|
|
434
|
+
// otherwise inherit an auditLog constructed while the OUTER wire's
|
|
435
|
+
// audit was unset (e.g. the generated rpcCaller has no audit config),
|
|
436
|
+
// silently dropping every write. Re-gate here: if this function
|
|
437
|
+
// declares audit and the inherited auditLog wasn't built for this
|
|
438
|
+
// invocation (config identity check), bind a fresh one to this wire.
|
|
439
|
+
if (
|
|
440
|
+
resolvedAuditConfig &&
|
|
441
|
+
resolvedSingletonServices.audit &&
|
|
442
|
+
services.auditLog?.config !== resolvedAuditConfig
|
|
443
|
+
) {
|
|
444
|
+
invocationAuditLog = createInvocationAudit(
|
|
445
|
+
resolvedSingletonServices.audit,
|
|
446
|
+
invocationWire,
|
|
447
|
+
resolvedSingletonServices.logger
|
|
448
|
+
)
|
|
449
|
+
services = { ...services, auditLog: invocationAuditLog }
|
|
450
|
+
}
|
|
427
451
|
const callerPackageName = packageName
|
|
428
452
|
Object.defineProperty(invocationWire, 'rpc', {
|
|
429
453
|
get() {
|
|
@@ -446,6 +470,8 @@ export const runPikkuFunc = async <In = any, Out = any>(
|
|
|
446
470
|
})
|
|
447
471
|
return await funcConfig.func(services, actualData, invocationWire)
|
|
448
472
|
} finally {
|
|
473
|
+
// Flush the runner-installed audit buffer before wire services close.
|
|
474
|
+
await invocationAuditLog?.close()
|
|
449
475
|
if (wireServices && Object.keys(wireServices).length > 0) {
|
|
450
476
|
await closeWireServices(resolvedSingletonServices.logger, wireServices)
|
|
451
477
|
}
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
PikkuWire,
|
|
4
4
|
PikkuWiringTypes,
|
|
5
5
|
} from '../types/core.types.js'
|
|
6
|
+
import type { Logger } from './logger.js'
|
|
6
7
|
|
|
7
8
|
export type AuditDurability = 'best-effort' | 'transactional'
|
|
8
9
|
export type AuditOutcome = 'success' | 'failed' | 'denied'
|
|
@@ -68,14 +69,18 @@ class DisabledInvocationAudit implements AuditLog {
|
|
|
68
69
|
private warned = false
|
|
69
70
|
|
|
70
71
|
constructor(
|
|
71
|
-
private readonly wire: PikkuWire<any, any, any, CoreUserSession
|
|
72
|
+
private readonly wire: PikkuWire<any, any, any, CoreUserSession>,
|
|
73
|
+
private readonly logger?: Logger
|
|
72
74
|
) {}
|
|
73
75
|
|
|
74
76
|
async write(_event: AuditLogWriteInput): Promise<void> {
|
|
75
77
|
if (!this.warned) {
|
|
76
78
|
this.warned = true
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
// Fall back to the singleton logger — wires rarely carry their own, and
|
|
80
|
+
// a dropped audit write must never be invisible.
|
|
81
|
+
const logger = (this.wire as any).logger ?? this.logger
|
|
82
|
+
logger?.warn?.(
|
|
83
|
+
`audit.write() dropped for '${this.wire.functionId ?? 'unknown function'}' — the function has no audit config (set audit: true on it)`
|
|
79
84
|
)
|
|
80
85
|
}
|
|
81
86
|
}
|
|
@@ -91,7 +96,8 @@ class InvocationAuditLog implements AuditLog {
|
|
|
91
96
|
constructor(
|
|
92
97
|
public readonly config: ResolvedAuditConfig,
|
|
93
98
|
private readonly service: AuditService,
|
|
94
|
-
private readonly wire: PikkuWire<any, any, any, CoreUserSession
|
|
99
|
+
private readonly wire: PikkuWire<any, any, any, CoreUserSession>,
|
|
100
|
+
private readonly logger?: Logger
|
|
95
101
|
) {}
|
|
96
102
|
|
|
97
103
|
async write(event: AuditLogWriteInput): Promise<void> {
|
|
@@ -129,10 +135,8 @@ class InvocationAuditLog implements AuditLog {
|
|
|
129
135
|
await this.service.audit(event)
|
|
130
136
|
}
|
|
131
137
|
} catch (error) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
error
|
|
135
|
-
)
|
|
138
|
+
const logger = (this.wire as any).logger ?? this.logger
|
|
139
|
+
logger?.warn?.('best-effort audit flush failed', error)
|
|
136
140
|
}
|
|
137
141
|
}
|
|
138
142
|
|
|
@@ -171,13 +175,14 @@ export const resolveAuditConfig = (
|
|
|
171
175
|
|
|
172
176
|
export const createInvocationAudit = (
|
|
173
177
|
service: AuditService,
|
|
174
|
-
wire: PikkuWire<any, any, any, CoreUserSession
|
|
178
|
+
wire: PikkuWire<any, any, any, CoreUserSession>,
|
|
179
|
+
logger?: Logger
|
|
175
180
|
): AuditLog => {
|
|
176
181
|
if (!wire.audit) {
|
|
177
|
-
return new DisabledInvocationAudit(wire)
|
|
182
|
+
return new DisabledInvocationAudit(wire, logger)
|
|
178
183
|
}
|
|
179
184
|
|
|
180
|
-
return new InvocationAuditLog(wire.audit, service, wire)
|
|
185
|
+
return new InvocationAuditLog(wire.audit, service, wire, logger)
|
|
181
186
|
}
|
|
182
187
|
|
|
183
188
|
export const resolveAuditActorFromWire = (
|
|
@@ -0,0 +1,115 @@
|
|
|
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 { createHttpUserFlowActors } from './http-user-flow-actors.js'
|
|
6
|
+
|
|
7
|
+
// Minimal target app: actor sign-in endpoint + exposed RPC endpoint, session
|
|
8
|
+
// via cookie. Mirrors the Better Auth actor plugin's contract.
|
|
9
|
+
const startTarget = async () => {
|
|
10
|
+
let logins = 0
|
|
11
|
+
let expireNext = false
|
|
12
|
+
const server: Server = createServer((req, res) => {
|
|
13
|
+
const chunks: Buffer[] = []
|
|
14
|
+
req.on('data', (c) => chunks.push(c))
|
|
15
|
+
req.on('end', () => {
|
|
16
|
+
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {}
|
|
17
|
+
if (req.url === '/api/auth/sign-in/actor') {
|
|
18
|
+
if (body.secret !== 'impersonation-secret') {
|
|
19
|
+
res.writeHead(401).end(JSON.stringify({ message: 'bad actor secret' }))
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
logins++
|
|
23
|
+
res.setHeader('set-cookie', [
|
|
24
|
+
`session=s${logins}; Path=/; HttpOnly`,
|
|
25
|
+
`csrf=c${logins}; Path=/`,
|
|
26
|
+
])
|
|
27
|
+
res.writeHead(200).end(JSON.stringify({ ok: true, email: body.email }))
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
if (req.url?.startsWith('/api/rpc/')) {
|
|
31
|
+
const cookie = req.headers.cookie ?? ''
|
|
32
|
+
if (!cookie.includes('session=') || expireNext) {
|
|
33
|
+
expireNext = false
|
|
34
|
+
res.writeHead(401).end()
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
38
|
+
JSON.stringify({
|
|
39
|
+
rpcName: req.url.slice('/api/rpc/'.length),
|
|
40
|
+
echoed: body.data,
|
|
41
|
+
cookie,
|
|
42
|
+
})
|
|
43
|
+
)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
res.writeHead(404).end()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
await new Promise<void>((resolve) => server.listen(0, resolve))
|
|
50
|
+
const { port } = server.address() as { port: number }
|
|
51
|
+
return {
|
|
52
|
+
server,
|
|
53
|
+
apiUrl: `http://127.0.0.1:${port}/api`,
|
|
54
|
+
loginCount: () => logins,
|
|
55
|
+
expireSession: () => {
|
|
56
|
+
expireNext = true
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('HttpUserFlowActor', async () => {
|
|
62
|
+
const target = await startTarget()
|
|
63
|
+
after(() => target.server.close())
|
|
64
|
+
|
|
65
|
+
const makeActors = (secret = 'impersonation-secret') =>
|
|
66
|
+
createHttpUserFlowActors({
|
|
67
|
+
apiUrl: target.apiUrl,
|
|
68
|
+
secret,
|
|
69
|
+
actors: {
|
|
70
|
+
customer: { email: 'customer@actors.local', jobTitle: 'Buyer' },
|
|
71
|
+
manager: { email: 'manager@actors.local' },
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('builds one lazy actor per config entry', () => {
|
|
76
|
+
const actors = makeActors()
|
|
77
|
+
assert.deepEqual(Object.keys(actors).sort(), ['customer', 'manager'])
|
|
78
|
+
assert.equal(actors.customer!.name, 'customer')
|
|
79
|
+
assert.equal(target.loginCount(), 0, 'no login until first invoke')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('logs in lazily once and replays the session cookie on RPCs', async () => {
|
|
83
|
+
const actors = makeActors()
|
|
84
|
+
|
|
85
|
+
const first = (await actors.customer!.invoke('createTodo', { title: 'x' })) as any
|
|
86
|
+
const second = (await actors.customer!.invoke('listTodos', {})) as any
|
|
87
|
+
|
|
88
|
+
assert.equal(first.rpcName, 'createTodo')
|
|
89
|
+
assert.deepEqual(first.echoed, { title: 'x' })
|
|
90
|
+
assert.match(first.cookie, /session=s1/)
|
|
91
|
+
assert.match(first.cookie, /csrf=c1/)
|
|
92
|
+
assert.match(second.cookie, /session=s1/, 'session is cached, not re-minted')
|
|
93
|
+
assert.equal(target.loginCount(), 1)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('re-logs-in once when the session expires mid-run', async () => {
|
|
97
|
+
const actors = makeActors()
|
|
98
|
+
await actors.manager!.invoke('ping', {})
|
|
99
|
+
const loginsBefore = target.loginCount()
|
|
100
|
+
|
|
101
|
+
target.expireSession()
|
|
102
|
+
const result = (await actors.manager!.invoke('ping', {})) as any
|
|
103
|
+
|
|
104
|
+
assert.equal(target.loginCount(), loginsBefore + 1, 'one re-login')
|
|
105
|
+
assert.match(result.cookie, new RegExp(`session=s${loginsBefore + 1}`))
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('a wrong impersonation secret surfaces status and body', async () => {
|
|
109
|
+
const actors = makeActors('wrong-secret')
|
|
110
|
+
await assert.rejects(
|
|
111
|
+
actors.customer!.invoke('ping', {}),
|
|
112
|
+
/actor sign-in failed for 'customer' \(401\).*bad actor secret/
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
UserFlowActor,
|
|
3
|
+
UserFlowActorConfig,
|
|
4
|
+
UserFlowActors,
|
|
5
|
+
} from './user-flow-actors-service.js'
|
|
6
|
+
|
|
7
|
+
export interface HttpUserFlowActorsConfig {
|
|
8
|
+
/**
|
|
9
|
+
* Base API URL of the target app, INCLUDING the HTTP prefix — e.g.
|
|
10
|
+
* `https://app.example.com/api` or `http://localhost:4000/api`. Actor
|
|
11
|
+
* sign-in is reached at `${apiUrl}${signInPath}` and exposed RPCs at
|
|
12
|
+
* `${apiUrl}${rpcPath}/:rpcName`.
|
|
13
|
+
*/
|
|
14
|
+
apiUrl: string
|
|
15
|
+
/**
|
|
16
|
+
* The actor impersonation secret. Sign-in only ever works for user rows
|
|
17
|
+
* flagged `actor: true` — knowing the secret never impersonates real users.
|
|
18
|
+
*/
|
|
19
|
+
secret: string
|
|
20
|
+
/** Actor name → config (usually from pikku.config.json's actor registry). */
|
|
21
|
+
actors: Record<string, UserFlowActorConfig>
|
|
22
|
+
/** Sign-in path under apiUrl. Default: the actor plugin's `/auth/sign-in/actor`. */
|
|
23
|
+
signInPath?: string
|
|
24
|
+
/** Exposed-RPC path prefix under apiUrl. Default `/rpc`. */
|
|
25
|
+
rpcPath?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Default HTTP-backed actor. Signs in lazily on first invoke via the Better
|
|
30
|
+
* Auth actor plugin (`POST /auth/sign-in/actor` with `{ email, secret }` —
|
|
31
|
+
* the plugin upserts the actor-flagged user row and mints a session whose
|
|
32
|
+
* `actor` flag flows into audits/analytics). Holds the session cookies for
|
|
33
|
+
* its lifetime; a 401 mid-run re-logs-in once (long health-check runs can
|
|
34
|
+
* outlive a session).
|
|
35
|
+
*/
|
|
36
|
+
export class HttpUserFlowActor implements UserFlowActor {
|
|
37
|
+
private cookie: string | null = null
|
|
38
|
+
private origin: string
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
readonly name: string,
|
|
42
|
+
private actorConfig: UserFlowActorConfig,
|
|
43
|
+
private config: HttpUserFlowActorsConfig
|
|
44
|
+
) {
|
|
45
|
+
this.origin = new URL(config.apiUrl).origin
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async invoke(rpcName: string, data: unknown): Promise<unknown> {
|
|
49
|
+
const cookie = this.cookie ?? (await this.login())
|
|
50
|
+
const res = await this.postRpc(rpcName, data, cookie)
|
|
51
|
+
if (res.status === 401) {
|
|
52
|
+
// Session expired mid-run — re-login once and retry.
|
|
53
|
+
this.cookie = null
|
|
54
|
+
return this.readRpcResponse(rpcName, await this.postRpc(rpcName, data, await this.login()))
|
|
55
|
+
}
|
|
56
|
+
return this.readRpcResponse(rpcName, res)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private async postRpc(rpcName: string, data: unknown, cookie: string) {
|
|
60
|
+
const rpcPath = this.config.rpcPath ?? '/rpc'
|
|
61
|
+
return fetch(`${this.config.apiUrl}${rpcPath}/${rpcName}`, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: {
|
|
64
|
+
'content-type': 'application/json',
|
|
65
|
+
origin: this.origin,
|
|
66
|
+
cookie,
|
|
67
|
+
},
|
|
68
|
+
body: JSON.stringify({ data }),
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private async readRpcResponse(rpcName: string, res: Response) {
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const body = (await res.text().catch(() => '')).slice(0, 300)
|
|
75
|
+
throw new Error(
|
|
76
|
+
`[user-flow] '${rpcName}' as '${this.name}' returned ${res.status}: ${body}`
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
if (res.status === 204) return undefined
|
|
80
|
+
const text = await res.text()
|
|
81
|
+
return text ? JSON.parse(text) : undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private async login(): Promise<string> {
|
|
85
|
+
const signInPath = this.config.signInPath ?? '/auth/sign-in/actor'
|
|
86
|
+
const res = await fetch(`${this.config.apiUrl}${signInPath}`, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'content-type': 'application/json', origin: this.origin },
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
email: this.actorConfig.email,
|
|
91
|
+
name: this.actorConfig.name ?? this.name,
|
|
92
|
+
secret: this.config.secret,
|
|
93
|
+
}),
|
|
94
|
+
})
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
const body = (await res.text().catch(() => '')).slice(0, 300)
|
|
97
|
+
throw new Error(
|
|
98
|
+
`[user-flow] actor sign-in failed for '${this.name}' (${res.status}): ${body}`
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
const setCookies = res.headers.getSetCookie?.() ?? []
|
|
102
|
+
const cookie = setCookies
|
|
103
|
+
.map((c) => c.split(';')[0]!)
|
|
104
|
+
.filter(Boolean)
|
|
105
|
+
.join('; ')
|
|
106
|
+
if (!cookie) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`[user-flow] actor sign-in for '${this.name}' returned no session cookie`
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
this.cookie = cookie
|
|
112
|
+
return cookie
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build the injected `actors` service from the config registry: actor name →
|
|
118
|
+
* lazy HTTP actor. Wire the result as the `actors` singleton service.
|
|
119
|
+
*/
|
|
120
|
+
export function createHttpUserFlowActors(
|
|
121
|
+
config: HttpUserFlowActorsConfig
|
|
122
|
+
): UserFlowActors {
|
|
123
|
+
const actors: UserFlowActors = {}
|
|
124
|
+
for (const [name, actorConfig] of Object.entries(config.actors)) {
|
|
125
|
+
actors[name] = new HttpUserFlowActor(name, actorConfig, config)
|
|
126
|
+
}
|
|
127
|
+
return actors
|
|
128
|
+
}
|
package/src/services/index.ts
CHANGED
|
@@ -36,6 +36,16 @@ export type {
|
|
|
36
36
|
WriteFileArgs,
|
|
37
37
|
CopyFileArgs,
|
|
38
38
|
} from './content-service.js'
|
|
39
|
+
export type {
|
|
40
|
+
UserFlowActor,
|
|
41
|
+
UserFlowActorConfig,
|
|
42
|
+
UserFlowActors,
|
|
43
|
+
} from './user-flow-actors-service.js'
|
|
44
|
+
export {
|
|
45
|
+
HttpUserFlowActor,
|
|
46
|
+
createHttpUserFlowActors,
|
|
47
|
+
type HttpUserFlowActorsConfig,
|
|
48
|
+
} from './http-user-flow-actors.js'
|
|
39
49
|
export type { JWTService } from './jwt-service.js'
|
|
40
50
|
export type {
|
|
41
51
|
EmailService,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A user-flow actor: a synthetic user (a normal user row flagged `actor`) that
|
|
3
|
+
* workflow steps can run as. Passed to `workflow.do(step, rpc, data, { actor })`
|
|
4
|
+
* — the step then goes through the actor's authenticated client over the REAL
|
|
5
|
+
* transport (auth middleware, permissions, serialization all exercised),
|
|
6
|
+
* never through internal dispatch. Login is lazy: the first `invoke` signs the
|
|
7
|
+
* actor in and the session is cached for the actor's lifetime.
|
|
8
|
+
*/
|
|
9
|
+
export interface UserFlowActor {
|
|
10
|
+
/** Stable actor name (the key in pikku.config.json's actor registry). */
|
|
11
|
+
readonly name: string
|
|
12
|
+
/** Invoke an exposed RPC as this actor over the real transport. */
|
|
13
|
+
invoke(rpcName: string, data: unknown): Promise<unknown>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Display/config metadata for an actor (from pikku.config.json). The email
|
|
18
|
+
* identifies the actor's user row; personality/jobTitle exist for the console
|
|
19
|
+
* screen and for agent-driven flows (the agent plays the persona).
|
|
20
|
+
*/
|
|
21
|
+
export interface UserFlowActorConfig {
|
|
22
|
+
email: string
|
|
23
|
+
name?: string
|
|
24
|
+
jobTitle?: string
|
|
25
|
+
personality?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The injected `actors` service: actor name → actor. */
|
|
29
|
+
export type UserFlowActors = Record<string, UserFlowActor>
|
package/src/types/core.types.ts
CHANGED
|
@@ -29,6 +29,7 @@ import type { DeploymentService } from '../services/deployment-service.js'
|
|
|
29
29
|
import type { AIStorageService } from '../services/ai-storage-service.js'
|
|
30
30
|
|
|
31
31
|
import type { ContentService } from '../services/content-service.js'
|
|
32
|
+
import type { UserFlowActors } from '../services/user-flow-actors-service.js'
|
|
32
33
|
import type { AIAgentRunnerService } from '../services/ai-agent-runner-service.js'
|
|
33
34
|
import type { AIRunStateService } from '../services/ai-run-state-service.js'
|
|
34
35
|
import type { AgentRunService } from '../wirings/ai-agent/ai-agent.types.js'
|
|
@@ -243,6 +244,8 @@ export type CoreConfig<Config extends Record<string, unknown> = {}> = {
|
|
|
243
244
|
export interface CoreUserSession {
|
|
244
245
|
userId?: string
|
|
245
246
|
orgId?: string
|
|
247
|
+
/** True when the session belongs to a synthetic user-flow actor — lets audits/analytics address synthetic traffic */
|
|
248
|
+
actor?: boolean
|
|
246
249
|
}
|
|
247
250
|
|
|
248
251
|
/**
|
|
@@ -336,6 +339,8 @@ export type PikkuWire<
|
|
|
336
339
|
queue: PikkuQueue
|
|
337
340
|
cli: PikkuCLI
|
|
338
341
|
workflow: TypedWorkflow
|
|
342
|
+
/** User-flow actor registry (user-flow runs only) — pass into workflow.do as `{ actor: actors.x }` */
|
|
343
|
+
actors: UserFlowActors
|
|
339
344
|
workflowStep: WorkflowStepWire
|
|
340
345
|
graph: PikkuGraphWire
|
|
341
346
|
trigger: PikkuTrigger<TriggerOutput>
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { WorkflowRun } from '../workflow.types.js'
|
|
7
|
+
import type { UserFlowActor } from '../../../services/user-flow-actors-service.js'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Workflow step options
|
|
@@ -15,9 +16,27 @@ export interface WorkflowStepOptions {
|
|
|
15
16
|
retries?: number
|
|
16
17
|
/** Delay between retry attempts (e.g., '1s', '2s', '2min') */
|
|
17
18
|
retryDelay?: string | number
|
|
19
|
+
/**
|
|
20
|
+
* Run this step as an actor (user flows). The RPC is sent through the
|
|
21
|
+
* actor's authenticated client over the REAL transport — never dispatched
|
|
22
|
+
* internally — so auth middleware and permissions are exercised end-to-end.
|
|
23
|
+
* The step is recorded durably like any RPC step.
|
|
24
|
+
*/
|
|
25
|
+
actor?: UserFlowActor
|
|
18
26
|
// Future: timeout, failFast, priority
|
|
19
27
|
}
|
|
20
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Options for workflow.expectEventually() — a durable polling step used by
|
|
31
|
+
* user flows to await async effects (a notification landing, a job finishing).
|
|
32
|
+
*/
|
|
33
|
+
export interface WorkflowExpectEventuallyOptions extends WorkflowStepOptions {
|
|
34
|
+
/** Give up after this long (e.g. '30s'). Default '30s'. */
|
|
35
|
+
within?: string | number
|
|
36
|
+
/** Poll interval (e.g. '1s'). Default '1s'. */
|
|
37
|
+
interval?: string | number
|
|
38
|
+
}
|
|
39
|
+
|
|
21
40
|
/**
|
|
22
41
|
* Type signature for workflow.do() RPC form - used by inspector
|
|
23
42
|
*/
|
|
@@ -346,6 +365,18 @@ export interface PikkuWorkflowWire {
|
|
|
346
365
|
/** Execute a workflow step (overloaded - RPC or inline form) */
|
|
347
366
|
do: WorkflowWireDoRPC & WorkflowWireDoInline
|
|
348
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Durable polling step (user flows): invoke `rpcName` (as an actor when
|
|
370
|
+
* `options.as` is set) until `predicate` passes or `options.within` elapses.
|
|
371
|
+
*/
|
|
372
|
+
expectEventually: <TOutput = any, TInput = any>(
|
|
373
|
+
stepName: string,
|
|
374
|
+
rpcName: string,
|
|
375
|
+
data: TInput,
|
|
376
|
+
predicate: (output: TOutput) => boolean,
|
|
377
|
+
options?: WorkflowExpectEventuallyOptions
|
|
378
|
+
) => Promise<TOutput>
|
|
379
|
+
|
|
349
380
|
/** Sleep for a duration */
|
|
350
381
|
sleep: WorkflowWireSleep
|
|
351
382
|
|