@pikku/core 0.12.117 → 0.12.120

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,98 @@
1
+ ## 0.12.120
2
+
3
+ ### Patch Changes
4
+
5
+ - 4a9dcd2: `admin:listUsers` now pages, counts and can carry roles.
6
+
7
+ `ListUsersInput` gains `offset` and `includeRoles`; `ListUsersOutput` gains `total`, and each `User` gains `roles` and `fields`. `total` is how many users match `search`, which is what a pager counts against — `users.length` never was, because it is capped by `limit`.
8
+
9
+ ```typescript
10
+ const { users, total } = await rpc.invoke('admin:listUsers', {
11
+ search: 'example.com',
12
+ limit: 50,
13
+ offset: 50,
14
+ includeRoles: true,
15
+ })
16
+ ```
17
+
18
+ Paging only means something over a stable order, so the query now sorts newest first rather than however the database felt like returning rows.
19
+
20
+ Synthetic principals — the platform credential owner, Fabric service users, scenario actors — are excluded by the query instead of dropped from the page afterwards. Filtering after the fact broke both halves of paging: `limit` had already counted the rows it then discarded, so a page came back short, and `offset` skipped synthetic rows as though they were people, so the same person could appear on two pages or on none.
21
+
22
+ `includeRoles` is refused without `admin:scopes:read`. `admin:users:list` says who may see the directory; it does not say who may see what each of those users can do.
23
+
24
+ `ScopeService` gains `listRolesForUsers(userIds)`, implemented in `@pikku/kysely`. It answers for every id asked for — an empty array for a user holding no roles, so a caller cannot read a missing key as "holds nothing" — and chunks its `in` list to stay inside the bound-parameter cap. A page of users used to cost one query per row, which on a database reached over the network is a round trip per row.
25
+
26
+ ```typescript
27
+ listRolesForUsers(userIds: string[]): Promise<Record<string, string[]>>
28
+ ```
29
+
30
+ Anything implementing `ScopeService` outside this repository has to add it.
31
+
32
+ - 5ab24ad: Scenario recordings can be followed by eye, at no cost to the run. The encode holds each browser step's starting screen, and the last frame, for two seconds (`E2E_VIDEO_STEP_HOLD_MS`, `0` to turn off). The step offsets in the run record account for the holds. Recordings are made at the viewport's own size instead of Playwright's 800px downscale, and they show a pointer that follows the mouse and jumps to each filled field. Drivers get an optional `ScenarioBrowserProvider.markVideoStep(actor)`, which returns a step's offset in the finished video. It is preferred over `videoStartedAt`.
33
+ - 42b7ac3: The app decides which of an addon's functions `rpc.exposed` reaches
34
+
35
+ `wireAddon` takes `expose?: boolean | string[]`, mirroring `mcp`. Unset or
36
+ `true` keeps the functions the addon declared `expose: true`; `false` exposes
37
+ none of the instance's functions; a list names exactly the functions to expose,
38
+ whether or not the addon declared them, typed against the addon's function
39
+ names. A listed name the addon does not publish fails the build with PKU343, a value that is not written inline fails it with
40
+ PKU344,
41
+ and the deploy analyzer's per-addon unit carries only what the wiring exposes.
42
+
43
+ ## 0.12.119
44
+
45
+ ### Patch Changes
46
+
47
+ - e85f07e: A route declaring a numeric parameter could not be called over a query string. `coerceTopLevelDataFromSchema` converted an `array` from a comma-separated string and a `date-time` from text, but left numbers alone — and because the JSON Schema check runs before zod, `?year=2027` was rejected as `Instance type "string" is invalid. Expected "integer"` before the function ever ran. `z.coerce.number()` does not help: it runs after the schema has already refused. The shipped validator is spec-compliant and has no `coerceTypes` of its own, and `minimum`/`maximum` cannot stand in for one, being value constraints that apply only to instances that are already numbers.
48
+
49
+ `integer` and `number` now coerce from a string, on the same path as the two existing cases — so query strings, path params and argv reach a numeric parameter.
50
+
51
+ A string converts only when the number it produces prints back as the identical text. That rules out the readings that quietly rewrite the input — `007`, `+5`, `1e3`, `2027.50`, a padded `12` — and the values `Number` invents from nothing, where `''` and `' '` both become `0`. It also rules out `9007199254740993`, which does not survive a double: a caller who sends an id as a string is usually doing so precisely because it does not, and rounding it silently would be data corruption. `NaN` and `Infinity` are refused for the same reason, and a fraction is refused where `integer` was asked for. Everything refused is left exactly as it arrived, so the validator reports the value the caller actually sent.
52
+
53
+ Note that this applies wherever the existing coercions already applied, which includes a JSON body — a string `"2027"` for a numeric field is now accepted there too, consistently with how an array and a `date-time` have always been read. A union `type` such as `['integer', 'null']` is left alone, as the array and `date-time` cases already leave it.
54
+
55
+ ## 0.12.118
56
+
57
+ ### Patch Changes
58
+
59
+ - b4a895e: A scenario step row now carries where it fell inside each actor's recording, and a scenario result now says which feature it came from by id as well as by title.
60
+
61
+ The video offset is recorded rather than estimated. Documentation built out of a run has to turn a step sentence into an exact moment — a chapter marker, or a still pulled with `ffmpeg -ss` — and the only number available until now was the scenario clock summed out of the ladder. That clock is not the video's: recording starts when the actor's browser context opens, which is somewhere after step one, and every RPC step before or between the browser ones burns scenario time while the file sits still. The two drift apart by however much of the scenario happened off camera, and a still forty seconds out is a picture of the wrong screen with nothing to say it is wrong.
62
+
63
+ So `ScenarioStepRow.video` is stamped at the moment the step runs, from the driver's own clock: `ScenarioBrowserProvider.videoStartedAt(actor)` reports when that actor's context was opened with `recordVideo`, and the runner subtracts. It is a list of `{ actor, offsetMs }` rather than one number, because a video belongs to an actor and not to the scenario — one step touching two windows falls at a different moment in each, and an offset that does not name its file cannot be seeked to. A run without video, a step with no actor, and a step that never ran all carry nothing, which is what keeps the console's existing estimate as the fallback for runs recorded before this.
64
+
65
+ `ScenarioResult.featureId` is the other half of the same problem. `feature` is a title written for people to read and rewritten whenever the wording improves, so nothing downstream could key off it; the id `addFeature` registered the feature under does not move. The runner threads it from the plan, which read it off the registry, instead of deriving it from the label. `scenarioName` already carried the registration id and keeps it.
66
+
67
+ - b4a895e: `pikku scenario guide` writes the user guide a scenario suite already contains. A feature reads as a page and a scenario as a section, and a run leaves screenshots behind with the captions their author took them under — the command joins that to the editorial prose a project checks in under `docs/` and writes markdown. It renders no HTML, ships no components, resolves no asset URLs and calls no model: an image is an ordinary relative `![caption](path)`, and whoever consumes the markdown rewrites the paths.
68
+
69
+ A page cites a feature by leaving the marker pair where the block belongs:
70
+
71
+ ```markdown
72
+ ---
73
+ title: Deployments
74
+ ---
75
+
76
+ A deployment is one tracked shipment of your app.
77
+
78
+ <!-- pikku:guide feature=deploymentsFeature -->
79
+ <!-- /pikku:guide -->
80
+
81
+ ## Does my app go down during a deploy?
82
+ ```
83
+
84
+ That one line does both halves of the job. It says _where_ the block goes, which a frontmatter list cannot express, and it is what the coverage gate counts to decide _whether_ a feature is documented at all. The mapping stays many-to-many and falls out of the union of every marker in the tree. A rebuild rewrites exactly the regions between the markers, so every sentence a human wrote around them survives.
85
+
86
+ **The steps are evidence, not content.** A generated block is the scenario's title, the description its author wrote, and the shots it filed — never a numbered Given/When/Then ladder, which is a test report and not something anybody arrives at a documentation page wanting. The sentences are still what the guide is kept honest against: `docs/.guide.lock` records a hash of each feature's step sentences and artifact ids, deliberately not of the image bytes. Restyling a UI changes every screenshot and no sentence; inserting or renaming a step changes what the prose was describing, and the page is reported stale. The lock is generated and checked in, so no hash is ever typed or merged by hand — and a tree whose lock is untracked reports every page as current forever.
87
+
88
+ Every registered feature has to be cited by some page, and a feature that is pure plumbing says so rather than being written about — `pikkuFeature({ document: false })`, threaded through the inspector and `FeatureMeta`. An uncited feature fails the command by name; `--allow-undocumented` downgrades that one failure to a report. A page citing a feature id that is not registered stays an error either way.
89
+
90
+ A guide is only written out of a run that can stand behind it. A run that failed or was killed halfway is refused, because a page is a claim that the product does what it says. So is a narrowed one: `pikku scenario run --flows`/`--features`/`--tags` leaves out scenarios the suite has, and a guide built from it would describe those flows as though they do not exist. `ScenarioRunRecord.selection` records the filters a run was selected with, since nothing in the results afterwards can tell a suite of forty from forty that were asked for.
91
+
92
+ Results are joined to features by `featureId`, falling back to the display name only for records written before that field existed — a title is rewritten freely and two features may share one.
93
+
94
+ Emission is deterministic — identical inputs give byte-identical output, and no timestamp goes in that did not come from the run record. Frontmatter the compiler does not own (`slug`, `draft`, `sidebar_position`, anything else a docs site reads) passes through untouched, and a source written with CRLF line endings is read as having frontmatter.
95
+
1
96
  ## 0.12.117
