@pikku/core 0.12.90 → 0.12.92

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 CHANGED
@@ -1,3 +1,59 @@
1
+ ## 0.12.92
2
+
3
+ ### Patch Changes
4
+
5
+ - b521f1b: Resolve the persona to impersonate during the fabric operator sign-in.
6
+
7
+ `POST /sign-in/fabric` now takes an optional
8
+ `actAs: { email, name?, create?, role? }`
9
+ and returns `actAs: { userId }` — the stage's own id for that address, looked up
10
+ before creating and created only when asked.
11
+
12
+ It has to happen there. Impersonation names a user id, a persona only knows an
13
+ email, and since better-auth's `admin()` plugin was dropped no HTTP endpoint
14
+ lists users — so the two calls the scenario runner made to resolve one
15
+ (`/auth/admin/list-users`, then `/auth/admin/create-user`) had nothing left to
16
+ reach and every deployed persona failed with `YOU_ARE_NOT_ALLOWED_TO_LIST_USERS`.
17
+ The adapter is already in hand on the sign-in request and the operator token has
18
+ already been verified, so the lookup is free and gated by the same check that
19
+ mints the session.
20
+
21
+ A created row gets a `role` only when the caller names one. pikku has no `role`
22
+ column of its own any more, but an app may still run better-auth's `admin()`
23
+ plugin and constrain that column, so the persona's first role is passed through
24
+ for those.
25
+
26
+ `OperatorSignInOptions.adminPath` is removed; nothing points at it any more.
27
+
28
+ A Fabric operator row now also satisfies the default impersonation gate.
29
+ `fabric()` grants the `admin` scope only when handed a `ScopeService`, and no
30
+ app template wires one — so the operator signed in holding nothing and every
31
+ impersonated request fell back to the operator's own session. The `fabric`
32
+ column is written by nothing but that sign-in, after an RS256 verification
33
+ against the stage's public key, so the row's existence is the authorization.
34
+ The scope half of the gate still fails closed.
35
+
36
+ ## 0.12.91
37
+
38
+ ### Patch Changes
39
+
40
+ - 09aff02: Let personas run against a deployed stage.
41
+
42
+ A persona could only ever sign in through the actor plugin, which is
43
+ passwordless and therefore a local-development mechanism — so the scenario
44
+ suite had no way to reach staging or production, including the parts of it
45
+ that never assert anything about a logged-in user.
46
+
47
+ `HttpPersonasConfig` now takes `operator` as an alternative to `secret`. Given
48
+ Fabric operator credentials, a persona signs in at `/auth/sign-in/fabric` and
49
+ acts as its account through the `x-pikku-impersonate-user-id` header, which is
50
+ gated on the umbrella `admin` scope rather than `user.role`. Nothing on the
51
+ deployed side holds a test credential: the stage verifies operator tokens and
52
+ cannot mint them.
53
+
54
+ Provisioning stays opt-in (`createMissing`), so pointing a run at a live
55
+ environment never quietly writes user rows into it.
56
+
1
57
  ## 0.12.90
2
58
 
3
59
  ### 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
- secret: string;
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 via the Better
33
- * Auth actor plugin (`POST /auth/sign-in/actor` with `{ email, secret }`
34
- * the plugin upserts the actor-flagged user row and mints a session whose
35
- * `actor` flag flows into audits/analytics). Holds the session cookies for
36
- * its lifetime; a 401 mid-run re-logs-in once (long health-check runs can
37
- * outlive a session).
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 via the Better
8
- * Auth actor plugin (`POST /auth/sign-in/actor` with `{ email, secret }`
9
- * the plugin upserts the actor-flagged user row and mints a session whose
10
- * `actor` flag flows into audits/analytics). Holds the session cookies for
11
- * its lifetime; a 401 mid-run re-logs-in once (long health-check runs can
12
- * outlive a session).
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;
@@ -101,7 +113,9 @@ export class HttpPersona {
101
113
  await this.login();
102
114
  }
103
115
  const sessionPath = this.config.sessionPath ?? '/auth/get-session';
104
- 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
+ });
105
119
  if (!res.ok) {
106
120
  return null;
107
121
  }
