@pikku/core 0.12.89 → 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 +181 -0
- package/dist/services/http-personas.d.ts +21 -7
- package/dist/services/http-personas.js +39 -28
- package/dist/services/persona-sign-in.d.ts +107 -0
- package/dist/services/persona-sign-in.js +179 -0
- package/dist/types/core.types.d.ts +7 -0
- package/dist/wirings/persona/index.d.ts +1 -0
- package/dist/wirings/persona/index.js +1 -0
- package/dist/wirings/rpc/rpc-runner.js +4 -5
- package/dist/wirings/virtual-user/index.d.ts +3 -1
- package/dist/wirings/virtual-user/index.js +1 -0
- package/dist/wirings/virtual-user/virtual-user-agents.d.ts +7 -2
- package/dist/wirings/virtual-user/virtual-user-agents.js +8 -2
- package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +32 -1
- package/dist/wirings/virtual-user/virtual-user-schedule-store.d.ts +89 -0
- package/dist/wirings/virtual-user/virtual-user-schedule-store.js +1 -0
- package/dist/wirings/virtual-user/virtual-user-schedule.d.ts +71 -0
- package/dist/wirings/virtual-user/virtual-user-schedule.js +101 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +3 -2
- package/dist/wirings/workflow/pikku-workflow-service.js +3 -2
- package/dist/wirings/workflow/scenario-cookie-jar.js +10 -1
- package/knowledge/decisions/internals/a-virtual-user-cadence-is-a-row-not-a-timer.md +66 -0
- package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +8 -3
- package/knowledge/decisions/internals/index.md +1 -0
- package/knowledge/decisions/internals/the-ecosystem-entry-point-carries-the-adapter-surface.md +7 -6
- package/package.json +1 -1
- package/src/app-leaf-surface.test.ts +2 -2
- package/src/ecosystem-tier-removed.test.ts +69 -0
- package/src/public-surface.json +10 -0
- package/src/services/http-personas-converse.test.ts +16 -2
- package/src/services/http-personas.ts +60 -33
- package/src/services/persona-sign-in.test.ts +209 -0
- package/src/services/persona-sign-in.ts +284 -0
- package/src/types/core.types.ts +7 -0
- package/src/wirings/persona/index.ts +9 -0
- package/src/wirings/rpc/rpc-runner.test.ts +100 -0
- package/src/wirings/rpc/rpc-runner.ts +8 -5
- package/src/wirings/virtual-user/index.ts +18 -0
- package/src/wirings/virtual-user/virtual-user-agents.test.ts +8 -4
- package/src/wirings/virtual-user/virtual-user-agents.ts +8 -3
- package/src/wirings/virtual-user/virtual-user-run-store.ts +33 -0
- package/src/wirings/virtual-user/virtual-user-schedule-store.ts +93 -0
- package/src/wirings/virtual-user/virtual-user-schedule.test.ts +280 -0
- package/src/wirings/virtual-user/virtual-user-schedule.ts +156 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +3 -2
- package/src/wirings/workflow/scenario-cookie-jar.ts +12 -1
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,184 @@
|
|
|
1
|
+
## 0.12.91
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 09aff02: Let personas run against a deployed stage.
|
|
6
|
+
|
|
7
|
+
A persona could only ever sign in through the actor plugin, which is
|
|
8
|
+
passwordless and therefore a local-development mechanism — so the scenario
|
|
9
|
+
suite had no way to reach staging or production, including the parts of it
|
|
10
|
+
that never assert anything about a logged-in user.
|
|
11
|
+
|
|
12
|
+
`HttpPersonasConfig` now takes `operator` as an alternative to `secret`. Given
|
|
13
|
+
Fabric operator credentials, a persona signs in at `/auth/sign-in/fabric` and
|
|
14
|
+
acts as its account through the `x-pikku-impersonate-user-id` header, which is
|
|
15
|
+
gated on the umbrella `admin` scope rather than `user.role`. Nothing on the
|
|
16
|
+
deployed side holds a test credential: the stage verifies operator tokens and
|
|
17
|
+
cannot mint them.
|
|
18
|
+
|
|
19
|
+
Provisioning stays opt-in (`createMissing`), so pointing a run at a live
|
|
20
|
+
environment never quietly writes user rows into it.
|
|
21
|
+
|
|
22
|
+
## 0.12.90
|
|
23
|
+
|
|
24
|
+
### Patch Changes
|
|
25
|
+
|
|
26
|
+
- 3c0012c: Gate console agent-thread reads and deletes on thread ownership, claim MongoDB workflow steps atomically, and reach the deployment fallback from `rpcWithWire`
|
|
27
|
+
|
|
28
|
+
`getAgentThreadMessages` and `deleteAgentThread` in the console addon took a
|
|
29
|
+
caller-supplied `threadId` straight to storage, while their siblings
|
|
30
|
+
`getAgentThreads` and `getAgentThreadRuns` already filtered to what the session
|
|
31
|
+
owns. Both now carry an `isThreadOwner` permission: an admin reaches any thread,
|
|
32
|
+
everyone else only their own, and a missing thread is refused rather than 404'd
|
|
33
|
+
so it is indistinguishable from someone else's.
|
|
34
|
+
|
|
35
|
+
`MongoDBWorkflowService` claimed a step by reading its status and then writing
|
|
36
|
+
it, under a `withStepLock` that is a pass-through — so two dispatches racing for
|
|
37
|
+
the same step could both proceed and run a side-effecting step twice. The claim
|
|
38
|
+
is now a single status-guarded update, atomic on one document.
|
|
39
|
+
|
|
40
|
+
`rpcWithWire` threw `RPCNotFoundError` for any unresolved namespaced call
|
|
41
|
+
instead of falling through to the deployment service, so a namespaced RPC that
|
|
42
|
+
`rpc()` would have dispatched remotely failed when called with an explicit wire.
|
|
43
|
+
|
|
44
|
+
- 05e47cf: fix(personas): sign a persona in before it converses, not after a 401
|
|
45
|
+
|
|
46
|
+
`HttpPersona.converse` left authentication to `postAgent`'s 401-retry, which
|
|
47
|
+
only fires on a route that refuses an anonymous caller. An agent route wired
|
|
48
|
+
without `auth: true` never refuses one: turn one is accepted and the thread is
|
|
49
|
+
minted under a fresh anonymous id, turn two arrives under a different anonymous
|
|
50
|
+
id, and the persona is told the thread belongs to somebody else — intermittently,
|
|
51
|
+
because it depends on which turn the retry happened to run on.
|
|
52
|
+
|
|
53
|
+
The persona now logs in before the first turn if it has not already. A persona
|
|
54
|
+
is a declared account with real credentials in every case, so there was never a
|
|
55
|
+
run where conversing as nobody was what was wanted; the sign-in is the same one
|
|
56
|
+
`call` has always done, just no longer conditional on the server pushing back.
|
|
57
|
+
|
|
58
|
+
- cfd364a: Remove the last `@pikku/core/ecosystem` references and guard against new ones
|
|
59
|
+
|
|
60
|
+
`@pikku/kysely`'s workflow-service test still imported `StepState` from
|
|
61
|
+
`@pikku/core/ecosystem/workflow`, a subpath that no longer exists in
|
|
62
|
+
`@pikku/core`'s `exports`. Nothing caught it: the import is type-only, so tsx
|
|
63
|
+
erases it before it can fail at runtime, and the package tsconfig excludes
|
|
64
|
+
`**/*.test.ts`, so `yarn tsc` never saw it either. It now imports from
|
|
65
|
+
`@pikku/core/workflow`.
|
|
66
|
+
|
|
67
|
+
A new guard test in `@pikku/core` scans the repository for the dead specifier
|
|
68
|
+
and fails if one comes back, so the next stale import is a red test rather than
|
|
69
|
+
a silent `any`.
|
|
70
|
+
|
|
71
|
+
- 05e47cf: fix(virtual-user): offer agents under the name the server can resolve
|
|
72
|
+
|
|
73
|
+
`reachableAgents` named each offered agent `agent.name ?? id`, where `id` is the
|
|
74
|
+
key the agent is registered under and `agent.name` is the display label from its
|
|
75
|
+
config. Those are the same string only by coincidence. `addAgent` stores the
|
|
76
|
+
export's own name and `resolveAgent` looks the call up by it, so an agent
|
|
77
|
+
exported as `adminAgent` and declaring `name: 'admin-agent'` was advertised to a
|
|
78
|
+
virtual user as `admin-agent` — a name nothing has ever registered. The persona
|
|
79
|
+
took the offer on its first turn, the stage answered
|
|
80
|
+
`500 AI agent not found: admin-agent`, and the run died there. Every fixture in
|
|
81
|
+
the tests happened to use one string for both, so nothing caught it.
|
|
82
|
+
|
|
83
|
+
The offered name is now always the registration key. `AgentReachability.name` is
|
|
84
|
+
gone rather than ignored, so there is no longer a display label sitting in the
|
|
85
|
+
shape inviting the same mistake.
|
|
86
|
+
|
|
87
|
+
- 05e47cf: feat(virtual-user): keep the transcript a run already produced
|
|
88
|
+
|
|
89
|
+
The engine returns `intents` and `steps` on every run — what the user set out
|
|
90
|
+
to do, and every turn it took getting there — and `VirtualUserRunOutcome` kept
|
|
91
|
+
neither. The record held counts and findings, so the one question anybody
|
|
92
|
+
actually asks of a completed run ("what did it _do_?") had no answer anywhere,
|
|
93
|
+
even though the answer had been computed and thrown away a moment earlier.
|
|
94
|
+
|
|
95
|
+
`VirtualUserRunOutcome` now carries both, and `VirtualUserRunStore` gains a
|
|
96
|
+
`steps(runId, options?)` read. Intents ride on the run record: there are a
|
|
97
|
+
handful of them and every read of the run wants them. Steps get their own
|
|
98
|
+
`virtualUserRunStep` table, because a run at a 500-step budget carries more
|
|
99
|
+
transcript than every other column together and `list()` would pay for it on
|
|
100
|
+
every row.
|
|
101
|
+
|
|
102
|
+
Three things the kysely store had to get right, all of them driver differences
|
|
103
|
+
rather than design:
|
|
104
|
+
|
|
105
|
+
- steps are inserted in chunks of 50, because a bare sqlite driver binds at
|
|
106
|
+
most 999 variables per statement and ten columns times a 500-step budget is
|
|
107
|
+
five thousand — an un-chunked insert fails on long runs, which are the
|
|
108
|
+
interesting ones;
|
|
109
|
+
- `ok` is stored as 0 or 1, since a bare driver cannot bind a boolean at all
|
|
110
|
+
and `SerializePlugin` is not installed everywhere;
|
|
111
|
+
- `response` is stored JSON-encoded, because a truncated API response usually
|
|
112
|
+
starts with a brace and `SerializePlugin` would otherwise read it back as an
|
|
113
|
+
object rather than the string the engine saw.
|
|
114
|
+
|
|
115
|
+
Completing a run that does not exist no longer writes steps: there is no
|
|
116
|
+
foreign key to refuse them and nothing would ever read or reap them.
|
|
117
|
+
|
|
118
|
+
**This adds a table to the `virtualUser` schema**, and the runtime creates
|
|
119
|
+
nothing: a database that already has `virtualUserRun` gets the store's own
|
|
120
|
+
refusal at startup until `pikku db generate` writes the migration and
|
|
121
|
+
`pikku db migrate` applies it. Landing it now costs nothing, because
|
|
122
|
+
`scaffold.virtualUser` is not yet switched on anywhere.
|
|
123
|
+
|
|
124
|
+
- 05e47cf: feat(virtual-user): put each persona on its own clock
|
|
125
|
+
|
|
126
|
+
A budget says where one run stops. Nothing said how often a persona should use
|
|
127
|
+
the application, so in practice each one ran whenever somebody remembered — and
|
|
128
|
+
what actually tells you about a product is the same user coming back over a
|
|
129
|
+
fortnight.
|
|
130
|
+
|
|
131
|
+
Each persona now gets a row rather than a bigger budget. `virtualUserSchedule`
|
|
132
|
+
holds `enabled`, the disposition and goals to run with, an interval **range**,
|
|
133
|
+
and `nextRunAt`. `tickVirtualUserSchedules` acts on whichever rows are due:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
wireScheduler({
|
|
137
|
+
name: 'virtualUsers',
|
|
138
|
+
schedule: '0 * * * *',
|
|
139
|
+
func: tickVirtualUserSchedules,
|
|
140
|
+
})
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The tick is generated and wired by nobody, deliberately. A scaffolded
|
|
144
|
+
`wireScheduler` would start spending an application's model budget the moment
|
|
145
|
+
somebody ran `pikku all`, on a host that may not run schedulers at all. Tick
|
|
146
|
+
resolution bounds how late a due persona is, never how often it runs.
|
|
147
|
+
|
|
148
|
+
Three things it does that are easy to leave out:
|
|
149
|
+
|
|
150
|
+
- The next due time is written **before** the run is dispatched, so a tick that
|
|
151
|
+
dies halfway cannot hand the same persona to the next one. A dispatch that
|
|
152
|
+
throws waits a full interval instead of retrying every minute for a week.
|
|
153
|
+
That write is a compare-and-set against the `nextRunAt` the tick read, so it
|
|
154
|
+
is also how a tick _wins_ the persona: two processes on the same cron see the
|
|
155
|
+
same due row, and only the one whose claim lands dispatches.
|
|
156
|
+
- A persona whose previous run is still `running` is skipped, not queued. Two
|
|
157
|
+
copies of the same user acting at once is a different test, and its findings
|
|
158
|
+
do not reproduce.
|
|
159
|
+
- A run still `running` after two hours is failed. Without that, one restart
|
|
160
|
+
mid-run blocks that persona's schedule permanently — which is where the
|
|
161
|
+
stranded-record cost of not using a queue finally gets paid.
|
|
162
|
+
|
|
163
|
+
Reschedule-on-completion was the other candidate and is worse in exactly one
|
|
164
|
+
way, fatally: a crash between finishing and scheduling ends the persona forever,
|
|
165
|
+
and the evidence is an absence.
|
|
166
|
+
|
|
167
|
+
New: `VirtualUserScheduleStore` in core (with the tick, `isDue` and `nextRunAt`
|
|
168
|
+
as pure functions), `KyselyVirtualUserScheduleStore` and its own schema —
|
|
169
|
+
its own rather than a third table in `virtualUserSchema`, and owned by its own
|
|
170
|
+
store, so a project that records runs and never wants them unattended carries no
|
|
171
|
+
cadence table. `scaffold.virtualUser` gains `setVirtualUserSchedule`,
|
|
172
|
+
`listVirtualUserSchedules` and the tick, behind a new `virtualUser:schedule`
|
|
173
|
+
scope: starting a run spends money once with a caller watching, while writing a
|
|
174
|
+
schedule spends it repeatedly with nobody there.
|
|
175
|
+
|
|
176
|
+
The console's Virtual Users screen gains a **Run now** button beside a persona's
|
|
177
|
+
run history, gated on `pikku:console:virtualUsers:run`. It dispatches the
|
|
178
|
+
project's own `runVirtualUser` rather than starting a run itself, so a run the
|
|
179
|
+
application would refuse — an acted-upon persona, a non-accountable disposition
|
|
180
|
+
in production — is still refused.
|
|
181
|
+
|
|
1
182
|
## 0.12.89
|
|
2
183
|
|
|
3
184
|
### Patch Changes
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ScenarioPersona, ResolvedPersona, ScenarioPersonas, ScenarioInvokeOptions, ScenarioHttpResponse } from './personas-service.js';
|
|
2
2
|
import type { ConverseOptions, ActorFlowVerdict } from '../wirings/actor-flow/actor-flow.types.js';
|
|
3
|
+
import { type OperatorSignInOptions } from './persona-sign-in.js';
|
|
3
4
|
export interface HttpPersonasConfig {
|
|
4
5
|
/**
|
|
5
6
|
* Base API URL of the target app, INCLUDING the HTTP prefix — e.g.
|
|
@@ -11,8 +12,19 @@ export interface HttpPersonasConfig {
|
|
|
11
12
|
/**
|
|
12
13
|
* The impersonation secret. Sign-in only ever works for user rows flagged
|
|
13
14
|
* `actor: true` — knowing the secret never impersonates real users.
|
|
15
|
+
*
|
|
16
|
+
* The local-development credential. A deployed stage has none, and passes
|
|
17
|
+
* {@link HttpPersonasConfig.operator} instead.
|
|
18
|
+
*/
|
|
19
|
+
secret?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Fabric operator credentials, for signing personas into a DEPLOYED stage.
|
|
22
|
+
*
|
|
23
|
+
* Mutually exclusive with {@link HttpPersonasConfig.secret}: the operator
|
|
24
|
+
* path acts as the persona through an admin session rather than logging in as
|
|
25
|
+
* them, so no test credential has to exist on the target at all.
|
|
14
26
|
*/
|
|
15
|
-
|
|
27
|
+
operator?: OperatorSignInOptions;
|
|
16
28
|
/** Persona id → the declaration with its address filled in. */
|
|
17
29
|
personas: Record<string, ResolvedPersona>;
|
|
18
30
|
/** Sign-in path under apiUrl. Default: the actor plugin's `/auth/sign-in/actor`. */
|
|
@@ -29,12 +41,13 @@ export interface HttpPersonasConfig {
|
|
|
29
41
|
model?: string;
|
|
30
42
|
}
|
|
31
43
|
/**
|
|
32
|
-
* Default HTTP-backed persona. Signs in lazily on first invoke
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
44
|
+
* Default HTTP-backed persona. Signs in lazily on first invoke, holds the
|
|
45
|
+
* session cookies for its lifetime, and re-logs-in once on a 401 mid-run (long
|
|
46
|
+
* health-check runs can outlive a session).
|
|
47
|
+
*
|
|
48
|
+
* How it signs in depends on the target, and the two ways are not
|
|
49
|
+
* interchangeable — see {@link ActorSignIn} for local development and
|
|
50
|
+
* {@link OperatorSignIn} for a deployed stage.
|
|
38
51
|
*/
|
|
39
52
|
export declare class HttpPersona implements ScenarioPersona {
|
|
40
53
|
readonly name: string;
|
|
@@ -48,6 +61,7 @@ export declare class HttpPersona implements ScenarioPersona {
|
|
|
48
61
|
* established.
|
|
49
62
|
*/
|
|
50
63
|
private signedIn;
|
|
64
|
+
private signIn;
|
|
51
65
|
constructor(name: string, persona: ResolvedPersona, config: HttpPersonasConfig);
|
|
52
66
|
get email(): string;
|
|
53
67
|
invoke(rpcName: string, data: unknown): Promise<unknown>;
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { readScenarioHttpResponse } from './personas-service.js';
|
|
2
2
|
import { runConversation } from '../wirings/actor-flow/run-conversation.js';
|
|
3
3
|
import { createCookieJar, } from '../wirings/workflow/scenario-cookie-jar.js';
|
|
4
|
+
import { ActorSignIn, OperatorSignIn, } from './persona-sign-in.js';
|
|
4
5
|
import { getSingletonServices } from '../pikku-state.js';
|
|
5
6
|
import { AIProviderNotConfiguredError } from '../errors/errors.js';
|
|
6
7
|
/**
|
|
7
|
-
* Default HTTP-backed persona. Signs in lazily on first invoke
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* Default HTTP-backed persona. Signs in lazily on first invoke, holds the
|
|
9
|
+
* session cookies for its lifetime, and re-logs-in once on a 401 mid-run (long
|
|
10
|
+
* health-check runs can outlive a session).
|
|
11
|
+
*
|
|
12
|
+
* How it signs in depends on the target, and the two ways are not
|
|
13
|
+
* interchangeable — see {@link ActorSignIn} for local development and
|
|
14
|
+
* {@link OperatorSignIn} for a deployed stage.
|
|
13
15
|
*/
|
|
14
16
|
export class HttpPersona {
|
|
15
17
|
name;
|
|
@@ -23,11 +25,21 @@ export class HttpPersona {
|
|
|
23
25
|
* established.
|
|
24
26
|
*/
|
|
25
27
|
signedIn = false;
|
|
28
|
+
signIn;
|
|
26
29
|
constructor(name, persona, config) {
|
|
27
30
|
this.name = name;
|
|
28
31
|
this.persona = persona;
|
|
29
32
|
this.config = config;
|
|
30
33
|
this.jar = createCookieJar(config.apiUrl);
|
|
34
|
+
if (config.operator) {
|
|
35
|
+
this.signIn = new OperatorSignIn(config.apiUrl, config.operator);
|
|
36
|
+
}
|
|
37
|
+
else if (config.secret) {
|
|
38
|
+
this.signIn = new ActorSignIn(config.apiUrl, config.secret, config.signInPath ?? '/auth/sign-in/actor');
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
throw new Error(`[scenario] persona '${name}' has no way to sign in — set 'secret' for a dev target or 'operator' for a deployed one`);
|
|
42
|
+
}
|
|
31
43
|
}
|
|
32
44
|
get email() {
|
|
33
45
|
return this.persona.email;
|
|
@@ -57,6 +69,14 @@ export class HttpPersona {
|
|
|
57
69
|
if (!agentRunner) {
|
|
58
70
|
throw new AIProviderNotConfiguredError();
|
|
59
71
|
}
|
|
72
|
+
// Signed in here rather than left to postAgent's 401 retry, which a public
|
|
73
|
+
// agent route never triggers. An unowned thread is minted under a fresh
|
|
74
|
+
// anonymous id per request, so turn one succeeds and turn two is refused as
|
|
75
|
+
// somebody else's — and a persona is a real account with real credentials,
|
|
76
|
+
// so there is no case where conversing as nobody is the intent.
|
|
77
|
+
if (!this.signedIn) {
|
|
78
|
+
await this.login();
|
|
79
|
+
}
|
|
60
80
|
const model = options.model ?? this.config.model;
|
|
61
81
|
if (!model) {
|
|
62
82
|
throw new Error(`[scenario] persona '${this.name}' converse needs a model — pass options.model or set 'model' on the personas service`);
|
|
@@ -93,7 +113,9 @@ export class HttpPersona {
|
|
|
93
113
|
await this.login();
|
|
94
114
|
}
|
|
95
115
|
const sessionPath = this.config.sessionPath ?? '/auth/get-session';
|
|
96
|
-
const res = await this.jar.fetch(`${this.config.apiUrl}${sessionPath}
|
|
116
|
+
const res = await this.jar.fetch(`${this.config.apiUrl}${sessionPath}`, {
|
|
117
|
+
headers: this.signIn.headers(),
|
|
118
|
+
});
|
|
97
119
|
if (!res.ok) {
|
|
98
120
|
return null;
|
|
99
121
|
}
|
|
@@ -146,7 +168,10 @@ export class HttpPersona {
|
|
|
146
168
|
const url = `${this.config.apiUrl}${rpcPath}/${subPath}`;
|
|
147
169
|
const send = () => this.jar.fetch(url, {
|
|
148
170
|
method: 'POST',
|
|
149
|
-
headers: {
|
|
171
|
+
headers: {
|
|
172
|
+
'content-type': 'application/json',
|
|
173
|
+
...this.signIn.headers(),
|
|
174
|
+
},
|
|
150
175
|
body: JSON.stringify(body),
|
|
151
176
|
});
|
|
152
177
|
let res = await send();
|
|
@@ -168,7 +193,11 @@ export class HttpPersona {
|
|
|
168
193
|
const rpcPath = this.config.rpcPath ?? '/rpc';
|
|
169
194
|
return this.jar.fetch(`${this.config.apiUrl}${rpcPath}/${rpcName}`, {
|
|
170
195
|
method: 'POST',
|
|
171
|
-
headers: {
|
|
196
|
+
headers: {
|
|
197
|
+
'content-type': 'application/json',
|
|
198
|
+
...this.signIn.headers(),
|
|
199
|
+
...extraHeaders,
|
|
200
|
+
},
|
|
172
201
|
body: JSON.stringify({ data }),
|
|
173
202
|
});
|
|
174
203
|
}
|
|
@@ -178,25 +207,7 @@ export class HttpPersona {
|
|
|
178
207
|
this.signedIn = false;
|
|
179
208
|
}
|
|
180
209
|
async login() {
|
|
181
|
-
|
|
182
|
-
const res = await this.jar.fetch(`${this.config.apiUrl}${signInPath}`, {
|
|
183
|
-
method: 'POST',
|
|
184
|
-
headers: { 'content-type': 'application/json' },
|
|
185
|
-
body: JSON.stringify({
|
|
186
|
-
email: this.persona.email,
|
|
187
|
-
name: this.persona.name,
|
|
188
|
-
secret: this.config.secret,
|
|
189
|
-
}),
|
|
190
|
-
});
|
|
191
|
-
if (!res.ok) {
|
|
192
|
-
const body = (await res.text().catch(() => '')).slice(0, 300);
|
|
193
|
-
throw new Error(`[scenario] persona sign-in failed for '${this.name}' (${res.status}): ${body}`);
|
|
194
|
-
}
|
|
195
|
-
// What proves a session was established is this response setting a cookie,
|
|
196
|
-
// not the jar being non-empty — the target may have set one earlier.
|
|
197
|
-
if (res.headers.getSetCookie().length === 0) {
|
|
198
|
-
throw new Error(`[scenario] persona sign-in for '${this.name}' returned no session cookie`);
|
|
199
|
-
}
|
|
210
|
+
await this.signIn.login(this.jar, this.persona);
|
|
200
211
|
this.signedIn = true;
|
|
201
212
|
}
|
|
202
213
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ResolvedPersona } from './personas-service.js';
|
|
2
|
+
import type { ScenarioCookieJar } from '../wirings/workflow/scenario-cookie-jar.js';
|
|
3
|
+
/**
|
|
4
|
+
* The header `resolveImpersonatedSession` reads the target user id from.
|
|
5
|
+
*
|
|
6
|
+
* A wire value rather than a shared import: the reader lives in
|
|
7
|
+
* `@pikku/services-better-auth`, which depends on core, so core cannot import
|
|
8
|
+
* it back. The two agree by protocol, the way an HTTP header always does.
|
|
9
|
+
*/
|
|
10
|
+
export declare const IMPERSONATE_USER_ID_HEADER = "x-pikku-impersonate-user-id";
|
|
11
|
+
/**
|
|
12
|
+
* How a persona obtains a session on the target, and what every later request
|
|
13
|
+
* needs to carry to keep acting as them.
|
|
14
|
+
*
|
|
15
|
+
* Two answers exist because the two environments have opposite trust models,
|
|
16
|
+
* not because one is a fallback for the other. See {@link ActorSignIn} and
|
|
17
|
+
* {@link OperatorSignIn}.
|
|
18
|
+
*/
|
|
19
|
+
export interface PersonaSignIn {
|
|
20
|
+
/**
|
|
21
|
+
* Establish a session in `jar`. Throws on failure with a message naming the
|
|
22
|
+
* persona, since a run that continues unauthenticated fails later and
|
|
23
|
+
* somewhere less informative.
|
|
24
|
+
*/
|
|
25
|
+
login(jar: ScenarioCookieJar, persona: ResolvedPersona): Promise<void>;
|
|
26
|
+
/** Headers every request after `login` must carry. */
|
|
27
|
+
headers(): Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Sign a persona in through the Better Auth actor plugin — the local-development
|
|
31
|
+
* path.
|
|
32
|
+
*
|
|
33
|
+
* `POST /auth/sign-in/actor` upserts an `actor: true` row and mints a session
|
|
34
|
+
* for it. Passwordless by design and refused for any row not carrying that flag,
|
|
35
|
+
* so the secret can never reach a real user's account; the plugin still declines
|
|
36
|
+
* to serve the endpoint at all outside `pikku dev`.
|
|
37
|
+
*/
|
|
38
|
+
export declare class ActorSignIn implements PersonaSignIn {
|
|
39
|
+
private readonly apiUrl;
|
|
40
|
+
private readonly secret;
|
|
41
|
+
private readonly signInPath;
|
|
42
|
+
constructor(apiUrl: string, secret: string, signInPath: string);
|
|
43
|
+
login(jar: ScenarioCookieJar, persona: ResolvedPersona): Promise<void>;
|
|
44
|
+
headers(): Record<string, string>;
|
|
45
|
+
}
|
|
46
|
+
export interface OperatorSignInOptions {
|
|
47
|
+
/**
|
|
48
|
+
* The short-lived RS256 operator token, or a function that mints one. Prefer
|
|
49
|
+
* the function: tokens expire, and a long run re-logs-in after a 401.
|
|
50
|
+
*/
|
|
51
|
+
token: string | (() => string | Promise<string>);
|
|
52
|
+
/**
|
|
53
|
+
* Create the persona's user row when the target has no account for that
|
|
54
|
+
* address.
|
|
55
|
+
*
|
|
56
|
+
* Off by default, which is the whole point of the deployed path: a persona is
|
|
57
|
+
* meant to be a real account somebody provisioned, and a test run that
|
|
58
|
+
* silently writes users into a live database is a side effect nobody asked
|
|
59
|
+
* for. Turn it on for throwaway stages.
|
|
60
|
+
*/
|
|
61
|
+
createMissing?: boolean;
|
|
62
|
+
/** Admin endpoint prefix under apiUrl. Default `/auth/admin`. */
|
|
63
|
+
adminPath?: string;
|
|
64
|
+
/** Fabric operator sign-in path under apiUrl. Default `/auth/sign-in/fabric`. */
|
|
65
|
+
signInPath?: string;
|
|
66
|
+
}
|
|
67
|
+
/** What an operator handshake yields: the session, and who to act as. */
|
|
68
|
+
export interface OperatorSessionResult {
|
|
69
|
+
/** `Set-Cookie` values the operator sign-in returned. */
|
|
70
|
+
setCookies: string[];
|
|
71
|
+
/** The target's own id for the persona, for the impersonation header. */
|
|
72
|
+
userId: string;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Establish a Fabric operator session against `apiUrl` and resolve the target's
|
|
76
|
+
* own id for `persona`, which is what the impersonation header names.
|
|
77
|
+
*
|
|
78
|
+
* Takes the fetch to use rather than making one, because the two callers need
|
|
79
|
+
* the cookies to land in different places: an HTTP persona keeps them in its
|
|
80
|
+
* jar, a browser run plants them on a Playwright context. Both need the same
|
|
81
|
+
* handshake, and it is the kind of sequence that quietly diverges once it is
|
|
82
|
+
* written twice.
|
|
83
|
+
*/
|
|
84
|
+
export declare const establishOperatorSession: (fetchImpl: typeof fetch, apiUrl: string, persona: ResolvedPersona, options: OperatorSignInOptions, extraHeaders?: Record<string, string>) => Promise<OperatorSessionResult>;
|
|
85
|
+
/**
|
|
86
|
+
* Sign a persona in on a DEPLOYED stage, by having a Fabric operator act as
|
|
87
|
+
* them — the path that needs no test credential to exist anywhere.
|
|
88
|
+
*
|
|
89
|
+
* `POST /auth/sign-in/fabric` verifies an RS256 token against the stage's
|
|
90
|
+
* `FABRIC_AUTH_PUBLIC_KEY` and mints a session for a synthetic operator row
|
|
91
|
+
* granted the umbrella `admin` scope. Impersonation is then a header on each
|
|
92
|
+
* request rather than a second session, and its gate is that scope — not
|
|
93
|
+
* `user.role`, which is why this works without touching the app's roles.
|
|
94
|
+
*
|
|
95
|
+
* Asymmetric throughout: the stage can verify an operator token and never mint
|
|
96
|
+
* one, so nothing in a deployed environment is worth stealing. That is the
|
|
97
|
+
* property the actor secret cannot have, and the reason these are two classes
|
|
98
|
+
* instead of one with a flag.
|
|
99
|
+
*/
|
|
100
|
+
export declare class OperatorSignIn implements PersonaSignIn {
|
|
101
|
+
private readonly apiUrl;
|
|
102
|
+
private readonly options;
|
|
103
|
+
private userId;
|
|
104
|
+
constructor(apiUrl: string, options: OperatorSignInOptions);
|
|
105
|
+
login(jar: ScenarioCookieJar, persona: ResolvedPersona): Promise<void>;
|
|
106
|
+
headers(): Record<string, string>;
|
|
107
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The header `resolveImpersonatedSession` reads the target user id from.
|
|
3
|
+
*
|
|
4
|
+
* A wire value rather than a shared import: the reader lives in
|
|
5
|
+
* `@pikku/services-better-auth`, which depends on core, so core cannot import
|
|
6
|
+
* it back. The two agree by protocol, the way an HTTP header always does.
|
|
7
|
+
*/
|
|
8
|
+
export const IMPERSONATE_USER_ID_HEADER = 'x-pikku-impersonate-user-id';
|
|
9
|
+
const failed = async (what, personaId, res) => {
|
|
10
|
+
const body = (await res.text().catch(() => '')).slice(0, 300);
|
|
11
|
+
return new Error(`[scenario] ${what} failed for '${personaId}' (${res.status}): ${body}`);
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Sign a persona in through the Better Auth actor plugin — the local-development
|
|
15
|
+
* path.
|
|
16
|
+
*
|
|
17
|
+
* `POST /auth/sign-in/actor` upserts an `actor: true` row and mints a session
|
|
18
|
+
* for it. Passwordless by design and refused for any row not carrying that flag,
|
|
19
|
+
* so the secret can never reach a real user's account; the plugin still declines
|
|
20
|
+
* to serve the endpoint at all outside `pikku dev`.
|
|
21
|
+
*/
|
|
22
|
+
export class ActorSignIn {
|
|
23
|
+
apiUrl;
|
|
24
|
+
secret;
|
|
25
|
+
signInPath;
|
|
26
|
+
constructor(apiUrl, secret, signInPath) {
|
|
27
|
+
this.apiUrl = apiUrl;
|
|
28
|
+
this.secret = secret;
|
|
29
|
+
this.signInPath = signInPath;
|
|
30
|
+
}
|
|
31
|
+
async login(jar, persona) {
|
|
32
|
+
const res = await jar.fetch(`${this.apiUrl}${this.signInPath}`, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { 'content-type': 'application/json' },
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
email: persona.email,
|
|
37
|
+
name: persona.name,
|
|
38
|
+
secret: this.secret,
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
throw await failed('persona sign-in', persona.id, res);
|
|
43
|
+
}
|
|
44
|
+
// What proves a session was established is this response setting a cookie,
|
|
45
|
+
// not the jar being non-empty — the target may have set one earlier.
|
|
46
|
+
if (res.headers.getSetCookie().length === 0) {
|
|
47
|
+
throw new Error(`[scenario] persona sign-in for '${persona.id}' returned no session cookie`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
headers() {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Establish a Fabric operator session against `apiUrl` and resolve the target's
|
|
56
|
+
* own id for `persona`, which is what the impersonation header names.
|
|
57
|
+
*
|
|
58
|
+
* Takes the fetch to use rather than making one, because the two callers need
|
|
59
|
+
* the cookies to land in different places: an HTTP persona keeps them in its
|
|
60
|
+
* jar, a browser run plants them on a Playwright context. Both need the same
|
|
61
|
+
* handshake, and it is the kind of sequence that quietly diverges once it is
|
|
62
|
+
* written twice.
|
|
63
|
+
*/
|
|
64
|
+
export const establishOperatorSession = async (fetchImpl, apiUrl, persona, options, extraHeaders = {}) => {
|
|
65
|
+
const signInPath = options.signInPath ?? '/auth/sign-in/fabric';
|
|
66
|
+
const token = typeof options.token === 'function' ? await options.token() : options.token;
|
|
67
|
+
const res = await fetchImpl(`${apiUrl}${signInPath}`, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'content-type': 'application/json', ...extraHeaders },
|
|
70
|
+
body: JSON.stringify({ token }),
|
|
71
|
+
});
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
throw await failed('operator sign-in', persona.id, res);
|
|
74
|
+
}
|
|
75
|
+
const setCookies = res.headers.getSetCookie?.() ?? [];
|
|
76
|
+
if (setCookies.length === 0) {
|
|
77
|
+
throw new Error(`[scenario] operator sign-in for '${persona.id}' returned no session cookie`);
|
|
78
|
+
}
|
|
79
|
+
// The lookup runs on the session this handshake just established, and a
|
|
80
|
+
// plain `fetch` keeps no cookies — the browser path in particular hands the
|
|
81
|
+
// jar's contents to Playwright only after this returns. Forwarding them
|
|
82
|
+
// explicitly is what keeps the admin calls authenticated for every caller.
|
|
83
|
+
const session = setCookies
|
|
84
|
+
.map((raw) => raw.split(';')[0])
|
|
85
|
+
.filter((pair) => Boolean(pair))
|
|
86
|
+
.join('; ');
|
|
87
|
+
const userId = await resolveUserId(fetchImpl, apiUrl, persona, options, {
|
|
88
|
+
...extraHeaders,
|
|
89
|
+
cookie: session,
|
|
90
|
+
});
|
|
91
|
+
return { setCookies, userId };
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* The target's own id for this persona's address, since impersonation names a
|
|
95
|
+
* user id and a persona only knows an email.
|
|
96
|
+
*
|
|
97
|
+
* Looked up before creating, so a persona that already exists is never
|
|
98
|
+
* duplicated and the run reads as "act as this person" rather than "make one".
|
|
99
|
+
*/
|
|
100
|
+
const resolveUserId = async (fetchImpl, apiUrl, persona, options, extraHeaders) => {
|
|
101
|
+
const adminPath = options.adminPath ?? '/auth/admin';
|
|
102
|
+
const query = new URLSearchParams({
|
|
103
|
+
filterField: 'email',
|
|
104
|
+
filterValue: persona.email,
|
|
105
|
+
filterOperator: 'eq',
|
|
106
|
+
limit: '1',
|
|
107
|
+
});
|
|
108
|
+
const found = await fetchImpl(`${apiUrl}${adminPath}/list-users?${query}`, {
|
|
109
|
+
headers: { accept: 'application/json', ...extraHeaders },
|
|
110
|
+
});
|
|
111
|
+
if (!found.ok) {
|
|
112
|
+
throw await failed('persona lookup', persona.id, found);
|
|
113
|
+
}
|
|
114
|
+
const listed = (await found.json().catch(() => null));
|
|
115
|
+
const existing = listed?.users?.find((u) => u.email === persona.email);
|
|
116
|
+
if (existing?.id) {
|
|
117
|
+
return String(existing.id);
|
|
118
|
+
}
|
|
119
|
+
if (!options.createMissing) {
|
|
120
|
+
throw new Error(`[scenario] no account on the target for persona '${persona.id}' (${persona.email}) — ` +
|
|
121
|
+
'provision it, or set createMissing on the operator credentials');
|
|
122
|
+
}
|
|
123
|
+
const created = await fetchImpl(`${apiUrl}${adminPath}/create-user`, {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: { 'content-type': 'application/json', ...extraHeaders },
|
|
126
|
+
body: JSON.stringify({
|
|
127
|
+
email: persona.email,
|
|
128
|
+
name: persona.name,
|
|
129
|
+
// Never used and never returned: the run impersonates rather than signs
|
|
130
|
+
// in, so the account is reachable only by someone already holding an
|
|
131
|
+
// operator token. A derivable password would undo exactly that.
|
|
132
|
+
password: globalThis.crypto.randomUUID(),
|
|
133
|
+
...(persona.roles[0] ? { role: persona.roles[0] } : {}),
|
|
134
|
+
}),
|
|
135
|
+
});
|
|
136
|
+
if (!created.ok) {
|
|
137
|
+
throw await failed('persona creation', persona.id, created);
|
|
138
|
+
}
|
|
139
|
+
const body = (await created.json().catch(() => null));
|
|
140
|
+
const id = body?.user?.id;
|
|
141
|
+
if (!id) {
|
|
142
|
+
throw new Error(`[scenario] creating persona '${persona.id}' returned no user id`);
|
|
143
|
+
}
|
|
144
|
+
return String(id);
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* Sign a persona in on a DEPLOYED stage, by having a Fabric operator act as
|
|
148
|
+
* them — the path that needs no test credential to exist anywhere.
|
|
149
|
+
*
|
|
150
|
+
* `POST /auth/sign-in/fabric` verifies an RS256 token against the stage's
|
|
151
|
+
* `FABRIC_AUTH_PUBLIC_KEY` and mints a session for a synthetic operator row
|
|
152
|
+
* granted the umbrella `admin` scope. Impersonation is then a header on each
|
|
153
|
+
* request rather than a second session, and its gate is that scope — not
|
|
154
|
+
* `user.role`, which is why this works without touching the app's roles.
|
|
155
|
+
*
|
|
156
|
+
* Asymmetric throughout: the stage can verify an operator token and never mint
|
|
157
|
+
* one, so nothing in a deployed environment is worth stealing. That is the
|
|
158
|
+
* property the actor secret cannot have, and the reason these are two classes
|
|
159
|
+
* instead of one with a flag.
|
|
160
|
+
*/
|
|
161
|
+
export class OperatorSignIn {
|
|
162
|
+
apiUrl;
|
|
163
|
+
options;
|
|
164
|
+
userId = null;
|
|
165
|
+
constructor(apiUrl, options) {
|
|
166
|
+
this.apiUrl = apiUrl;
|
|
167
|
+
this.options = options;
|
|
168
|
+
}
|
|
169
|
+
async login(jar, persona) {
|
|
170
|
+
const { userId } = await establishOperatorSession(jar.fetch, this.apiUrl, persona, this.options);
|
|
171
|
+
this.userId = userId;
|
|
172
|
+
}
|
|
173
|
+
headers() {
|
|
174
|
+
if (!this.userId) {
|
|
175
|
+
throw new Error('[scenario] operator session has no persona to act as — login() first');
|
|
176
|
+
}
|
|
177
|
+
return { [IMPERSONATE_USER_ID_HEADER]: this.userId };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -29,6 +29,7 @@ import type { AgentRunService } from '../wirings/agent/agent.types.js';
|
|
|
29
29
|
import type { MiddlewareMetadata } from '../middleware/middleware.types.js';
|
|
30
30
|
import type { PermissionMetadata } from '../function/function-meta.types.js';
|
|
31
31
|
import type { VirtualUserRunStore } from '../wirings/virtual-user/virtual-user-run-store.js';
|
|
32
|
+
import type { VirtualUserScheduleStore } from '../wirings/virtual-user/virtual-user-schedule-store.js';
|
|
32
33
|
import type { WorkflowRunService } from '../wirings/workflow/workflow.types.js';
|
|
33
34
|
import type { CredentialService } from '../services/credential-service.js';
|
|
34
35
|
import type { EmailService } from '../services/email-service.js';
|
|
@@ -135,6 +136,12 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
|
|
|
135
136
|
* {@link VirtualUserRunStore}.
|
|
136
137
|
*/
|
|
137
138
|
virtualUserRunStore?: VirtualUserRunStore;
|
|
139
|
+
/**
|
|
140
|
+
* Each persona's cadence, for apps that want their virtual users to keep
|
|
141
|
+
* going without being asked. Separate from the run store on purpose: wiring
|
|
142
|
+
* nothing is how an app says it only wants the runs it starts itself.
|
|
143
|
+
*/
|
|
144
|
+
virtualUserScheduleStore?: VirtualUserScheduleStore;
|
|
138
145
|
/** V8 precise-coverage collector (`pikku dev --coverage` only) */
|
|
139
146
|
coverageService?: CoverageService;
|
|
140
147
|
audit?: AuditService;
|