@pikku/core 0.12.112 → 0.12.114

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/dist/function/function-runner.js +8 -2
  3. package/dist/middleware-runner.js +8 -3
  4. package/dist/services/credential-wire-service.d.ts +31 -1
  5. package/dist/services/credential-wire-service.js +47 -10
  6. package/dist/services/typed-secret-service.d.ts +11 -0
  7. package/dist/services/typed-secret-service.js +11 -1
  8. package/dist/types/state.types.d.ts +2 -1
  9. package/dist/wirings/addon/addon-runner.d.ts +2 -1
  10. package/dist/wirings/addon/wire-addon.d.ts +14 -4
  11. package/dist/wirings/cli/index.d.ts +6 -0
  12. package/dist/wirings/cli/index.js +6 -0
  13. package/dist/wirings/credential/credential-overrides.d.ts +26 -0
  14. package/dist/wirings/credential/credential-overrides.js +45 -0
  15. package/dist/wirings/credential/index.d.ts +2 -0
  16. package/dist/wirings/credential/index.js +1 -0
  17. package/dist/wirings/http/http-routes.js +1 -0
  18. package/dist/wirings/http/http-runner.js +5 -5
  19. package/dist/wirings/http/http-stream-protocol.d.ts +12 -0
  20. package/dist/wirings/http/http-stream-protocol.js +16 -0
  21. package/dist/wirings/http/http.types.d.ts +35 -0
  22. package/dist/wirings/http/index.d.ts +1 -1
  23. package/dist/wirings/mcp/index.d.ts +2 -1
  24. package/dist/wirings/mcp/index.js +1 -1
  25. package/dist/wirings/mcp/mcp-runner.d.ts +27 -0
  26. package/dist/wirings/mcp/mcp-runner.js +111 -0
  27. package/dist/wirings/rpc/rpc-types.d.ts +2 -1
  28. package/dist/wirings/secret/derive-oauth2-app-secrets.d.ts +20 -0
  29. package/dist/wirings/secret/derive-oauth2-app-secrets.js +47 -0
  30. package/dist/wirings/secret/index.d.ts +1 -0
  31. package/dist/wirings/secret/index.js +1 -0
  32. package/knowledge/decisions/internals/global-middleware-resolves-from-the-root-namespace-too.md +32 -0
  33. package/knowledge/decisions/internals/index.md +3 -0
  34. package/knowledge/decisions/internals/mcp-wire-names-are-assigned-over-the-whole-registry.md +39 -0
  35. package/knowledge/decisions/internals/the-mcp-handshake-is-challenged-only-when-every-target-is-gated.md +29 -0
  36. package/knowledge/decisions/security/an-mcp-refusal-is-decided-before-dispatch.md +48 -0
  37. package/knowledge/decisions/security/an-oauth2-credential-is-read-by-its-account-row.md +40 -0
  38. package/knowledge/decisions/security/index.md +2 -0
  39. package/package.json +1 -1
  40. package/src/public-surface.json +21 -12
