@pikku/core 0.12.118 → 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 +54 -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 +2 -0
- package/dist/wirings/workflow/pikku-scenario-service.js +16 -4
- package/dist/wirings/workflow/scenario-step.types.d.ts +17 -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,57 @@
|
|
|
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
|
+
|
|
1
55
|
## 0.12.118
|
|
2
56
|
|
|
3
57
|
### 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>;
|
|
@@ -204,6 +204,8 @@ export declare class PikkuScenarioService implements WorkflowRunExtension {
|
|
|
204
204
|
* clear them, and the only moment they are still wanted.
|
|
205
205
|
*/
|
|
206
206
|
takeStepVideoOffsets(runId: string): Map<string, ScenarioStepVideoOffset[]>;
|
|
207
|
+
/** Where a step starting now falls in this actor's recording, if anywhere. */
|
|
208
|
+
private videoOffsetFor;
|
|
207
209
|
/**
|
|
208
210
|
* Stamp where a step began inside one actor's recording.
|
|
209
211
|
*
|
|
@@ -309,6 +309,17 @@ export class PikkuScenarioService {
|
|
|
309
309
|
this.runVideoOffsets.delete(runId);
|
|
310
310
|
return offsets ?? new Map();
|
|
311
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
|
+
}
|
|
312
323
|
/**
|
|
313
324
|
* Stamp where a step began inside one actor's recording.
|
|
314
325
|
*
|
|
@@ -678,18 +689,19 @@ export class PikkuScenarioService {
|
|
|
678
689
|
// The dispatch guard above already refused an actor-less call, and a
|
|
679
690
|
// browser binding always requires one.
|
|
680
691
|
wire.browser = await this.scenarioBrowserProvider.sessionFor(actor.name);
|
|
681
|
-
const
|
|
682
|
-
if (
|
|
683
|
-
this.recordVideoOffset(runId, stepName, actor.name,
|
|
692
|
+
const offsetMs = this.videoOffsetFor(actor.name);
|
|
693
|
+
if (offsetMs !== undefined) {
|
|
694
|
+
this.recordVideoOffset(runId, stepName, actor.name, offsetMs);
|
|
684
695
|
}
|
|
685
696
|
}
|
|
686
|
-
|
|
697
|
+
const result = await runPikkuFunc('workflow', workflowName, resolvedStepFunc, {
|
|
687
698
|
singletonServices: getSingletonServices(),
|
|
688
699
|
createWireServices: getCreateWireServices(),
|
|
689
700
|
data: () => data,
|
|
690
701
|
wire,
|
|
691
702
|
packageName: packageName ?? undefined,
|
|
692
703
|
});
|
|
704
|
+
return result;
|
|
693
705
|
};
|
|
694
706
|
if (resolution.kind === 'action') {
|
|
695
707
|
if (resolution.fellBack &&
|
|
@@ -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;
|
|
@@ -244,6 +252,15 @@ export interface ScenarioBrowserProvider {
|
|
|
244
252
|
* for a run recording nothing — both of which leave the step's offset off.
|
|
245
253
|
*/
|
|
246
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;
|
|
247
264
|
/**
|
|
248
265
|
* Snapshot every open window for a failed scenario. `label` identifies the
|
|
249
266
|
* scenario in artifact filenames. Never throws: a failure to capture must
|
|
@@ -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.
|