2
97
 
3
98
  ### Patch Changes
package/dist/schema.js CHANGED
@@ -77,6 +77,41 @@ export const applyDefaultsFromSchema = (schemaName, data, packageName = null) =>
77
77
  }
78
78
  return result;
79
79
  };
80
+ /**
81
+ * A query string and an argv array carry numbers as text, and the JSON Schema
82
+ * check runs first — so without this a route declaring `year: number` can never
83
+ * be called at all: `?year=2027` is rejected as `Instance type "string" is
84
+ * invalid` long before zod or the function body get a look at it. The shipped
85
+ * validator is spec-compliant and has no `coerceTypes` of its own, so the
86
+ * conversion has to happen here.
87
+ *
88
+ * The rule is that a coerced value must be the *same* value, not merely a
89
+ * plausible reading of it. So a string converts only if the number it produces
90
+ * prints back as the identical text. That is a stronger test than it looks: it
91
+ * is what stops `9007199254740993` from silently becoming ...92, which is the
92
+ * one failure mode leniency cannot excuse, because a caller who sends an id as
93
+ * a string is usually doing it precisely because the number does not survive a
94
+ * double. It also rules out every reading that quietly rewrites the input —
95
+ * `007`, `+5`, `1e3`, `2027.50`, a padded ` 12 ` — along with the values
96
+ * `Number` invents out of nothing, where `''` and `' '` both become 0.
97
+ *
98
+ * Anything it rejects is left exactly as it arrived, so the validator reports
99
+ * the value the caller actually sent rather than one this function made up.
100
+ * Erring that way costs a loud 422 on an odd-looking but legal input; erring
101
+ * the other way corrupts data in silence.
102
+ */
103
+ const coerceNumeric = (value, integer) => {
104
+ const parsed = Number(value);
105
+ // Rejects NaN and Infinity, both of which would otherwise round-trip through
106
+ // String() unchanged, and neither of which JSON can carry anyway.
107
+ if (!Number.isFinite(parsed))
108
+ return value;
109
+ if (String(parsed) !== value)
110
+ return value;
111
+ if (integer && !Number.isInteger(parsed))
112
+ return value;
113
+ return parsed;
114
+ };
80
115
  export const coerceTopLevelDataFromSchema = (schemaName, data, packageName = null) => {
81
116
  const schema = pikkuState(packageName, 'misc', 'schemas').get(schemaName);
82
117
  if (!schema?.properties)
@@ -96,6 +131,10 @@ export const coerceTopLevelDataFromSchema = (schemaName, data, packageName = nul
96
131
  else if (type === 'string' && property.format === 'date-time') {
97
132
  data[key] = new Date(data[key]);
98
133
  }
134
+ else if ((type === 'integer' || type === 'number') &&
135
+ typeof data[key] === 'string') {
136
+ data[key] = coerceNumeric(data[key], type === 'integer');
137
+ }
99
138
  }
100
139
  };