@@ -154,7 +168,10 @@ export class HttpPersona {
154
168
  const url = `${this.config.apiUrl}${rpcPath}/${subPath}`;
155
169
  const send = () => this.jar.fetch(url, {
156
170
  method: 'POST',
157
- headers: { 'content-type': 'application/json' },
171
+ headers: {
172
+ 'content-type': 'application/json',
173
+ ...this.signIn.headers(),
174
+ },
158
175
  body: JSON.stringify(body),
159
176
  });
160
177
  let res = await send();
@@ -176,7 +193,11 @@ export class HttpPersona {
176
193
  const rpcPath = this.config.rpcPath ?? '/rpc';
177
194
  return this.jar.fetch(`${this.config.apiUrl}${rpcPath}/${rpcName}`, {
178
195
  method: 'POST',
179
- headers: { 'content-type': 'application/json', ...extraHeaders },
196
+ headers: {
197
+ 'content-type': 'application/json',
198
+ ...this.signIn.headers(),
199
+ ...extraHeaders,
200
+ },
180
201
  body: JSON.stringify({ data }),
181
202
  });
182
203
  }
@@ -186,25 +207,7 @@ export class HttpPersona {
186
207
  this.signedIn = false;
187
208
  }
188
209
  async login() {
189
- const signInPath = this.config.signInPath ?? '/auth/sign-in/actor';
190
- const res = await this.jar.fetch(`${this.config.apiUrl}${signInPath}`, {
191
- method: 'POST',
192
- headers: { 'content-type': 'application/json' },
193
- body: JSON.stringify({
194
- email: this.persona.email,
195
- name: this.persona.name,
196
- secret: this.config.secret,
197
- }),
198
- });
199
- if (!res.ok) {
200
- const body = (await res.text().catch(() => '')).slice(0, 300);
201
- throw new Error(`[scenario] persona sign-in failed for '${this.name}' (${res.status}): ${body}`);
202
- }
203
- // What proves a session was established is this response setting a cookie,
204
- // not the jar being non-empty — the target may have set one earlier.
205
- if (res.headers.getSetCookie().length === 0) {
206
- throw new Error(`[scenario] persona sign-in for '${this.name}' returned no session cookie`);
207
- }
210
+ await this.signIn.login(this.jar, this.persona);
208
211
  this.signedIn = true;
209
212
  }
210
213
  }