package/CHANGELOG.md CHANGED
@@ -1,3 +1,140 @@
1
+ ## 0.12.114
2
+
3
+ ### Patch Changes
4
+
5
+ - cb8f57e: Global middleware now runs for a dispatch that belongs to an addon, not just for one in the root namespace.
6
+
7
+ `combineMiddleware` read the global list out of a single namespace — the `packageName` the dispatch carried. For anything wired by the application itself that namespace is `null`, so it worked; for a wiring contributed by an addon (an MCP tool registered through `wireAddon({ mcp: [...] })`, say) it was the addon's own package name, and the application's globals were simply not in that list. The practical effect was that an addon's tools ran with no session middleware at all: every one of them refused with "authentication required" no matter who was calling, while the identical function reached through HTTP authenticated fine.
8
+
9
+ Global middleware is application-wide by definition, and an addon's function is still running inside the host application, so the root namespace always applies. A dispatch in the root namespace resolves `[null]` as before; one in an addon resolves `[null, packageName]` — the host's globals first, then the addon's own. Globals registered by one addon still do not reach another's dispatches.
10
+
11
+ - cb8f57e: MCP clients can now discover that a server needs signing in to, and can see tools whose names carry a namespace.
12
+
13
+ Three separate reasons a client ended up connected to a server it could do nothing with:
14
+
15
+ - **The handshake was never challenged.** A client decides at connection time whether a server speaks OAuth, and all it has to go on is whether `initialize` came back `401` with a `WWW-Authenticate` challenge. Answering it `200` and then refusing every subsequent call told the client "no sign-in needed" and then gave it nothing — Claude's connector setup, for one, reported the server as open access. `initialize` is now challenged when `mcpEveryTargetRequiresSession()` holds, which is the only case where answering it openly is a lie; a server with even one open target still completes the handshake unauthenticated, as it should.
16
+ - **Namespaced names were dropped on the floor.** MCP constrains tool and prompt names to `[A-Za-z0-9_-]{1,64}`, and pikku's namespace separator is `:`. Clients discard the names they cannot accept rather than failing the connection, so an addon's entire surface went missing with nothing more than a note about "tools with unsupported names". Names are now rewritten at the transport boundary (`mcpWireName`) and resolved back on the way in (`mcpResolveWireName`), so `bb2:getMe` is offered as `bb2_getMe` and calls to it dispatch correctly. The registry keeps its own spelling, since that is what dispatch keys on, and a tool genuinely registered under the wire spelling still wins over a rewritten match.
17
+ - **An addon-only app generated no tool metadata.** `pikku all` decided whether to emit the MCP file from the set of source files calling `wireMCPTool`/`Resource`/`Prompt`. An addon that contributes its tools through `wireAddon({ mcp: [...] })` adds none, so an application whose only MCP surface came from addons got neither wirings nor meta — the tools were listed from `mcp.gen.json` and then every call failed to resolve, because `toolsMeta` (which carries the `pikkuFuncId`) had never been written. Content now decides; the file set only decides whether there are imports to serialize.
18
+
19
+ Also in here: the resource URL advertised in the challenge honours `X-Forwarded-Proto` and `X-Forwarded-Host`, so a server behind a TLS-terminating proxy advertises the `https://` URL the client actually reached rather than its own internal `http://` origin — which the client would reject as a resource mismatch.
20
+
21
+ - 6db6a14: A wiring can now decide whether an addon's credential is per-user or deployment-wide
22
+
23
+ `wireAddon`'s `credentialOverrides` takes an object as well as a rename string:
24
+
25
+ ```ts
26
+ wireAddon({
27
+ name: 'gmail',
28
+ package: '@pikku/addon-gmail',
29
+ credentialOverrides: {
30
+ gmailOAuth: { mode: 'wire' }, // each user connects their own
31
+ calendarOAuth: { name: 'CAL', mode: 'singleton' },
32
+ },
33
+ })
34
+ ```
35
+
36
+ An addon author declares a default with `defineCredential`; the deployment decides, so one addon serves both a per-user product and a single team account.
37
+
38
+ The wire credential service now resolves each credential by what it _is_ rather than by what a lookup returned: `wire` reads only the user's value, `singleton` reads the deployment's. A per-user credential can no longer pick up a platform-level value because the user has not connected — which, before, would have quietly run someone's request against the deployment's own account.
39
+
40
+ A credential is never read from the secret vault. The wire credential service no longer takes a `SecretService` at all, so the only way a credential arrives is the one its mode names — the user's own value, or the deployment's.
41
+
42
+ Modes are resolved at generation time into the credentials meta, so the console's connect flow reflects the wiring rather than the addon's default.
43
+
44
+ Two credentials that resolve to one name are rejected unless they agree on the mode. Generation writes a single metadata entry per name, so a `wire` credential aliased onto a `singleton` one used to take whichever wiring was read last — a per-user credential served from the deployment's own account, or a deployment credential handed out per user. The inspector reports the collision against both wirings by name, and `buildCredentialResolutions` refuses it at runtime too.
45
+
46
+ A mode-only override is checked against the credential it names. `{ mode: 'wire' }` renames nothing, so nothing was validated: an override naming a credential that does not exist was dropped in silence and the credential kept the mode its author declared.
47
+
48
+ A credential the wiring resolves as `singleton` is no longer read out of the user's own store. Per-user values are imported first and a name already present is left alone, so a value stored under a singleton's name — from an earlier wiring, or a connect flow since rewired — decided what the deployment's own slot resolved to. It is now skipped by mode, the same way a `wire` credential is kept away from the deployment's value.
49
+
50
+ The project's own credentials are registered into pikku state from the generated credentials file, so `wire.getCredential` resolves an app-level singleton the same way it resolves an addon's.
51
+
52
+ `pikku new addon` no longer requires a `pikku.config.json` in the working directory. Scaffolding an addon is something you do before a project config exists, so the command now reads one when it is there and falls back to the working directory when it is not.
53
+
54
+ An addon's `pikkuAddonWireServices` factory now declares the same service contract its singleton factory does: what it destructures off the parent's bag is required, and what it returns is built by the addon. Before, a service an addon built per wire was demanded from the consumer, and a wire-only addon declared no contract at all. A nested callback that names its own parameter after the factory's is no longer read as the factory's own: what it destructured went onto the addon's contract, so a consumer was asked for a service the addon never wanted from them.
55
+
56
+ An OAuth2 credential now implies its app secret, so nobody hand-writes one. The client id and secret an OAuth app needs is the same shape every time — `OAuth2AppCredential`, which is what the runtime already reads it as — so the inspector registers a secret for each credential's `appCredentialSecretId`. A hand-written `defineSecret` covering that id still wins, so an author who wants their own description or `docsUrl` keeps it. The derived secret is optional, matching what every hand-written declaration chose: an addon that also authenticates by API key must still deploy without an OAuth app configured.
57
+
58
+ `optional` now means a secret is not reported as missing. `getMissing()` filtered on "not configured" alone, so a secret whose declaration said absence was supported still showed up on the list of things a deployment had to go and supply — burying the ones that genuinely were. `getAllStatus()` still reports it, flagged `optional`.
59
+
60
+ An OAuth2 secret carries its declaration's `optional` through code generation, to the app credential and to the token store alike: nobody connects without the client id and secret, so a deployment allowed to omit the app is never asked for its tokens either. That branch never looked at the flag before.
61
+
62
+ `credentialOAuthProviders` treats an app secret that resolves `undefined` as unconfigured. An optional secret resolves rather than throws, so the absence arrived past the `catch` that was meant to skip the provider — and `.reveal()` on it threw a `TypeError` that took every `getSession` down with it, which is the exact failure that code exists to prevent.
63
+
64
+ ## 0.12.113
65
+
66
+ ### Patch Changes
67
+
68
+ - c842054: A pikku command never ends in a node internals warning, and a secret can be set from a script.
69
+
70
+ `pikku fabric secrets set NAME` prompted for the value through readline in terminal mode. On a stdin that is not a tty that promise never settles at all, so the command printed `BETTER_AUTH_SECRET value:` and then node's "Detected unsettled top-level await", naming a line of `@pikku/cli`'s own bin — under bun it simply hung. The prompt now reads the first line of stdin when there is no tty, so `echo '<value>' | pikku fabric secrets set NAME` works, and refuses with the flag to reach for (`--value`) when stdin is closed or empty. `promptConfirm` gained the same backstop, so a caller that forgets its `isTTY` gate gets a refusal instead of a hang.
71
+
72
+ Alongside it, the places a raw stack could still reach a user:
73
+
74
+ - The `pikku` binary formats through `formatCLIError` instead of printing `error.message`, and installs `uncaughtException` / `unhandledRejection` handlers so nothing escaping a listener or a floating promise is dumped unformatted. A `CLIError` the runner already printed is no longer printed twice.
75
+ - The generated local and channel CLI bootstraps do the same, rather than `console.error('Fatal error:', error.message)` — which dropped the stack even when one was asked for.
76
+ - A missing `pikku.config.json` says where it looked and what to do, as a `PikkuCLIConfigError`, which is now a `PikkuError` along with `GitError` and every remaining plain `Error` raised by a `pikku fabric` command. A directory-wide test keeps it that way.
77
+ - `pikku dev`'s watcher and the MCP schema loader log their causes through the logger at debug level instead of `console.error(err)` over the top of the output.
78
+
79
+ Stacks are unchanged where they are the answer: an unexpected error still keeps its frames, and `--verbose` / `PIKKU_DEBUG` still prints the stack for a deliberate one. `formatCLIError` and `wantsStackTrace` are exported from `@pikku/core/cli` so every entrypoint that can be the last thing to catch an error prints it the same way.
80
+
81
+ - dfd7019: An MCP tool reads the claims its host verified
82
+
83
+ `PikkuHTTP` gains an `authInfo` of the new `PikkuHTTPAuthInfo`: the token,
84
+ client and scopes a transport that already verified a bearer token hands on.
85
+ It is strictly pass-through — nothing in pikku derives it from a request's own
86
+ headers, because verifying a token is the host's job. A function could always
87
+ read the `Authorization` header itself; what this adds is what the raw header
88
+ cannot carry.
89
+
90
+ `PikkuMCPServer`'s server factory now carries the SDK's `authInfo` onto the
91
+ wire beside the request, so the `authInfo` a host passes to
92
+ `createFetchHandler` reaches the tool rather than stopping at the SDK.
93
+
94
+ `pikkuCredentialOAuth` also names itself when it provisions the platform user.
95
+ Every other pikku plugin passes a source to `internalAdapter.createUser`, and
96
+ better-auth refuses a `user.validateUserInfo` gate that is handed none — so an
97
+ app with that hook configured could not link a singleton credential at all.
98
+
99
+ - dfd7019: An MCP call that needs a session is refused with an OAuth challenge
100
+
101
+ A tool fronting a session-requiring function used to answer an unauthenticated
102
+ caller with `200` and `isError: true`, which a client reads as a tool that
103
+ broke rather than one it has not authenticated for — so OAuth discovery never
104
+ began. Such a call now gets `401` with a `WWW-Authenticate: Bearer` challenge
105
+ naming the resource metadata, and `/.well-known/oauth-protected-resource` is
106
+ served alongside the MCP endpoint.
107
+
108
+ The endpoint is not gated as a whole. `mcpTargetRequiresSession` reads the
109
+ declarations the runner already enforces — a `pikkuFunc` needs a session, a
110
+ `pikkuSessionlessFunc` needs one only where it says `auth: true` — so public
111
+ and private tools can share one server and only the private ones are
112
+ challenged.
113
+
114
+ `createFetchHandler` and `createHTTPRequestHandler` take an optional `auth`
115
+ describing what to advertise (`authorizationServers`, `scopesSupported`,
116
+ `resourceName`), surfaced on both servers as an `mcpAuth` option. Every field
117
+ defaults from the request, because a pikku app is usually its own
118
+ authorization server. Both handlers now also return `ownsPath`, because the
119
+ discovery document lives outside `mcpPath` and a host routing on the endpoint
120
+ alone would 404 the document its own challenge points at.
121
+
122
+ - 9b978e7: A failed SSE stream reports the error in the protocol its client is parsing
123
+
124
+ An SSE route can now declare `streamProtocol: 'agui'`, and the generated agent
125
+ stream and resume routes do. A function that throws mid-stream then ends the
126
+ stream with a single AG-UI `RUN_ERROR` instead of Pikku's `error`/`done` frames,
127
+ which an AG-UI client could only surface as a Zod parse failure with the real
128
+ message nowhere in sight.
129
+
130
+ - 1469e73: The app names which of an addon's functions reach MCP
131
+
132
+ `wireAddon`'s `mcp` takes a list as well as `true`. `true` still offers every
133
+ function the addon declared `mcp: true`; a list names the tools this deployment
134
+ offers, whether or not the addon declared them, and is typed against the
135
+ function names that addon publishes — a typo is a compile error rather than a
136
+ tool silently missing from the menu.
137
+
1
138
  ## 0.12.112
