@pikku/core 0.12.118 → 0.12.121

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,63 @@
1
+ ## 0.12.121
2
+
3
+ ### Patch Changes
4
+
5
+ - e84abd0: Add CredentialRejectedError: an upstream refused the stored per-user credential, so the user must sign in again or reconnect it.
6
+
7
+ ## 0.12.120
8
+
9
+ ### Patch Changes
10
+
11
+ - 4a9dcd2: `admin:listUsers` now pages, counts and can carry roles.
12
+
13
+ `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`.
14
+
15
+ ```typescript
16
+ const { users, total } = await rpc.invoke('admin:listUsers', {
17
+ search: 'example.com',
18
+ limit: 50,
19
+ offset: 50,
20
+ includeRoles: true,
21
+ })
22
+ ```
23
+
24
+ Paging only means something over a stable order, so the query now sorts newest first rather than however the database felt like returning rows.
25
+
26
+ 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.
27
+
28
+ `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.
29
+
30
+ `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.
31
+
32
+ ```typescript
33
+ listRolesForUsers(userIds: string[]): Promise<Record<string, string[]>>
34
+ ```
35
+
36
+ Anything implementing `ScopeService` outside this repository has to add it.
37
+
38
+ - 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`.
39
+ - 42b7ac3: The app decides which of an addon's functions `rpc.exposed` reaches
40
+
41
+ `wireAddon` takes `expose?: boolean | string[]`, mirroring `mcp`. Unset or
42
+ `true` keeps the functions the addon declared `expose: true`; `false` exposes
43
+ none of the instance's functions; a list names exactly the functions to expose,
44
+ whether or not the addon declared them, typed against the addon's function
45
+ names. A listed name the addon does not publish fails the build with PKU343, a value that is not written inline fails it with
46
+ PKU344,
47
+ and the deploy analyzer's per-addon unit carries only what the wiring exposes.
48
+
49
+ ## 0.12.119
50
+
51
+ ### Patch Changes
52
+
53
+ - 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.
54
+
55
+ `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.
56
+
57
+ 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.
58
+
59
+ 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.
60
+
1
61
  ## 0.12.118
2
62
 
3
63
  ### Patch Changes
@@ -28,6 +28,14 @@ export declare class MissingCredentialError extends PikkuError {
28
28
  };
29
29
  constructor(credentialName: string, credentialType: 'oauth2' | 'apikey', connectUrl?: string);
30
30
  }
31
+ export declare class CredentialRejectedError extends PikkuError {
32
+ payload: {
33
+ error: 'credential_rejected';
34
+ credentialName: string;
35
+ reauth: 'sign-in' | 'connect';
36
+ };
37
+ constructor(credentialName: string, reauth: 'sign-in' | 'connect', message?: string);
38
+ }
31
39
  export declare class MissingScopeError extends PikkuError {
32
40
  payload: {
33
41
  error: 'missing_scope';
@@ -73,6 +73,20 @@ addError(MissingCredentialError, {
73
73
  status: 403,
74
74
  message: 'A required credential is not configured.',
75
75
  });
76
+ export class CredentialRejectedError extends PikkuError {
77
+ payload;
78
+ constructor(credentialName, reauth, message) {
79
+ super(message ??
80
+ (reauth === 'sign-in'
81
+ ? `${credentialName} rejected the stored session — sign in again`
82
+ : `${credentialName} rejected the stored credential — reconnect it`));
83
+ this.payload = { error: 'credential_rejected', credentialName, reauth };
84
+ }
85
+ }
86
+ addError(CredentialRejectedError, {
87
+ status: 403,
88
+ message: 'The upstream service rejected the stored credential; the user has to sign in again or reconnect it.',
89
+ });
76
90
  export class MissingScopeError extends PikkuError {
77
91
  payload;
78
92
  constructor(scope) {
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>;
@@ -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 videoStartedAt = this.scenarioBrowserProvider.videoStartedAt?.(actor.name);
682
- if (videoStartedAt !== undefined) {
683
- this.recordVideoOffset(runId, stepName, actor.name, Math.max(0, Date.now() - videoStartedAt));
692
+ const offsetMs = this.videoOffsetFor(actor.name);
693
+ if (offsetMs !== undefined) {
694
+ this.recordVideoOffset(runId, stepName, actor.name, offsetMs);
684
695
  }
685
696
  }
686
- return await runPikkuFunc('workflow', workflowName, resolvedStepFunc, {
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.118",
3
+ "version": "0.12.121",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -371,6 +371,7 @@
371
371
  "BadGatewayError",
372
372
  "BadRequestError",
373
373
  "ConflictError",
374
+ "CredentialRejectedError",
374
375
  "ExpectationFailedError",
375
376
  "FeatureUnavailableError",
376
377
  "ForbiddenError",