101
140
  /**
@@ -57,6 +57,19 @@ export interface ScopeService {
57
57
  addUserToRole(userId: string, role: string, grantedBy?: string): Promise<void>;
58
58
  removeUserFromRole(userId: string, role: string): Promise<void>;
59
59
  listUserRoles(userId: string): Promise<string[]>;
60
+ /**
61
+ * The roles held by each of `userIds`, keyed by user id.
62
+ *
63
+ * Every id asked for comes back as a key, holding an empty array when the
64
+ * user has no roles — so a caller can index the result directly and cannot
65
+ * mistake "this user was not in the answer" for "this user holds nothing".
66
+ *
67
+ * Exists because listing a page of the user directory otherwise costs one
68
+ * query per row. Against a database reached over the network — which is the
69
+ * normal case for a deployed unit — that is a round trip per user, so a page
70
+ * of fifty is fifty of them.
71
+ */
72
+ listRolesForUsers(userIds: string[]): Promise<Record<string, string[]>>;
60
73
  /** Grants outside of any role; additive with the user's role-derived scopes. */
61
74
  addScopeToUser(userId: string, scope: string, grantedBy?: string): Promise<void>;
62
75
  removeScopeFromUser(userId: string, scope: string): Promise<void>;
@@ -37,6 +37,8 @@ export interface PikkuPackageState {
37
37
  rpcEndpoint?: string;
38
38
  auth?: boolean;
39
39
  tags?: string[];
40
+ /** Which functions `rpc.exposed` may call: unset/`true` keeps the addon's own `expose`, `false` none, a list exactly those */
41
+ expose?: boolean | string[];
40
42
  /** Required of every function in this package, on top of the function's own */
41
43
  scopes?: string[];
42
44
  /** Per-instance name-aliases: logical name the addon reads -> actual project secret name */
@@ -16,6 +16,16 @@ export type WireAddonConfig = {
16
16
  * and is typed against the addon's function names.
17
17
  */
18
18
  mcp?: boolean | string[];
19
+ /**
20
+ * Which of the addon's functions `rpc.exposed` may call, under
21
+ * `<name>:<function>`. Unset or `true` keeps the functions the addon itself
22
+ * declared `expose: true`; `false` exposes none of them; a list names exactly
23
+ * the functions to expose, whether or not the addon declared them, and is
24
+ * typed against the addon's function names.
25
+ *
26
+ * knowledge: decisions/security/wire-addon-expose-selects-the-rpc-surface.md
27
+ */
28
+ expose?: boolean | string[];
19
29
  /**
20
30
  * Serves this addon's MCP tools on an endpoint of their own rather than
21
31
  * folding them into the project's single `/mcp`. `true` mounts them at
@@ -62,6 +72,14 @@ export type WireAddonConfig = {
62
72
  * @example snippet: addonWiring
63
73
  */
64
74
  export declare const wireAddon: (config: WireAddonConfig) => void;
75
+ /**
76
+ * Whether `rpc.exposed` may reach an addon function through the instance that
77
+ * resolved it. The wiring's `expose` decides when it is `false` or a list;
78
+ * otherwise the addon's own `expose: true` does.
79
+ *
80
+ * knowledge: decisions/security/wire-addon-expose-selects-the-rpc-surface.md
81
+ */
82
+ export declare const isAddonFunctionExposed: (expose: boolean | string[] | undefined, functionName: string, declaredExpose: boolean | undefined) => boolean;
65
83
  /**
66
84
  * knowledge: decisions/security/addon-scopes-are-resolved-where-the-function-runs.md
67
85
  */
@@ -12,6 +12,7 @@ export const wireAddon = (config) => {
12
12
  rpcEndpoint: config.rpcEndpoint,
13
13
  auth: config.auth,
14
14
  tags: config.tags,
15
+ ...(config.expose !== undefined ? { expose: config.expose } : {}),
15
16
  ...(config.scopes ? { scopes: config.scopes } : {}),
16
17
  ...(config.secretOverrides
17
18
  ? { secretOverrides: config.secretOverrides }
@@ -32,6 +33,22 @@ export const wireAddon = (config) => {
32
33
  : {}),
33
34
  });
34
35
  };
36
+ /**
37
+ * Whether `rpc.exposed` may reach an addon function through the instance that
38
+ * resolved it. The wiring's `expose` decides when it is `false` or a list;
39
+ * otherwise the addon's own `expose: true` does.
40
+ *
41
+ * knowledge: decisions/security/wire-addon-expose-selects-the-rpc-surface.md
42
+ */
43
+ export const isAddonFunctionExposed = (expose, functionName, declaredExpose) => {
44
+ if (Array.isArray(expose)) {
45
+ return expose.includes(functionName);
46
+ }
47
+ if (expose === false) {
48
+ return false;
49
+ }
50
+ return declaredExpose === true;
51
+ };
35
52
  /**
36
53
  * The addon configs a running function is governed by: the named instance when
37
54
  * the caller resolved one, every instance of the package otherwise.
@@ -1,5 +1,6 @@
1
1
  import { runPikkuFunc } from '../../function/function-runner.js';
2
2
  import { addonInstanceForNamespace } from '../addon/addon-runner.js';
3
+ import { isAddonFunctionExposed } from '../addon/wire-addon.js';
3
4
  import { pikkuState } from '../../pikku-state.js';
4
5
  import { PikkuError, addError } from '../../errors/error-handler.js';
5
6
  import { parseVersionedId } from '../../version.js';
@@ -110,10 +111,11 @@ export class ContextAwareRPCService {
110
111
  }
111
112
  async rpcExposed(funcName, data) {
112
113
  let functionMeta;
114
+ let resolvedAddon = null;
113
115
  if (funcName.includes(':')) {
114
- const resolved = resolveNamespace(funcName);
115
- if (resolved) {
116
- functionMeta = pikkuState(resolved.package, 'function', 'meta')[resolved.function];
116
+ resolvedAddon = resolveNamespace(funcName);
117
+ if (resolvedAddon) {
118
+ functionMeta = pikkuState(resolvedAddon.package, 'function', 'meta')[resolvedAddon.function];
117
119
  }
118
120
  }
119
121
  else {
@@ -121,12 +123,24 @@ export class ContextAwareRPCService {
121
123
  functionMeta = pikkuState(resolved.packageName, 'function', 'meta')[resolved.pikkuFuncId];
122
124
  }
123
125
  if (!functionMeta) {
124
- if (funcName.includes(':') && this.services.deploymentService) {
126
+ // The addon runs in another deploy unit, which only carries the
127
+ // functions the analyzer found exposed. A wiring present here still
128
+ // gets to narrow what is forwarded.
129
+ const refusedHere = resolvedAddon &&
130
+ (resolvedAddon.addonConfig.expose === false ||
131
+ (Array.isArray(resolvedAddon.addonConfig.expose) &&
132
+ !resolvedAddon.addonConfig.expose.includes(resolvedAddon.function)));
133
+ if (funcName.includes(':') &&
134
+ this.services.deploymentService &&
135
+ !refusedHere) {
125
136
  return await this.rpc(funcName, data);
126
137
  }
127
138
  throw new RPCNotFoundError(funcName);
128
139
  }
129
- if (!functionMeta.expose || functionMeta.scenarioStep) {
140
+ const exposed = resolvedAddon
141
+ ? isAddonFunctionExposed(resolvedAddon.addonConfig.expose, resolvedAddon.function, functionMeta.expose)
142
+ : functionMeta.expose;
143
+ if (!exposed || functionMeta.scenarioStep) {
130
144
  throw new RPCNotFoundError(funcName);
131
145
  }
132
146
  return await this.rpc(funcName, data);
@@ -45,6 +45,8 @@ export interface ResolvedFunction {
45
45
  package: string;
46
46
  auth?: boolean;
47
47
  tags?: string[];
48
+ /** Set by the consuming app: which functions `rpc.exposed` may call */
49
+ expose?: boolean | string[];
48
50
  rpcEndpoint?: string;
49
51
  secretOverrides?: Record<string, string>;
50
52
  variableOverrides?: Record<string, string>;
@@ -3,11 +3,12 @@ import { InMemoryWorkflowService } from '../../services/in-memory-workflow-servi
3
3
  import type { RunLifecycleContext, WorkflowRunEngine, WorkflowRunExtension } from './workflow-run-engine.types.js';
4
4
  import type { PikkuRawWire } from '../../types/core.types.js';
5
5
  import type { ScenarioPersonas } from '../../services/personas-service.js';
6
+ import type { ScenarioStepVideoOffset } from './scenario-run.types.js';
6
7
  import type { ScenarioBrowserProvider, ScenarioEnvironment, ScenarioSurface } from './scenario-step.types.js';
7
8
  import type { PikkuWorkflowWire, WorkflowQueueOptions } from './workflow.types.js';
8
9
  export { addFeature, resolveFeatureScenarios } from './feature.js';
9
10
  export type { CoreFeature, CoreFeatureScenario, FeatureMeta, FeaturesMeta, PikkuBrowserWire, PikkuScenarioWire, ScenarioBrowserFailure, ScenarioBrowserProvider, ScenarioEnvironment, ScenarioStepKind, ScenarioStepMeta, ScenarioStepOptions, ScenarioScreenshotOptions, ScenarioStepPhase, ScenarioSurface, TestIdSelector, } from './scenario.types.js';
10
- export type { ScenarioArtifact, ScenarioFailureDetail, ScenarioResult, ScenarioRunRecord, ScenarioRunReport, ScenarioRunStatus, ScenarioRunStore, ScenarioRunSummary, ScenarioStepRow, } from './scenario-run.types.js';
11
+ export type { ScenarioArtifact, ScenarioFailureDetail, ScenarioResult, ScenarioRunRecord, ScenarioRunReport, ScenarioRunSelection, ScenarioRunStatus, ScenarioRunStore, ScenarioRunSummary, ScenarioStepRow, ScenarioStepVideoOffset, } from './scenario-run.types.js';
11
12
  export { SCENARIO_SURFACES } from './scenario-step.types.js';
12
13
  export { resolveScenarioSurfaces } from './scenario-surface.js';
13
14
  export { pollUntil, type PollOptions } from './scenario-poll.js';
@@ -161,6 +162,7 @@ export declare class PikkuScenarioService implements WorkflowRunExtension {
161
162
  private readonly engine;
162
163
  private runActors;
163
164
  private runContexts;
165
+ private runVideoOffsets;
164
166
  private scenarioBrowserProvider?;
165
167
  private scenarioEnvironment?;
166
168
  private runSurface;
@@ -193,6 +195,25 @@ export declare class PikkuScenarioService implements WorkflowRunExtension {
193
195
  */
194
196
  setScenarioEnvironment(env: ScenarioEnvironment | undefined): void;
195
197
  getScenarioEnvironment(): ScenarioEnvironment | undefined;
198
+ /**
199
+ * Where each of a run's browser steps fell in its actor's video, keyed by the
200
+ * durable step name, handed over and forgotten in one call.
201
+ *
202
+ * Taken rather than read because the runner joins these onto the step rows
203
+ * after the run has finished — which is past the point anything else would
204
+ * clear them, and the only moment they are still wanted.
205
+ */
206
+ takeStepVideoOffsets(runId: string): Map<string, ScenarioStepVideoOffset[]>;
207
+ /** Where a step starting now falls in this actor's recording, if anywhere. */
208
+ private videoOffsetFor;
209
+ /**
210
+ * Stamp where a step began inside one actor's recording.
211
+ *
212
+ * First write per actor wins: a `then` step runs once per witness, and the
213
+ * moment the reader wants is when the sentence started, not when its last
214
+ * witness got around to the browser.
215
+ */
216
+ private recordVideoOffset;
196
217
  attachRunContext(runId: string, workflowMeta: any, options?: {
197
218
  actors?: ScenarioPersonas;
198
219
  }): Promise<void>;
@@ -243,6 +243,10 @@ export class PikkuScenarioService {
243
243
  // so the body and its hooks share one object rather than reading it back off
244
244
  // the persisted wire.
245
245
  runContexts = new Map();
246
+ // Where each browser step landed in its actor's recording, per run. Held
247
+ // apart from the run context because the runner reads it once the run has
248
+ // ended, which is exactly when `detachRunContext` has cleared that.
249
+ runVideoOffsets = new Map();
246
250
  scenarioBrowserProvider;
247
251
  scenarioEnvironment;
248
252
  runSurface = 'default';
@@ -292,6 +296,50 @@ export class PikkuScenarioService {
292
296
  getScenarioEnvironment() {
293
297
  return this.scenarioEnvironment;
294
298
  }
299
+ /**
300
+ * Where each of a run's browser steps fell in its actor's video, keyed by the
301
+ * durable step name, handed over and forgotten in one call.
302
+ *
303
+ * Taken rather than read because the runner joins these onto the step rows
304
+ * after the run has finished — which is past the point anything else would
305
+ * clear them, and the only moment they are still wanted.
306
+ */
307
+ takeStepVideoOffsets(runId) {
308
+ const offsets = this.runVideoOffsets.get(runId);
309
+ this.runVideoOffsets.delete(runId);
310
+ return offsets ?? new Map();
311
+ }
312
+ /** Where a step starting now falls in this actor's recording, if anywhere. */
313
+ videoOffsetFor(actor) {
314
+ const provider = this.scenarioBrowserProvider;
315
+ if (provider?.markVideoStep) {
316
+ return provider.markVideoStep(actor);
317
+ }
318
+ const startedAt = provider?.videoStartedAt?.(actor);
319
+ return startedAt === undefined
320
+ ? undefined
321
+ : Math.max(0, Date.now() - startedAt);
322
+ }
323
+ /**
324
+ * Stamp where a step began inside one actor's recording.
325
+ *
326
+ * First write per actor wins: a `then` step runs once per witness, and the
327
+ * moment the reader wants is when the sentence started, not when its last
328
+ * witness got around to the browser.
329
+ */
330
+ recordVideoOffset(runId, stepName, actor, offsetMs) {
331
+ let byStep = this.runVideoOffsets.get(runId);
332
+ if (!byStep) {
333
+ byStep = new Map();
334
+ this.runVideoOffsets.set(runId, byStep);
335
+ }
336
+ const offsets = byStep.get(stepName) ?? [];
337
+ if (offsets.some((offset) => offset.actor === actor)) {
338
+ return;
339
+ }
340
+ offsets.push({ actor, offsetMs });
341
+ byStep.set(stepName, offsets);
342
+ }
295
343
  async attachRunContext(runId, workflowMeta, options) {
296
344
  const actors = options?.actors ??
297
345
  (workflowMeta.source === 'scenario'
@@ -641,14 +689,19 @@ export class PikkuScenarioService {
641
689
  // The dispatch guard above already refused an actor-less call, and a
642
690
  // browser binding always requires one.
643
691
  wire.browser = await this.scenarioBrowserProvider.sessionFor(actor.name);
692
+ const offsetMs = this.videoOffsetFor(actor.name);
693
+ if (offsetMs !== undefined) {
694
+ this.recordVideoOffset(runId, stepName, actor.name, offsetMs);
695
+ }
644
696
  }
645
- return await runPikkuFunc('workflow', workflowName, resolvedStepFunc, {
697
+ const result = await runPikkuFunc('workflow', workflowName, resolvedStepFunc, {
646
698
  singletonServices: getSingletonServices(),
647
699
  createWireServices: getCreateWireServices(),
648
700
  data: () => data,
649
701
  wire,
650
702
  packageName: packageName ?? undefined,
651
703
  });
704
+ return result;
652
705
  };
653
706
  if (resolution.kind === 'action') {
654
707
  if (resolution.fellBack &&
@@ -41,6 +41,21 @@ export interface ScenarioArtifact {
41
41
  /** Fit to show outside the run: a marketing card, a docs page, a gallery. */
42
42
  showcase?: boolean;
43
43
  }
44
+ /**
45
+ * Where a step falls inside one actor's recording.
46
+ *
47
+ * Measured from the moment that actor's browser context opened — which is when
48
+ * Playwright starts the file — rather than from the start of the scenario, so
49
+ * it addresses the video's own clock. The two differ by however long the
50
+ * scenario spent before that window existed, plus every non-browser step since,
51
+ * which is why the offset is recorded at the moment the step runs instead of
52
+ * being summed back out of the ladder afterwards.
53
+ */
54
+ export interface ScenarioStepVideoOffset {
55
+ /** Whose recording this offset is into: one actor, one video file. */
56
+ actor: string;
57
+ offsetMs: number;
58
+ }
44
59
  /** One step of a run, already joined to the prose that declared it. */
45
60
  export interface ScenarioStepRow {
46
61
  sentence: string;
@@ -54,6 +69,16 @@ export interface ScenarioStepRow {
54
69
  status: string;
55
70
  durationMs?: number;
56
71
  error?: string;
72
+ /**
73
+ * Where this step lands in each actor's video, for the actors whose window
74
+ * was being recorded when it ran. Absent for a step with no actor, a run
75
+ * without video, and a step that never ran at all.
76
+ *
77
+ * A list rather than one number because a video belongs to an actor, not to
78
+ * the scenario: a step touching two windows falls at a different moment in
79
+ * each, and an offset that does not name its file cannot be seeked to.
80
+ */
81
+ video?: ScenarioStepVideoOffset[];
57
82
  }
58
83
  /** Everything known about why one scenario failed. */
59
84
  export interface ScenarioFailureDetail {
@@ -76,10 +101,22 @@ export interface ScenarioResult {
76
101
  error?: string;
77
102
  steps?: ScenarioStepRow[];
78
103
  failure?: ScenarioFailureDetail;
79
- /** The scenario registration this ran, which the label alone does not give. */
104
+ /**
105
+ * The registered scenario this ran, by the id it is registered under — the
106
+ * same id `FeatureMetaEntry.scenario` references. The label alone does not
107
+ * give it, and unlike the label it is not rewritten when the prose is.
108
+ */
80
109
  scenarioName?: string;
81
- /** The feature that grouped it, when one did. */
110
+ /** The feature that grouped it — its display name, which is freely renamed. */
82
111
  feature?: string;
112
+ /**
113
+ * The registered feature that grouped it, by id.
114
+ *
115
+ * `feature` is a title someone writes for people to read, so nothing that
116
+ * outlives a run can key off it. This is what `addFeature` registered the
117
+ * feature under, and it is what survives the title being rewritten.
118
+ */
119
+ featureId?: string;
83
120
  tags?: string[];
84
121
  /** Images and footage this scenario produced, filed under the run. */
85
122
  artifacts?: ScenarioArtifact[];
@@ -103,6 +140,21 @@ export interface ScenarioRunReport {
103
140
  hookFailures: string[];
104
141
  }
105
142
  export type ScenarioRunStatus = 'running' | 'passed' | 'failed';
143
+ /**
144
+ * The filters a run was selected with, recorded when it was narrowed at all.
145
+ *
146
+ * A narrowed run is a partial record of the suite: scenarios a feature owns can
147
+ * be missing from it, and whole features can be absent, with nothing in the
148
+ * results to say so. Anything that reads a run as evidence of what the suite
149
+ * does — rather than of what happened that afternoon — has to be able to tell
150
+ * the two apart, and it cannot be inferred from the results afterwards.
151
+ */
152
+ export interface ScenarioRunSelection {
153
+ flows?: string[];
154
+ features?: string[];
155
+ tags?: string[];
156
+ excludeTags?: string[];
157
+ }
106
158
  /**
107
159
  * A whole run, as it is stored and read back.
108
160
  *
@@ -116,6 +168,8 @@ export interface ScenarioRunRecord extends ScenarioRunReport {
116
168
  status: ScenarioRunStatus;
117
169
  /** The surface the run targeted: `default`, `browser`, … */
118
170
  surface: string;
171
+ /** Absent on a run of the whole suite; see {@link ScenarioRunSelection}. */
172
+ selection?: ScenarioRunSelection;
119
173
  startedAt: string;
120
174
  finishedAt?: string;
121
175
  }
@@ -177,6 +177,14 @@ export interface ScenarioScreenshotOptions {
177
177
  /** Photograph the whole scrollable page rather than the viewport. */
178
178
  fullPage?: boolean;
179
179
  }
180
+ /**
181
+ * One actor's browser session, handed to a step as `wire.browser`.
182
+ *
183
+ * Only what every driver can honour is declared here. A driver package adds
184
+ * the rest by declaration-merging onto this interface — `@pikku/playwright`
185
+ * contributes `page`, `context` and `locate` — so a step written against the
186
+ * structural surface keeps working whichever driver runs it.
187
+ */
180
188
  export interface PikkuBrowserWire {
181
189
  /** The actor whose browser context this is */
182
190
  readonly actor: string;
@@ -235,6 +243,24 @@ export interface ScenarioBrowserProvider {
235
243
  * scenario's reset, long after the outcome that decides whether to keep them.
236
244
  */
237
245
  endScenario?(outcome: 'passed' | 'failed'): void;
246
+ /**
247
+ * When this actor's recording started, as epoch milliseconds.
248
+ *
249
+ * The seam that keeps the video clock out of `@pikku/core`: a driver knows
250
+ * when it opened the context it passed `recordVideo` to, and the runner turns
251
+ * that into a per-step offset. Absent for an actor with no window open, and
252
+ * for a run recording nothing — both of which leave the step's offset off.
253
+ */
254
+ videoStartedAt?(actorName: string): number | undefined;
255
+ /**
256
+ * Mark a browser step starting in this actor's recording, answering where it
257
+ * falls in the finished video (ms).
258
+ *
259
+ * Preferred over `videoStartedAt` when present: a driver that edits its
260
+ * footage afterwards — holding each step's screen still, say — is the only
261
+ * one that knows how far that moves the step. Undefined when nothing records.
262
+ */
263
+ markVideoStep?(actorName: string): number | undefined;
238
264
  /**
239
265
  * Snapshot every open window for a failed scenario. `label` identifies the
240
266
  * scenario in artifact filenames. Never throws: a failure to capture must
@@ -9,6 +9,14 @@ export type CoreFeature = {
9
9
  name: string;
10
10
  description?: string;
11
11
  tags?: string[];
12
+ /**
13
+ * Whether this feature is guide material. Defaults to true: a feature is a
14
+ * page of the user guide unless it says otherwise, so a feature nobody has
15
+ * written about is a gap `pikku scenario guide` reports rather than a page
16
+ * silently missing. Pure plumbing — a wire, a validation layer, a bearer
17
+ * auth handshake — sets it false and stops being a coverage problem.
18
+ */
19
+ document?: boolean;
12
20
  scenarios: readonly CoreFeatureScenario[];
13
21
  before?: CorePikkuFunctionHook;
14
22
  after?: CorePikkuFunctionHook;
@@ -22,6 +30,8 @@ export type FeatureMeta = {
22
30
  name: string;
23
31
  description?: string;
24
32
  tags: string[];
33
+ /** Present only when the feature opted out; absent means documented. */
34
+ document?: boolean;
25
35
  entries: FeatureMetaEntry[];
26
36
  unresolvedEntries: number;
27
37
  hasBefore: boolean;
@@ -24,6 +24,7 @@ A rule about who may do what, and which way it fails when it is unsure.
24
24
  - [Addon auth and tags only tighten, and resolve where the function runs](addon-auth-and-tags-only-tighten.md) — wireAddon auth and tags are applied in runPikkuFunc like scopes, but auth:false is ignored and tags resolve against the consuming app's tag groups rather than the addon package's
25
25
  - [Addon auth and tag gates apply wherever the function runs, including inside the addon](addon-config-gates-apply-only-at-the-namespaced-rpc-boundary.md) — wireAddon's auth and tags moved from the namespaced RPC boundary into runPikkuFunc, so they also apply to direct wirings and to bare intra-addon calls
26
26
  - [Addon scopes are resolved where the function runs](addon-scopes-are-resolved-where-the-function-runs.md) — wireAddon scopes are merged inside runPikkuFunc rather than at namespace resolution, because most wirings reach an addon function without ever resolving a namespace
27
+ - [wireAddon expose selects the RPC surface, and a list may widen it](wire-addon-expose-selects-the-rpc-surface.md) — The consuming app decides which addon functions rpc.exposed reaches per instance — unset keeps the addon's own expose, false closes the instance, a list names exactly what is callable — and the deploy analyzer applies the same rule
27
28
  - [Only a Symbol-branded framework result can request tool approval](ai-agent-approval-forwarding-requires-a-symbol-brand.md) — Approval markers are trusted from the APPROVAL_REQUIRED Symbol on a forwardsApproval tool, never from a JSON key an LLM could emit
28
29
  - [Credential requests are trusted only when Symbol-branded](ai-agent-credential-requests-are-symbol-branded.md) — The string key is a wire field; the Symbol is the capability, and only core can mint it
29
30
  - [An agent requires a session only when auth is true, but always enforces scopes and permissions](ai-agent-gate-requires-a-session-only-when-auth-is-true.md) — Agents follow pikkuSessionlessFunc semantics so crons and queue workers can run them; scopes are an AND gate checked before any permission I/O
@@ -0,0 +1,51 @@
1
+ ---
2
+ type: decision
3
+ title: wireAddon expose selects the RPC surface, and a list may widen it
4
+ description: The consuming app decides which addon functions rpc.exposed reaches per instance — unset keeps the addon's own expose, false closes the instance, a list names exactly what is callable — and the deploy analyzer applies the same rule
5
+ tags: addon, rpc, expose, authorization
6
+ ---
7
+
8
+ # wireAddon expose selects the RPC surface, and a list may widen it
9
+
10
+ `rpc.exposed('<name>:<fn>')` — what the generated `POST /rpc/:rpcName` forwards
11
+ to — used to consult only the addon's own `expose: true`. The app installing the
12
+ addon had no say: it could not close an addon whose author marked functions
13
+ exposed, and it could not open one the author had not. `mcp` already worked the
14
+ other way (the wiring decides), and the two now match.
15
+
16
+ `expose` on `wireAddon` is `boolean | string[]`, resolved per instance by
17
+ `isAddonFunctionExposed`:
18
+
19
+ - **unset or `true`** — the addon's declaration decides, as before. Keeping the
20
+ default unchanged means no existing app gains or loses a route on upgrade.
21
+ - **`false`** — nothing in this instance is reachable through `rpc.exposed`.
22
+ - **a list** — exactly those functions, whether or not the addon declared them.
23
+ The list can widen the addon's surface, the same power `mcp` has, because the
24
+ app is the one that knows what its deployment should offer. The generated
25
+ `#pikku/addon` types the list against the package's function names, and a name
26
+ that survives to the build unpublished fails it with PKU343.
27
+ The value has to be written inline (`true`, `false` or an array of string
28
+ literals): the build reads it statically, and a variable or spread fails it
29
+ with PKU344 rather than being read as unset, which would leave the deploy
30
+ units disagreeing with the runtime.
31
+
32
+ The decision is per **instance**, not per package: two `wireAddon` calls for one
33
+ package may expose different things, and the gate reads the config the
34
+ namespace resolved to.
35
+
36
+ The deploy analyzer's per-addon unit applies the same rule, since that unit is
37
+ what the dispatcher can forward to. A function the wiring refuses gets no unit
38
+ and no dispatch entry, so the `deploymentService` fallback in `rpc.exposed` —
39
+ which forwards namespaced names with no local metadata — has nothing to reach.
40
+ Where the dispatcher does hold the wiring, it also refuses locally before
41
+ forwarding.
42
+
43
+ `expose` is reachability, not authorization. A listed function still runs
44
+ through `runPikkuFunc` with its own `auth`, permissions and the instance's
45
+ `auth`/`scopes`/`tags` — see
46
+ [addon auth and tags](./addon-auth-and-tags-only-tighten.md). Widening the list
47
+ to a sessionless function makes it public unless one of those gates it.
48
+
49
+ **What this rules out:** letting the addon's `expose: true` override a wiring's
50
+ `false`; treating an unknown list entry as a silent no-op; and a deploy unit
51
+ that carries functions the runtime gate would refuse.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.117",
3
+ "version": "0.12.120",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",