2
139
 
3
140
  ### Patch Changes
@@ -11,6 +11,7 @@ import { MissingSessionError, ReadonlySessionError } from '../errors/errors.js';
11
11
  import { verifyScopes } from '../scopes.js';
12
12
  import { assertFeatureAvailable } from '../wirings/flag/assert-feature-available.js';
13
13
  import { PikkuCredentialWireService, createWireServicesCredentialWireProps, } from '../services/credential-wire-service.js';
14
+ import { buildCredentialResolutions, credentialOverrideAliases, } from '../wirings/credential/credential-overrides.js';
14
15
  import { defaultPikkuUserIdResolver } from '../services/pikku-user-id.js';
15
16
  import { createInvocationAudit, resolveAuditConfig, } from '../services/audit-service.js';
16
17
  import { createInvocationAnalytics } from '../analytics/analytics.js';
@@ -125,6 +126,7 @@ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServic
125
126
  const resolvedWire = allChannelMiddleware.length > 0 && wire.channel
126
127
  ? wrapChannelWithMiddleware(wire, resolvedSingletonServices, allChannelMiddleware)
127
128
  : wire;
129
+ const declaredCredentials = pikkuState(funcPackageName ?? null, 'package', 'credentialsMeta');
128
130
  // Set up early so middleware can use setCredential. An addon instance with