@@ -0,0 +1,105 @@
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
+ /** Fabric operator sign-in path under apiUrl. Default `/auth/sign-in/fabric`. */
63
+ signInPath?: string;
64
+ }
65
+ /** What an operator handshake yields: the session, and who to act as. */
66
+ export interface OperatorSessionResult {
67
+ /** `Set-Cookie` values the operator sign-in returned. */
68
+ setCookies: string[];
69
+ /** The target's own id for the persona, for the impersonation header. */
70
+ userId: string;
71
+ }
72
+ /**
73
+ * Establish a Fabric operator session against `apiUrl` and resolve the target's
74
+ * own id for `persona`, which is what the impersonation header names.
75
+ *
76
+ * Takes the fetch to use rather than making one, because the two callers need
77
+ * the cookies to land in different places: an HTTP persona keeps them in its
78
+ * jar, a browser run plants them on a Playwright context. Both need the same
79
+ * handshake, and it is the kind of sequence that quietly diverges once it is
80
+ * written twice.
81
+ */
82
+ export declare const establishOperatorSession: (fetchImpl: typeof fetch, apiUrl: string, persona: ResolvedPersona, options: OperatorSignInOptions, extraHeaders?: Record<string, string>) => Promise<OperatorSessionResult>;
83
+ /**
84
+ * Sign a persona in on a DEPLOYED stage, by having a Fabric operator act as
85
+ * them — the path that needs no test credential to exist anywhere.
86
+ *
87
+ * `POST /auth/sign-in/fabric` verifies an RS256 token against the stage's
88
+ * `FABRIC_AUTH_PUBLIC_KEY` and mints a session for a synthetic operator row
89
+ * granted the umbrella `admin` scope. Impersonation is then a header on each
90
+ * request rather than a second session, and its gate is that scope — not
91
+ * `user.role`, which is why this works without touching the app's roles.
92
+ *
93
+ * Asymmetric throughout: the stage can verify an operator token and never mint
94
+ * one, so nothing in a deployed environment is worth stealing. That is the
95
+ * property the actor secret cannot have, and the reason these are two classes
96
+ * instead of one with a flag.
97
+ */
98
+ export declare class OperatorSignIn implements PersonaSignIn {
99
+ private readonly apiUrl;
100
+ private readonly options;
101
+ private userId;
102
+ constructor(apiUrl: string, options: OperatorSignInOptions);
103
+ login(jar: ScenarioCookieJar, persona: ResolvedPersona): Promise<void>;
104
+ headers(): Record<string, string>;
105
+ }
@@ -0,0 +1,128 @@
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({
71
+ token,
72
+ actAs: {
73
+ email: persona.email,
74
+ name: persona.name,
75
+ create: options.createMissing ?? false,
76
+ ...(persona.roles[0] ? { role: persona.roles[0] } : {}),
77
+ },
78
+ }),
79
+ });
80
+ if (!res.ok) {
81
+ throw await failed('operator sign-in', persona.id, res);
82
+ }
83
+ const setCookies = res.headers.getSetCookie?.() ?? [];
84
+ if (setCookies.length === 0) {
85
+ throw new Error(`[scenario] operator sign-in for '${persona.id}' returned no session cookie`);
86
+ }
87
+ const body = (await res.json().catch(() => null));
88
+ const userId = body?.actAs?.userId;
89
+ if (!userId) {
90
+ throw new Error(`[scenario] operator sign-in for '${persona.id}' returned no user to act as — ` +
91
+ 'the target is running a @pikku/better-auth too old to resolve one');
92
+ }
93
+ return { setCookies, userId: String(userId) };
94
+ };
95
+ /**
96
+ * Sign a persona in on a DEPLOYED stage, by having a Fabric operator act as
97
+ * them — the path that needs no test credential to exist anywhere.
98
+ *
99
+ * `POST /auth/sign-in/fabric` verifies an RS256 token against the stage's
100
+ * `FABRIC_AUTH_PUBLIC_KEY` and mints a session for a synthetic operator row
101
+ * granted the umbrella `admin` scope. Impersonation is then a header on each
102
+ * request rather than a second session, and its gate is that scope — not
103
+ * `user.role`, which is why this works without touching the app's roles.
104
+ *
105
+ * Asymmetric throughout: the stage can verify an operator token and never mint
106
+ * one, so nothing in a deployed environment is worth stealing. That is the
107
+ * property the actor secret cannot have, and the reason these are two classes
108
+ * instead of one with a flag.
109
+ */
110
+ export class OperatorSignIn {
111
+ apiUrl;
112
+ options;
113
+ userId = null;
114
+ constructor(apiUrl, options) {
115
+ this.apiUrl = apiUrl;
116
+ this.options = options;
117
+ }
118
+ async login(jar, persona) {
119
+ const { userId } = await establishOperatorSession(jar.fetch, this.apiUrl, persona, this.options);
120
+ this.userId = userId;
121
+ }
122
+ headers() {
123
+ if (!this.userId) {
124
+ throw new Error('[scenario] operator session has no persona to act as — login() first');
125
+ }
126
+ return { [IMPERSONATE_USER_ID_HEADER]: this.userId };
127
+ }
128
+ }
@@ -17,4 +17,5 @@ export type { CorePersona, CorePersonas, PersonaAccountMeta, PersonaDefinitions,
17
17
  * Lambda deploy would load outright.
18
18
  */
19
19
  export { HttpPersona, createHttpPersonas, type HttpPersonasConfig, } from '../../services/http-personas.js';
20
+ export { ActorSignIn, OperatorSignIn, establishOperatorSession, IMPERSONATE_USER_ID_HEADER, type PersonaSignIn, type OperatorSignInOptions, type OperatorSessionResult, } from '../../services/persona-sign-in.js';
20
21
  export { postScenarioJson, readScenarioHttpResponse, } from '../../services/personas-service.js';
@@ -13,4 +13,5 @@ export { personaEmail, personaEmails } from './persona-email.js';
13
13
  * Lambda deploy would load outright.
14
14
  */
15
15
  export { HttpPersona, createHttpPersonas, } from '../../services/http-personas.js';
16
+ export { ActorSignIn, OperatorSignIn, establishOperatorSession, IMPERSONATE_USER_ID_HEADER, } from '../../services/persona-sign-in.js';
16
17
  export { postScenarioJson, readScenarioHttpResponse, } from '../../services/personas-service.js';
@@ -5,8 +5,17 @@ export const createCookieJar = (apiUrl) => {
5
5
  fetch: async (input, init) => {
6
6
  const headers = new Headers(init?.headers);
7
7
  headers.set('origin', origin);
8
- const held = [...jar].map(([name, value]) => `${name}=${value}`);
8
+ // The caller's own cookie header wins per name: a request that already
9
+ // carries a session is stating which one it means, and emitting the jar's
10
+ // copy alongside it sends the same name twice.
9
11
  const caller = headers.get('cookie');
12
+ const named = new Set((caller ?? '')
13
+ .split(';')
14
+ .map((pair) => pair.split('=')[0]?.trim())
15
+ .filter(Boolean));
16
+ const held = [...jar]
17
+ .filter(([name]) => !named.has(name))
18
+ .map(([name, value]) => `${name}=${value}`);
10
19
  if (held.length > 0 || caller) {
11
20
  headers.set('cookie', [caller, ...held].filter(Boolean).join('; '));
12
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.90",
3
+ "version": "0.12.92",
4
4
  "description": "The Pikku runtime — functions, wirings, services, middleware and types",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -272,9 +272,13 @@
272
272
  "validateAndBuildSystemRoleDefinitionsMeta"
273
273
  ],
274
274
  "./persona": [
275
+ "ActorSignIn",
275
276
  "HttpPersona",
277
+ "IMPERSONATE_USER_ID_HEADER",
278
+ "OperatorSignIn",
276
279
  "createHttpPersonas",
277
280
  "definePersonas",
281
+ "establishOperatorSession",
278
282
  "isRunnablePersona",
279
283
  "personaEmail",
280
284
  "personaEmails",
@@ -16,6 +16,12 @@ import {
16
16
  createCookieJar,
17
17
  type ScenarioCookieJar,
18
18
  } from '../wirings/workflow/scenario-cookie-jar.js'
19
+ import {
20
+ ActorSignIn,
21
+ OperatorSignIn,
22
+ type OperatorSignInOptions,
23
+ type PersonaSignIn,
24
+ } from './persona-sign-in.js'
19
25
  import { getSingletonServices } from '../pikku-state.js'
20
26
  import { AIProviderNotConfiguredError } from '../errors/errors.js'
21
27
 
@@ -30,8 +36,19 @@ export interface HttpPersonasConfig {
30
36
  /**
31
37
  * The impersonation secret. Sign-in only ever works for user rows flagged
32
38
  * `actor: true` — knowing the secret never impersonates real users.
39
+ *
40
+ * The local-development credential. A deployed stage has none, and passes
41
+ * {@link HttpPersonasConfig.operator} instead.
42
+ */
43
+ secret?: string
44
+ /**
45
+ * Fabric operator credentials, for signing personas into a DEPLOYED stage.
46
+ *
47
+ * Mutually exclusive with {@link HttpPersonasConfig.secret}: the operator
48
+ * path acts as the persona through an admin session rather than logging in as
49
+ * them, so no test credential has to exist on the target at all.
33
50
  */
34
- secret: string
51
+ operator?: OperatorSignInOptions
35
52
  /** Persona id → the declaration with its address filled in. */
36
53
  personas: Record<string, ResolvedPersona>
37
54
  /** Sign-in path under apiUrl. Default: the actor plugin's `/auth/sign-in/actor`. */
@@ -49,12 +66,13 @@ export interface HttpPersonasConfig {
49
66
  }
50
67
 
51
68
  /**
52
- * Default HTTP-backed persona. Signs in lazily on first invoke via the Better
53
- * Auth actor plugin (`POST /auth/sign-in/actor` with `{ email, secret }`
54
- * the plugin upserts the actor-flagged user row and mints a session whose
55
- * `actor` flag flows into audits/analytics). Holds the session cookies for
56
- * its lifetime; a 401 mid-run re-logs-in once (long health-check runs can
57
- * outlive a session).
69
+ * Default HTTP-backed persona. Signs in lazily on first invoke, holds the
70
+ * session cookies for its lifetime, and re-logs-in once on a 401 mid-run (long
71
+ * health-check runs can outlive a session).
72
+ *
73
+ * How it signs in depends on the target, and the two ways are not
74
+ * interchangeable see {@link ActorSignIn} for local development and
75
+ * {@link OperatorSignIn} for a deployed stage.
58
76
  */
59
77
  export class HttpPersona implements ScenarioPersona {
60
78
  private jar: ScenarioCookieJar
@@ -65,6 +83,7 @@ export class HttpPersona implements ScenarioPersona {
65
83
  * established.
66
84
  */
67
85
  private signedIn = false
86
+ private signIn: PersonaSignIn
68
87
 
69
88
  constructor(
70
89
  readonly name: string,
@@ -72,6 +91,19 @@ export class HttpPersona implements ScenarioPersona {
72
91
  private config: HttpPersonasConfig
73
92
  ) {
74
93
  this.jar = createCookieJar(config.apiUrl)
94
+ if (config.operator) {
95
+ this.signIn = new OperatorSignIn(config.apiUrl, config.operator)
96
+ } else if (config.secret) {
97
+ this.signIn = new ActorSignIn(
98
+ config.apiUrl,
99
+ config.secret,
100
+ config.signInPath ?? '/auth/sign-in/actor'
101
+ )
102
+ } else {
103
+ throw new Error(
104
+ `[scenario] persona '${name}' has no way to sign in — set 'secret' for a dev target or 'operator' for a deployed one`
105
+ )
106
+ }
75
107
  }
76
108
 
77
109
  get email(): string {
@@ -161,7 +193,9 @@ export class HttpPersona implements ScenarioPersona {
161
193
  await this.login()
162
194
  }
163
195
  const sessionPath = this.config.sessionPath ?? '/auth/get-session'
164
- const res = await this.jar.fetch(`${this.config.apiUrl}${sessionPath}`)
196
+ const res = await this.jar.fetch(`${this.config.apiUrl}${sessionPath}`, {
197
+ headers: this.signIn.headers(),
198
+ })
165
199
  if (!res.ok) {
166
200
  return null
167
201
  }
@@ -226,7 +260,10 @@ export class HttpPersona implements ScenarioPersona {
226
260
  const send = () =>
227
261
  this.jar.fetch(url, {
228
262
  method: 'POST',
229
- headers: { 'content-type': 'application/json' },
263
+ headers: {
264
+ 'content-type': 'application/json',
265
+ ...this.signIn.headers(),
266
+ },
230
267
  body: JSON.stringify(body),
231
268
  })
232
269
 
@@ -255,7 +292,11 @@ export class HttpPersona implements ScenarioPersona {
255
292
  const rpcPath = this.config.rpcPath ?? '/rpc'
256
293
  return this.jar.fetch(`${this.config.apiUrl}${rpcPath}/${rpcName}`, {
257
294
  method: 'POST',
258
- headers: { 'content-type': 'application/json', ...extraHeaders },
295
+ headers: {
296
+ 'content-type': 'application/json',
297
+ ...this.signIn.headers(),
298
+ ...extraHeaders,
299
+ },
259
300
  body: JSON.stringify({ data }),
260
301
  })
261
302
  }
@@ -267,29 +308,7 @@ export class HttpPersona implements ScenarioPersona {
267
308
  }
268
309
 
269
310
  private async login(): Promise<void> {
270
- const signInPath = this.config.signInPath ?? '/auth/sign-in/actor'
271
- const res = await this.jar.fetch(`${this.config.apiUrl}${signInPath}`, {
272
- method: 'POST',
273
- headers: { 'content-type': 'application/json' },
274
- body: JSON.stringify({
275
- email: this.persona.email,
276
- name: this.persona.name,
277
- secret: this.config.secret,
278
- }),
279
- })
280
- if (!res.ok) {
281
- const body = (await res.text().catch(() => '')).slice(0, 300)
282
- throw new Error(
283
- `[scenario] persona sign-in failed for '${this.name}' (${res.status}): ${body}`
284
- )
285
- }
286
- // What proves a session was established is this response setting a cookie,
287
- // not the jar being non-empty — the target may have set one earlier.
288
- if (res.headers.getSetCookie().length === 0) {
289
- throw new Error(
290
- `[scenario] persona sign-in for '${this.name}' returned no session cookie`
291
- )
292
- }
311
+ await this.signIn.login(this.jar, this.persona)
293
312
  this.signedIn = true
294
313
  }
295
314
  }