@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.
@@ -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 { resolveAuditConfig } from '../services/audit-service.js'
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
- const services =
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
- ;(this.wire as any).logger?.warn?.(
78
- 'audit.write() called for an invocation without wire.audit enabled'
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
- ;(this.wire as any).logger?.warn?.(
133
- 'best-effort audit flush failed',
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
+ }
@@ -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>
@@ -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