129
131
  // credentialOverrides always gets a fresh alias-aware service, even when a
130
132
  // parent credential service is already present on the wire.
@@ -132,12 +134,16 @@ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServic
132
134
  // Credentials belong to the consuming project, so resolve them via the
133
135
  // project's credentialService (the addon's own singletons may not carry it).
134
136
  const aliasedCredentialWireService = new PikkuCredentialWireService(singletonServices.credentialService ??
135
- resolvedSingletonServices.credentialService, resolvedWire, addonInstance.credentialOverrides);
137
+ resolvedSingletonServices.credentialService, resolvedWire, credentialOverrideAliases(addonInstance.credentialOverrides), {
138
+ resolutions: buildCredentialResolutions(declaredCredentials, addonInstance.credentialOverrides),
139
+ });
136
140
  Object.assign(resolvedWire, createWireServicesCredentialWireProps(aliasedCredentialWireService));
137
141
  }
138
142
  else if (!resolvedWire.getCredentials) {
139
143
  const resolvedCredentialWireService = credentialWireService ??
140
- new PikkuCredentialWireService(resolvedSingletonServices.credentialService, resolvedWire);
144
+ new PikkuCredentialWireService(resolvedSingletonServices.credentialService, resolvedWire, undefined, {
145
+ resolutions: buildCredentialResolutions(declaredCredentials, undefined),
146
+ });
141
147
  Object.assign(resolvedWire, createWireServicesCredentialWireProps(resolvedCredentialWireService));
142
148
  }
143
149
  const resolvedAuditConfig = resolveAuditConfig(funcConfig.audit);
