@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 +95 -0
- package/dist/schema.js +39 -0
- package/dist/services/scope-service.d.ts +13 -0
- package/dist/types/state.types.d.ts +2 -0
- package/dist/wirings/addon/wire-addon.d.ts +18 -0
- package/dist/wirings/addon/wire-addon.js +17 -0
- package/dist/wirings/rpc/rpc-runner.js +19 -5
- package/dist/wirings/rpc/rpc-types.d.ts +2 -0
- package/dist/wirings/workflow/pikku-scenario-service.d.ts +22 -1
- package/dist/wirings/workflow/pikku-scenario-service.js +54 -1
- package/dist/wirings/workflow/scenario-run.types.d.ts +56 -2
- package/dist/wirings/workflow/scenario-step.types.d.ts +26 -0
- package/dist/wirings/workflow/scenario.types.d.ts +10 -0
- package/knowledge/decisions/security/index.md +1 -0
- package/knowledge/decisions/security/wire-addon-expose-selects-the-rpc-surface.md +51 -0
- package/package.json +1 -1
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 ``, 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
|
-
|
|
115
|
-
if (
|
|
116
|
-
functionMeta = pikkuState(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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,
|
|
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.
|