@@ -88,9 +88,14 @@ export const combineMiddleware = (wireType, uid, { wireInheritedMiddleware, wire
88
88
  return middlewareCache[wireType][uid];
89
89
  }
90
90
  const resolved = [];
91
- const globals = pikkuState(packageName, 'middleware', 'global');
92
- if (globals && globals.length > 0) {
93
- resolved.push(...globals);
91
+ // Global middleware is application-wide, so the root namespace always
92
+ // applies — an addon's function still runs inside the host application.
93
+ // See `global-middleware-resolves-from-the-root-namespace-too.md`.
94
+ for (const ns of packageName === null ? [null] : [null, packageName]) {
95
+ const globals = pikkuState(ns, 'middleware', 'global');
96
+ if (globals && globals.length > 0) {
97
+ resolved.push(...globals);
98
+ }
94
99
  }
95
100
  if (wireInheritedMiddleware) {
96
101
  for (const meta of wireInheritedMiddleware) {
@@ -1,13 +1,29 @@
1
1
  import type { CredentialService } from './credential-service.js';
2
2
  import type { PikkuRawWire } from '../types/core.types.js';
3
+ /**
4
+ * Where one credential's value is read from. Decided by what the credential IS
5
+ * — its declared type, as the wiring may have overridden it — and never by
6
+ * whether a per-user lookup came back empty. An absence-driven fallback would
7
+ * let a user who has not connected read the deployment's own token.
8
+ */
9
+ export type CredentialResolution = {
10
+ mode: 'wire';
11
+ } | {
12
+ mode: 'singleton';
13
+ };
14
+ export type CredentialResolutionConfig = {
15
+ /** Keyed by the resolved (post-alias) credential name. */
16
+ resolutions: Record<string, CredentialResolution>;
17
+ };
3
18
  export declare class PikkuCredentialWireService {
4
19
  private credentialService?;
5
20
  private wire?;
6
21
  private aliases?;
22
+ private resolution?;
7
23
  private credentials;
8
24
  private loaded;
9
25
  private loadPromise;
10
- constructor(credentialService?: CredentialService | undefined, wire?: PikkuRawWire | undefined, aliases?: Record<string, string> | undefined);
26
+ constructor(credentialService?: CredentialService | undefined, wire?: PikkuRawWire | undefined, aliases?: Record<string, string> | undefined, resolution?: CredentialResolutionConfig | undefined);
11
27
  private resolveName;
12
28
  /**
13
29
  * A credential is one of the few places vault material is meant to end up, so
@@ -35,6 +51,20 @@ export declare class PikkuCredentialWireService {
35
51
  * nothing to fetch — so they should reach the fast path too.
36
52
  */
37
53
  private doLoad;
54
+ /**
55
+ * The other half of resolving by type. A user's own store can hold a value
56
+ * under a name the wiring made `singleton` — from an earlier wiring, or a
57
+ * connect flow that has since been rewired — and importing it would put the
58
+ * user in charge of a slot the deployment owns. What `set` wrote is left
59
+ * alone: that is the deployment's own path, not the user's.
60
+ */
61
+ private isSingleton;
62
+ /**
63
+ * The `singleton` half of the dispatch. `wire` is absent here on purpose:
64
+ * those values arrive with `getAll(userId)` above, so a wire credential can
65
+ * never pick up a platform value that happens to exist.
66
+ */
67
+ private loadDeploymentCredentials;
38
68
  }
39
69
  export declare function createMiddlewareCredentialWireProps(credentialWire: PikkuCredentialWireService): {
40
70
  setCredential: (name: string, value: unknown) => void;
@@ -4,13 +4,15 @@ export class PikkuCredentialWireService {
4
4
  credentialService;
5
5
  wire;
6
6
  aliases;
7
+ resolution;
7
8
  credentials = {};
8
9
  loaded = false;
9
10
  loadPromise;
10
- constructor(credentialService, wire, aliases) {
11
+ constructor(credentialService, wire, aliases, resolution) {
11
12
  this.credentialService = credentialService;
12
13
  this.wire = wire;
13
14
  this.aliases = aliases;
15
+ this.resolution = resolution;
14
16
  }
15
17
  resolveName(name) {
16
18
  return this.aliases?.[name] ?? name;
@@ -73,23 +75,58 @@ export class PikkuCredentialWireService {
73
75
  */
74
76
  async doLoad() {
75
77
  try {
76
- if (!this.credentialService || !this.wire)
78
+ if (!this.credentialService && !this.resolution)
77
79
  return;
78
- const userId = defaultPikkuUserIdResolver(this.wire);
79
- if (!userId)
80
- return;
81
- this.wire.pikkuUserId = userId;
82
- const allCreds = await this.credentialService.getAll(userId);
83
- for (const [name, value] of Object.entries(allCreds)) {
84
- if (!(name in this.credentials)) {
85
- this.credentials[name] = value;
80
+ const userId = this.wire
81
+ ? defaultPikkuUserIdResolver(this.wire)
82
+ : undefined;
83
+ if (this.credentialService && this.wire && userId) {
84
+ this.wire.pikkuUserId = userId;
85
+ const allCreds = await this.credentialService.getAll(userId);
86
+ for (const [name, value] of Object.entries(allCreds)) {
87
+ if (this.isSingleton(name))
88
+ continue;
89
+ if (!(name in this.credentials)) {
90
+ this.credentials[name] = value;
91
+ }
86
92
  }
87
93
  }
94
+ await this.loadDeploymentCredentials();
88
95
  }
89
96
  finally {
90
97
  this.loaded = true;
91
98
  }
92
99
  }
100
+ /**
101
+ * The other half of resolving by type. A user's own store can hold a value
102
+ * under a name the wiring made `singleton` — from an earlier wiring, or a
103
+ * connect flow that has since been rewired — and importing it would put the
104
+ * user in charge of a slot the deployment owns. What `set` wrote is left
105
+ * alone: that is the deployment's own path, not the user's.
106
+ */
107
+ isSingleton(name) {
108
+ return this.resolution?.resolutions[name]?.mode === 'singleton';
109
+ }
110
+ /**
111
+ * The `singleton` half of the dispatch. `wire` is absent here on purpose:
112
+ * those values arrive with `getAll(userId)` above, so a wire credential can
113
+ * never pick up a platform value that happens to exist.
114
+ */
115
+ async loadDeploymentCredentials() {
116
+ const resolutions = this.resolution?.resolutions;
117
+ if (!resolutions)
118
+ return;
119
+ if (!this.credentialService)
120
+ return;
121
+ for (const [name, resolution] of Object.entries(resolutions)) {
122
+ if (resolution.mode === 'wire' || name in this.credentials)
123
+ continue;
124
+ const value = await this.credentialService.get(name);
125
+ if (value !== null) {
126
+ this.credentials[name] = value;
127
+ }
128
+ }
129
+ }
93
130
  }
94
131
  export function createMiddlewareCredentialWireProps(credentialWire) {
95
132
  return {
@@ -13,6 +13,8 @@ export interface CredentialStatus {
13
13
  name: string;
14
14
  displayName: string;
15
15
  isConfigured: boolean;
16
+ /** Declared `optional: true`, so absence is a supported state rather than a misconfiguration. */
17
+ optional?: boolean;
16
18
  oauth2?: {
17
19
  tokenSecretId: string;
18
20
  };
@@ -42,5 +44,14 @@ export declare class TypedSecretService<TMap = Record<string, unknown>> implemen
42
44
  deleteSecret(key: string): Promise<void>;
43
45
  getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>>;
44
46
  getAllStatus(): Promise<CredentialStatus[]>;
47
+ /**
48
+ * The secrets a deployment still has to supply.
49
+ *
50
+ * An optional one is absent from this list however it is stored: `optional`
51
+ * already declares that absence is supported, which is why `getSecret`
52
+ * resolves `undefined` for it rather than throwing. Reporting it as missing
53
+ * contradicts that, and buries the required secrets someone actually has to
54
+ * go and configure. `getAllStatus` still reports it, flagged.
55
+ */
45
56
  getMissing(): Promise<CredentialStatus[]>;
46
57
  }
@@ -65,13 +65,23 @@ export class TypedSecretService {
65
65
  name: meta.name,
66
66
  displayName: meta.displayName,
67
67
  isConfigured: await this.secrets.hasSecret(secretId),
68
+ optional: meta.optional,
68
69
  oauth2: meta.oauth2,
69
70
  });
70
71
  }
71
72
  return results;
72
73
  }
74
+ /**
75
+ * The secrets a deployment still has to supply.
76
+ *
77
+ * An optional one is absent from this list however it is stored: `optional`
78
+ * already declares that absence is supported, which is why `getSecret`
79
+ * resolves `undefined` for it rather than throwing. Reporting it as missing
80
+ * contradicts that, and buries the required secrets someone actually has to
81
+ * go and configure. `getAllStatus` still reports it, flagged.
82
+ */
73
83
  async getMissing() {
74
84
  const all = await this.getAllStatus();
75
- return all.filter((c) => !c.isConfigured);
85
+ return all.filter((c) => !c.isConfigured && !c.optional);
76
86
  }
77
87
  }
@@ -1,3 +1,4 @@
1
+ import type { CredentialOverrides } from '../wirings/credential/credential-overrides.js';
1
2
  import type { PikkuErrorConstructor, ErrorDetails } from '../errors/error-handler.js';
2
3
  import type { CorePikkuFunctionConfig, CorePermissionGroup, CorePikkuPermission } from '../function/functions.types.js';
3
4
  import type { CorePikkuTriggerFunctionConfig } from '../wirings/trigger/trigger.types.js';
@@ -43,7 +44,7 @@ export interface PikkuPackageState {
43
44
  /** Per-instance name-aliases: logical name the addon reads -> actual project variable name */
44
45
  variableOverrides?: Record<string, string>;
45
46
  /** Per-instance name-aliases: logical name the addon reads -> actual project credential name */
46
- credentialOverrides?: Record<string, string>;
47
+ credentialOverrides?: CredentialOverrides;
47
48
  /** Secrets the host lends this instance, named as the addon reads them */
48
49
  secretGrants?: string[];
49
50
  /** Credentials the host lends this instance, named as the addon reads them */
@@ -1,9 +1,10 @@
1
1
  import type { CoreSingletonServices } from '../../types/core.types.js';
2
+ import type { CredentialOverrides } from '../credential/credential-overrides.js';
2
3
  export type AddonInstance = {
3
4
  namespace: string;
4
5
  secretOverrides?: Record<string, string>;
5
6
  variableOverrides?: Record<string, string>;
6
- credentialOverrides?: Record<string, string>;
7
+ credentialOverrides?: CredentialOverrides;
7
8
  /** Set by the consuming app: secrets it lends this instance, as the addon names them. */
8
9
  secretGrants?: string[];
9
10
  /** Set by the consuming app: credentials it lends this instance, as the addon names them. */
@@ -1,3 +1,4 @@
1
+ import type { CredentialOverrides } from '../credential/credential-overrides.js';
1
2
  import type { CorePikkuMiddleware } from '../../middleware/middleware.types.js';
2
3
  export type WireAddonConfig = {
3
4
  /** How this instance is addressed. One package may be wired more than once, and the name is what tells the instances apart. */
@@ -8,8 +9,13 @@ export type WireAddonConfig = {
8
9
  rpcEndpoint?: string;
9
10
  /** Requires a session for every function in the addon, whatever each one declares. Gates an addon whose functions are individually open. */
10
11
  auth?: boolean;
11
- /** Offers the addon's functions to MCP clients as tools, without wiring each one. */
12
- mcp?: boolean;
12
+ /**
13
+ * Offers the addon's functions to MCP clients as tools, without wiring each
14
+ * one. `true` offers every function the addon itself declared `mcp: true`; a
15
+ * list names the functions to offer, whether or not the addon declared them,
16
+ * and is typed against the addon's function names.
17
+ */
18
+ mcp?: boolean | string[];
13
19
  /** Filters this addon in and out of a build — see the `tags` option on `pikku all`. It has no effect at runtime. */
14
20
  tags?: string[];
15
21
  /** Required of every function in the addon, on top of the function's own. */
@@ -18,8 +24,12 @@ export type WireAddonConfig = {
18
24
  secretOverrides?: Record<string, string>;
19
25
  /** Points a variable the addon reads at a different key in this deployment. */
20
26
  variableOverrides?: Record<string, string>;
21
- /** Points a credential the addon reads at a different key in this deployment. */
22
- credentialOverrides?: Record<string, string>;
27
+ /**
28
+ * Points a credential the addon reads at a different key in this deployment,
29
+ * and — in the object form — decides whether it is per-user or
30
+ * deployment-wide. An addon declares a default; the wiring decides.
31
+ */
32
+ credentialOverrides?: CredentialOverrides;
23
33
  /** Extra secrets this instance may read, named as the addon reads them — the scope check runs before `secretOverrides` renames them. */
24
34
  secretGrants?: string[];
25
35
  /** Credentials this instance may read on top of the ones it declared. */
@@ -1,4 +1,10 @@
1
1
  export { wireCLI, runCLICommand, pikkuCLIRender, executeCLI, CLIError, } from './cli-runner.js';
2
2
  export { parseCLIArguments, generateCommandHelp } from './command-parser.js';
3
3
  export { defineCLICommands } from './define-cli-commands.js';
4
+ /**
5
+ * Exported so every entrypoint that can be the last thing to catch an error —
6
+ * the `pikku` binary, a generated bootstrap, a channel client — prints it the
7
+ * same way, instead of each inventing its own `console.error(error.message)`.
8
+ */
9
+ export { formatCLIError, wantsStackTrace } from './format-cli-error.js';
4
10
  export type { CLIMeta, CLICommandMeta, CLIProgramMeta, CoreCLI, CoreCLICommandConfig, CorePikkuCLIRender, } from './cli.types.js';
@@ -1,3 +1,9 @@
1
1
  export { wireCLI, runCLICommand, pikkuCLIRender, executeCLI, CLIError, } from './cli-runner.js';
2
2
  export { parseCLIArguments, generateCommandHelp } from './command-parser.js';
3
3
  export { defineCLICommands } from './define-cli-commands.js';
4
+ /**
5
+ * Exported so every entrypoint that can be the last thing to catch an error —
6
+ * the `pikku` binary, a generated bootstrap, a channel client — prints it the
7
+ * same way, instead of each inventing its own `console.error(error.message)`.
8
+ */
9
+ export { formatCLIError, wantsStackTrace } from './format-cli-error.js';
@@ -0,0 +1,26 @@
1
+ import type { CredentialResolution } from '../../services/credential-wire-service.js';
2
+ /**
3
+ * How one credential is wired into a deployment. A bare string renames it, as
4
+ * it always has. The object form also decides where its value is read from,
5
+ * which is what lets one addon serve a per-user product and a single team
6
+ * account without the addon author choosing for both.
7
+ */
8
+ export type CredentialOverride = string | {
9
+ /** The key this deployment holds the value under. */
10
+ name?: string;
11
+ /** `wire` is per user, `singleton` is one value for the deployment. */
12
+ mode?: 'singleton' | 'wire';
13
+ };
14
+ export type CredentialOverrides = Record<string, CredentialOverride>;
15
+ /** The rename half, in the `Record<string, string>` shape the alias path wants. */
16
+ export declare const credentialOverrideAliases: (overrides: CredentialOverrides | undefined) => Record<string, string>;
17
+ /**
18
+ * Where each credential the package declared is read from, keyed by the
19
+ * resolved name so the wire service can look it up without re-aliasing.
20
+ *
21
+ * A credential with no override keeps the type its author declared, which is
22
+ * what makes an addon that reads one way work under either wiring.
23
+ */
24
+ export declare const buildCredentialResolutions: (declared: Record<string, {
25
+ type?: string;
26
+ }> | null | undefined, overrides: CredentialOverrides | undefined) => Record<string, CredentialResolution>;
@@ -0,0 +1,45 @@
1
+ /** The rename half, in the `Record<string, string>` shape the alias path wants. */
2
+ export const credentialOverrideAliases = (overrides) => {
3
+ const aliases = {};
4
+ for (const [name, override] of Object.entries(overrides ?? {})) {
5
+ const target = typeof override === 'string' ? override : override.name;
6
+ if (target) {
7
+ aliases[name] = target;
8
+ }
9
+ }
10
+ return aliases;
11
+ };
12
+ /**
13
+ * Where each credential the package declared is read from, keyed by the
14
+ * resolved name so the wire service can look it up without re-aliasing.
15
+ *
16
+ * A credential with no override keeps the type its author declared, which is
17
+ * what makes an addon that reads one way work under either wiring.
18
+ */
19
+ export const buildCredentialResolutions = (declared, overrides) => {
20
+ const resolutions = {};
21
+ const names = new Set([
22
+ ...Object.keys(declared ?? {}),
23
+ ...Object.keys(overrides ?? {}),
24
+ ]);
25
+ /** Which declaration put each resolution there, for the collision message. */
26
+ const claimedBy = {};
27
+ for (const name of names) {
28
+ const override = overrides?.[name];
29
+ const resolved = (typeof override === 'string' ? override : override?.name) ?? name;
30
+ const mode = (typeof override === 'object' ? override.mode : undefined) ??
31
+ declared?.[name]?.type;
32
+ const resolution = mode === 'singleton' ? { mode: 'singleton' } : { mode: 'wire' };
33
+ const existing = resolutions[resolved];
34
+ if (existing && existing.mode !== resolution.mode) {
35
+ throw new Error(`Credentials '${claimedBy[resolved]}' and '${name}' both resolve to '${resolved}' ` +
36
+ `but disagree on how it is read: '${existing.mode}' and '${resolution.mode}'. ` +
37
+ `Only one mode can win, so the loser would silently read the other's ` +
38
+ `value — a per-user credential served from the deployment's own account, ` +
39
+ `or the reverse. Give them separate names, or wire both to the same mode.`);
40
+ }
41
+ resolutions[resolved] = resolution;
42
+ claimedBy[resolved] = name;
43
+ }
44
+ return resolutions;
45
+ };
@@ -1,3 +1,5 @@
1
1
  export { defineCredential } from './define-credential.js';
2
2
  export { validateAndBuildCredentialDefinitionsMeta } from './validate-credential-definitions.js';
3
3
  export type { CoreCredential, CredentialDefinitionsMeta, CredentialDefinitions, } from './credential.types.js';
4
+ export { credentialOverrideAliases, buildCredentialResolutions, } from './credential-overrides.js';
5
+ export type { CredentialOverride, CredentialOverrides, } from './credential-overrides.js';
@@ -1,2 +1,3 @@
1
1
  export { defineCredential } from './define-credential.js';
2
2
  export { validateAndBuildCredentialDefinitionsMeta } from './validate-credential-definitions.js';
3
+ export { credentialOverrideAliases, buildCredentialResolutions, } from './credential-overrides.js';
@@ -54,6 +54,7 @@ function registerRoute(route, groupConfig) {
54
54
  timeout: route.timeout,
55
55
  headers: route.headers,
56
56
  sse: route.sse,
57
+ streamProtocol: route.streamProtocol,
57
58
  // `CoreHTTPFunctionWiring` is discriminated by `method`, and a group builds
58
59
  // its routes from a method chosen at runtime — no arm can be narrowed to.
59
60
  });
@@ -6,6 +6,7 @@ import { getErrorResponse } from '../../errors/error-handler.js';
6
6
  import { handleHTTPError } from '../../handle-error.js';
7
7
  import { isProduction } from '../../env.js';
8
8
  import { pikkuState } from '../../pikku-state.js';
9
+ import { streamErrorFrames } from './http-stream-protocol.js';
9
10
  import { PikkuFetchHTTPResponse } from './pikku-fetch-http-response.js';
10
11
  import { PikkuFetchHTTPRequest } from './pikku-fetch-http-request.js';
11
12
  // The leaf module, not the channel-rpc barrel: http needs one refusal
@@ -194,11 +195,10 @@ const executeRoute = async (services, matchedRoute, http, options) => {
194
195
  singletonServices.logger.error(e instanceof Error ? e.message : e);
195
196
  try {
196
197
  const errorResponse = getErrorResponse(e);
197
- http?.response?.arrayBuffer(JSON.stringify({
198
- type: 'error',
199
- errorText: errorResponse?.message ?? 'Internal server error',
200
- }));
201
- http?.response?.arrayBuffer(JSON.stringify({ type: 'done' }));
198
+ const message = errorResponse?.message ?? 'Internal server error';
199
+ for (const frame of streamErrorFrames(matchedRoute.route.streamProtocol, message)) {
200
+ http?.response?.arrayBuffer(JSON.stringify(frame));
201
+ }
202
202
  }
203
203
  catch { }
204
204
  channel?.close();
@@ -0,0 +1,12 @@
1
+ import type { HTTPStreamProtocol } from './http.types.js';
2
+ /**
3
+ * The frames that terminate a stream whose function threw after the response
4
+ * was already committed to streaming.
5
+ *
6
+ * A stream's consumer parses every frame against the protocol it was promised,
7
+ * so an error announced in the wrong one reaches it as a parser failure with
8
+ * the actual message nowhere in sight. AG-UI accepts `RUN_ERROR` as the first
9
+ * event as readily as the last, and forbids anything after it, so a failure
10
+ * there is that one frame — never a trailing `done`.
11
+ */
12
+ export declare const streamErrorFrames: (protocol: HTTPStreamProtocol | undefined, message: string) => Array<Record<string, unknown>>;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The frames that terminate a stream whose function threw after the response
3
+ * was already committed to streaming.
4
+ *
5
+ * A stream's consumer parses every frame against the protocol it was promised,
6
+ * so an error announced in the wrong one reaches it as a parser failure with
7
+ * the actual message nowhere in sight. AG-UI accepts `RUN_ERROR` as the first
8
+ * event as readily as the last, and forbids anything after it, so a failure
9
+ * there is that one frame — never a trailing `done`.
10
+ */
11
+ export const streamErrorFrames = (protocol, message) => {
12
+ if (protocol === 'agui') {
13
+ return [{ type: 'RUN_ERROR', message }];
14
+ }
15
+ return [{ type: 'error', errorText: message }, { type: 'done' }];
16
+ };