@pikku/core 0.12.144 → 0.12.146

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.
@@ -24,10 +24,10 @@ export declare const encryptJSON: (secret: string, value: unknown) => Promise<st
24
24
  */
25
25
  export declare const decryptJSON: <T>(secret: string, token: string) => Promise<T>;
26
26
  /**
27
- * Namespaces the remote-RPC session key so the same deployment secret used for
27
+ * Namespaces the mesh-RPC session key so the same deployment secret used for
28
28
  * another purpose derives a different key. See knowledge/crypto.md.
29
29
  */
30
- export declare const REMOTE_SESSION_INFO = "pikku:remote-session";
30
+ export declare const MESH_SESSION_INFO = "pikku:mesh-session";
31
31
  /** Shortest key material, in characters, that the `*WithKeyMaterial` helpers accept. */
32
32
  export declare const MIN_KEY_MATERIAL_LENGTH = 32;
33
33
  /**
@@ -44,7 +44,7 @@ export declare const assertStrongKeyMaterial: (name: string, keyMaterial: string
44
44
  * high-entropy key material by HKDF-SHA256, with a fresh random salt per call.
45
45
  *
46
46
  * `info` domain-separates the derived key, so the same deployment secret used for
47
- * two purposes (e.g. {@link REMOTE_SESSION_INFO}) yields two unrelated keys.
47
+ * two purposes (e.g. {@link MESH_SESSION_INFO}) yields two unrelated keys.
48
48
  *
49
49
  * @param name - The key material's name, for the weak-key error.
50
50
  * @param keyMaterial - At least {@link MIN_KEY_MATERIAL_LENGTH} characters.
@@ -109,10 +109,10 @@ export const decryptJSON = async (secret, token) => {
109
109
  return JSON.parse(decoder.decode(new Uint8Array(decrypted)));
110
110
  };
111
111
  /**
112
- * Namespaces the remote-RPC session key so the same deployment secret used for
112
+ * Namespaces the mesh-RPC session key so the same deployment secret used for
113
113
  * another purpose derives a different key. See knowledge/crypto.md.
114
114
  */
115
- export const REMOTE_SESSION_INFO = 'pikku:remote-session';
115
+ export const MESH_SESSION_INFO = 'pikku:mesh-session';
116
116
  /** Shortest key material, in characters, that the `*WithKeyMaterial` helpers accept. */
117
117
  export const MIN_KEY_MATERIAL_LENGTH = 32;
118
118
  /**
@@ -143,7 +143,7 @@ const expandKeyMaterial = async (keyMaterial, info, salt) => {
143
143
  * high-entropy key material by HKDF-SHA256, with a fresh random salt per call.
144
144
  *
145
145
  * `info` domain-separates the derived key, so the same deployment secret used for
146
- * two purposes (e.g. {@link REMOTE_SESSION_INFO}) yields two unrelated keys.
146
+ * two purposes (e.g. {@link MESH_SESSION_INFO}) yields two unrelated keys.
147
147
  *
148
148
  * @param name - The key material's name, for the weak-key error.
149
149
  * @param keyMaterial - At least {@link MIN_KEY_MATERIAL_LENGTH} characters.
@@ -165,7 +165,7 @@ export type FunctionMeta = FunctionRuntimeMeta & Partial<{
165
165
  exportedName: string;
166
166
  /**
167
167
  * RPC names this function's own body calls by literal name through
168
- * `rpc.invoke(...)` / `rpc.remote(...)`. The deploy planner binds the
168
+ * `rpc.invoke(...)` / `rpc.mesh(...)`. The deploy planner binds the
169
169
  * function's unit to each callee's unit from this; a call with a computed
170
170
  * name is not listed, and is warned about instead.
171
171
  */
@@ -2,7 +2,7 @@ import type { JWTService } from './services/jwt-service.js';
2
2
  import type { SecretService } from './services/secret-service.js';
3
3
  /**
4
4
  * Authorization headers carrying a JWT-signed session and traceId for
5
- * `pikkuRemoteAuthMiddleware` on the receiving end. Used by every deployment
5
+ * `pikkuMeshAuthMiddleware` on the receiving end. Used by every deployment
6
6
  * service regardless of transport (HTTP, Lambda Invoke, service bindings).
7
7
  */
8
- export declare function buildRemoteHeaders(jwt: JWTService | undefined, secrets: SecretService | undefined, funcName: string, session?: unknown, traceId?: string): Promise<Record<string, string>>;
8
+ export declare function buildMeshHeaders(jwt: JWTService | undefined, secrets: SecretService | undefined, funcName: string, session?: unknown, traceId?: string): Promise<Record<string, string>>;
@@ -1,43 +1,43 @@
1
- import { assertStrongKeyMaterial, encryptWithKeyMaterial, REMOTE_SESSION_INFO, } from './crypto-utils.js';
1
+ import { assertStrongKeyMaterial, encryptWithKeyMaterial, MESH_SESSION_INFO, } from './crypto-utils.js';
2
2
  /**
3
- * An unset secret means remote auth is not configured, so the call goes out
3
+ * An unset secret means mesh auth is not configured, so the call goes out
4
4
  * unauthenticated. A secrets backend that fails is a different thing: sending
5
5
  * the call anyway would only earn a 401 from the peer with no hint why.
6
6
  */
7
- const readRemoteSecret = async (secrets) => {
7
+ const readMeshSecret = async (secrets) => {
8
8
  if (!secrets) {
9
9
  return undefined;
10
10
  }
11
11
  try {
12
- if (!(await secrets.hasSecret('PIKKU_REMOTE_SECRET'))) {
12
+ if (!(await secrets.hasSecret('PIKKU_MESH_SECRET'))) {
13
13
  return undefined;
14
14
  }
15
- return (await secrets.getSecret('PIKKU_REMOTE_SECRET')).reveal();
15
+ return (await secrets.getSecret('PIKKU_MESH_SECRET')).reveal();
16
16
  }
17
17
  catch (cause) {
18
- throw new Error('Could not read PIKKU_REMOTE_SECRET from the secrets service; refusing to send an unauthenticated remote call', { cause });
18
+ throw new Error('Could not read PIKKU_MESH_SECRET from the secrets service; refusing to send an unauthenticated mesh call', { cause });
19
19
  }
20
20
  };
21
21
  /**
22
22
  * Authorization headers carrying a JWT-signed session and traceId for
23
- * `pikkuRemoteAuthMiddleware` on the receiving end. Used by every deployment
23
+ * `pikkuMeshAuthMiddleware` on the receiving end. Used by every deployment
24
24
  * service regardless of transport (HTTP, Lambda Invoke, service bindings).
25
25
  */
26
- export async function buildRemoteHeaders(jwt, secrets, funcName, session, traceId) {
26
+ export async function buildMeshHeaders(jwt, secrets, funcName, session, traceId) {
27
27
  const headers = {
28
28
  'content-type': 'application/json',
29
29
  ...(traceId && { 'x-request-id': traceId }),
30
30
  };
31
- const secret = await readRemoteSecret(secrets);
31
+ const secret = await readMeshSecret(secrets);
32
32
  if (secret && jwt) {
33
- assertStrongKeyMaterial('PIKKU_REMOTE_SECRET', secret);
33
+ assertStrongKeyMaterial('PIKKU_MESH_SECRET', secret);
34
34
  const sessionEnc = session
35
- ? await encryptWithKeyMaterial('PIKKU_REMOTE_SECRET', secret, REMOTE_SESSION_INFO, {
35
+ ? await encryptWithKeyMaterial('PIKKU_MESH_SECRET', secret, MESH_SESSION_INFO, {
36
36
  session,
37
37
  })
38
38
  : undefined;
39
39
  const token = await jwt.encode({ value: 5, unit: 'minute' }, {
40
- aud: 'pikku-remote',
40
+ aud: 'pikku-mesh',
41
41
  fn: funcName,
42
42
  iat: Math.floor(Date.now() / 1000),
43
43
  session: sessionEnc,
@@ -1,9 +1,9 @@
1
1
  export { authAPIKey } from './auth-apikey.js';
2
2
  export { authCookie } from './auth-cookie.js';
3
3
  export { authBearer } from './auth-bearer.js';
4
- export { addonScopeCap, ADDON_HEADER, } from './addon-scope-cap.js';
4
+ export { addonScopeCap, ADDON_HEADER } from './addon-scope-cap.js';
5
5
  export { remoteJobsSecret } from './remote-jobs-secret.js';
6
- export { pikkuRemoteAuthMiddleware } from './remote-auth.js';
6
+ export { pikkuMeshAuthMiddleware } from './mesh-auth.js';
7
7
  export { cors } from './cors.js';
8
8
  export { requireOrigin, isAllowedOrigin, toOrigin } from './require-origin.js';
9
9
  export { telemetryOuter, telemetryInner } from './telemetry.js';
@@ -1,9 +1,9 @@
1
1
  export { authAPIKey } from './auth-apikey.js';
2
2
  export { authCookie } from './auth-cookie.js';
3
3
  export { authBearer } from './auth-bearer.js';
4
- export { addonScopeCap, ADDON_HEADER, } from './addon-scope-cap.js';
4
+ export { addonScopeCap, ADDON_HEADER } from './addon-scope-cap.js';
5
5
  export { remoteJobsSecret } from './remote-jobs-secret.js';
6
- export { pikkuRemoteAuthMiddleware } from './remote-auth.js';
6
+ export { pikkuMeshAuthMiddleware } from './mesh-auth.js';
7
7
  export { cors } from './cors.js';
8
8
  export { requireOrigin, isAllowedOrigin, toOrigin } from './require-origin.js';
9
9
  export { telemetryOuter, telemetryInner } from './telemetry.js';
@@ -1,19 +1,19 @@
1
1
  /**
2
2
  * Authenticates calls between deployments of the same app, on the generated
3
- * remote-RPC route. Once `PIKKU_REMOTE_SECRET` is set it requires a
4
- * `Bearer` JWT with audience `pikku-remote`; a token bound to a function (`fn`)
5
- * is only accepted on that function's `/remote/rpc/<fn>` path, and any session
3
+ * mesh-RPC route. Once `PIKKU_MESH_SECRET` is set it requires a
4
+ * `Bearer` JWT with audience `pikku-mesh`; a token bound to a function (`fn`)
5
+ * is only accepted on that function's `/mesh/rpc/<fn>` path, and any session
6
6
  * it carries is decrypted with the secret and set on the wire.
7
7
  *
8
- * Without the secret it lets non-remote paths through and refuses
9
- * `/remote/rpc/*`, so the remote surface fails closed.
8
+ * Without the secret it lets non-mesh paths through and refuses
9
+ * `/mesh/rpc/*`, so the mesh surface fails closed.
10
10
  *
11
11
  * @throws {UnauthorizedError} On a missing, malformed, mis-addressed or
12
12
  * undecryptable token.
13
13
  * @throws {WeakKeyMaterialError} When the secret is too short to use.
14
14
  * @throws {Error} When the secret is set but no JWT service is configured.
15
15
  */
16
- export declare const pikkuRemoteAuthMiddleware: import("./middleware.types.js").CorePikkuMiddleware<import("../types/core.types.js").CoreSingletonServices<{
16
+ export declare const pikkuMeshAuthMiddleware: import("./middleware.types.js").CorePikkuMiddleware<import("../types/core.types.js").CoreSingletonServices<{
17
17
  logLevel?: import("../services/logger.js").LogLevel;
18
18
  secrets?: {
19
19
  requireAllowedHosts?: boolean;
@@ -1,47 +1,47 @@
1
1
  import { UnauthorizedError } from '../errors/errors.js';
2
2
  import { pikkuMiddleware } from './middleware-factories.js';
3
- import { assertStrongKeyMaterial, decryptWithKeyMaterial, REMOTE_SESSION_INFO, } from '../crypto-utils.js';
4
- const REMOTE_RPC_PREFIX = '/remote/rpc/';
3
+ import { assertStrongKeyMaterial, decryptWithKeyMaterial, MESH_SESSION_INFO, } from '../crypto-utils.js';
4
+ const MESH_RPC_PREFIX = '/mesh/rpc/';
5
5
  /**
6
- * Whether this request targets the remote-RPC surface. Compared case-insensitively
6
+ * Whether this request targets the mesh-RPC surface. Compared case-insensitively
7
7
  * because the router matches routes that way (path-to-regexp defaults to
8
- * `sensitive: false`), so `/Remote/RPC/fn` reaches the same handler as
9
- * `/remote/rpc/fn`. A case-sensitive check here let a case-varied path slip
8
+ * `sensitive: false`), so `/Mesh/RPC/fn` reaches the same handler as
9
+ * `/mesh/rpc/fn`. A case-sensitive check here let a case-varied path slip
10
10
  * past the trust gate and the token's `fn` binding while still being dispatched.
11
11
  */
12
- const isRemoteRpcPath = (path) => path.toLowerCase().startsWith(REMOTE_RPC_PREFIX);
12
+ const isMeshRpcPath = (path) => path.toLowerCase().startsWith(MESH_RPC_PREFIX);
13
13
  /**
14
14
  * Authenticates calls between deployments of the same app, on the generated
15
- * remote-RPC route. Once `PIKKU_REMOTE_SECRET` is set it requires a
16
- * `Bearer` JWT with audience `pikku-remote`; a token bound to a function (`fn`)
17
- * is only accepted on that function's `/remote/rpc/<fn>` path, and any session
15
+ * mesh-RPC route. Once `PIKKU_MESH_SECRET` is set it requires a
16
+ * `Bearer` JWT with audience `pikku-mesh`; a token bound to a function (`fn`)
17
+ * is only accepted on that function's `/mesh/rpc/<fn>` path, and any session
18
18
  * it carries is decrypted with the secret and set on the wire.
19
19
  *
20
- * Without the secret it lets non-remote paths through and refuses
21
- * `/remote/rpc/*`, so the remote surface fails closed.
20
+ * Without the secret it lets non-mesh paths through and refuses
21
+ * `/mesh/rpc/*`, so the mesh surface fails closed.
22
22
  *
23
23
  * @throws {UnauthorizedError} On a missing, malformed, mis-addressed or
24
24
  * undecryptable token.
25
25
  * @throws {WeakKeyMaterialError} When the secret is too short to use.
26
26
  * @throws {Error} When the secret is set but no JWT service is configured.
27
27
  */
28
- export const pikkuRemoteAuthMiddleware = pikkuMiddleware(async ({ secrets, jwt }, { http, setInitialSession }, next) => {
28
+ export const pikkuMeshAuthMiddleware = pikkuMiddleware(async ({ secrets, jwt }, { http, setInitialSession }, next) => {
29
29
  if (!http?.request || !secrets) {
30
30
  return next();
31
31
  }
32
32
  let secret;
33
33
  try {
34
- secret = (await secrets.getSecret('PIKKU_REMOTE_SECRET')).reveal();
34
+ secret = (await secrets.getSecret('PIKKU_MESH_SECRET')).reveal();
35
35
  }
36
36
  catch {
37
- if (isRemoteRpcPath(http.request.path())) {
37
+ if (isMeshRpcPath(http.request.path())) {
38
38
  throw new UnauthorizedError();
39
39
  }
40
40
  return next();
41
41
  }
42
- assertStrongKeyMaterial('PIKKU_REMOTE_SECRET', secret);
42
+ assertStrongKeyMaterial('PIKKU_MESH_SECRET', secret);
43
43
  if (!jwt) {
44
- throw new Error('PIKKU_REMOTE_SECRET set but JWT service missing');
44
+ throw new Error('PIKKU_MESH_SECRET set but JWT service missing');
45
45
  }
46
46
  const authHeader = http.request.header('authorization') ||
47
47
  http.request.header('Authorization');
@@ -59,20 +59,20 @@ export const pikkuRemoteAuthMiddleware = pikkuMiddleware(async ({ secrets, jwt }
59
59
  catch {
60
60
  throw new UnauthorizedError();
61
61
  }
62
- if (payload?.aud !== 'pikku-remote') {
62
+ if (payload?.aud !== 'pikku-mesh') {
63
63
  throw new UnauthorizedError();
64
64
  }
65
- if (payload?.fn && isRemoteRpcPath(http.request.path())) {
65
+ if (payload?.fn && isMeshRpcPath(http.request.path())) {
66
66
  // The prefix length is the same in any case, so slicing the raw path
67
67
  // keeps the requested function name in its original case for the compare.
68
- const fn = decodeURIComponent(http.request.path().slice(REMOTE_RPC_PREFIX.length));
68
+ const fn = decodeURIComponent(http.request.path().slice(MESH_RPC_PREFIX.length));
69
69
  if (fn && payload.fn !== fn) {
70
70
  throw new UnauthorizedError();
71
71
  }
72
72
  }
73
73
  if (payload?.session) {
74
74
  try {
75
- const decrypted = await decryptWithKeyMaterial('PIKKU_REMOTE_SECRET', secret, REMOTE_SESSION_INFO, payload.session);
75
+ const decrypted = await decryptWithKeyMaterial('PIKKU_MESH_SECRET', secret, MESH_SESSION_INFO, payload.session);
76
76
  if (decrypted?.session && setInitialSession) {
77
77
  setInitialSession(decrypted.session);
78
78
  }
@@ -255,16 +255,16 @@ export type PikkuWire<In = unknown, Out = unknown, HasInitialSession extends boo
255
255
  * sets it; services that log fall back to the singleton logger.
256
256
  */
257
257
  logger: Logger;
258
- /** Trace ID for distributed tracing — propagated across remote RPC calls via the x-request-id header */
258
+ /** Trace ID for distributed tracing — propagated across mesh RPC calls via the x-request-id header */
259
259
  traceId: string;
260
260
  functionId: string;
261
261
  addonNamespace: string;
262
262
  http: PikkuHTTP<In>;
263
263
  mcp: PikkuMCP<MCPTools>;
264
- channel: [IsChannel] extends [null] ? PikkuChannel<unknown, Out, TypedRPC['remote']> : PikkuChannel<unknown, Out, TypedRPC['remote']> | undefined;
264
+ channel: [IsChannel] extends [null] ? PikkuChannel<unknown, Out, TypedRPC['mesh']> : PikkuChannel<unknown, Out, TypedRPC['mesh']> | undefined;
265
265
  scheduledTask: PikkuScheduledTask;
266
266
  queue: PikkuQueue;
267
- cli: PikkuCLI<TypedRPC['remote']>;
267
+ cli: PikkuCLI<TypedRPC['mesh']>;
268
268
  workflow: TypedWorkflow;
269
269
  scenario: TypedScenario;
270
270
  virtualUsers: TScenarioVirtualUsers;
@@ -42,7 +42,7 @@ export interface PikkuPackageState {
42
42
  rpcEndpoint?: string;
43
43
  auth?: boolean;
44
44
  tags?: string[];
45
- /** Which functions `rpc.exposed` may call: unset/`true` keeps the addon's own `expose`, `false` none, a list exactly those */
45
+ /** Which functions `rpc.exposed` may call, from those the addon declared `expose: true`: `true` all of them, a list those named, unset/`false` none */
46
46
  expose?: boolean | string[];
47
47
  /** Required of every function in this package, on top of the function's own */
48
48
  scopes?: string[];
@@ -78,10 +78,14 @@ export interface PikkuPackageState {
78
78
  } | {
79
79
  secretId: string;
80
80
  } | {
81
- resolve: (services: any, wire: any) => string | Promise<string>;
81
+ resolve: (services: any, wire: any) => string | null | Promise<string | null>;
82
82
  };
83
- /** Map consumer-facing fn name → remote fn name (wireRemoteAddon) */
83
+ /** Map consumer-facing base fn name → remote fn name (wireRemoteAddon); `@vN` is appended after */
84
84
  remoteName?: (fn: string) => string;
85
+ /** Milliseconds a remote addon call may take before it is aborted (wireRemoteAddon) */
86
+ remoteTimeoutMs?: number;
87
+ /** Extra headers sent on every call to the hosted addon (wireRemoteAddon) */
88
+ remoteHeaders?: (services: any, wire: any) => Record<string, string> | Promise<Record<string, string>>;
85
89
  }>;
86
90
  };
87
91
  http: {
@@ -10,7 +10,12 @@ export type RemoteAddonAuthBinding = {
10
10
  } | {
11
11
  secretId: string;
12
12
  } | {
13
- resolve: (services: CoreServices, wire: PikkuRawWire) => string | SecretValue<string> | Promise<string | SecretValue<string>>;
13
+ /**
14
+ * `null` says this call carries no credential, for a host whose surface
15
+ * is public to anonymous callers. An empty or undefined token still
16
+ * throws, so a resolver that lost its token does not go out anonymous.
17
+ */
18
+ resolve: (services: CoreServices, wire: PikkuRawWire) => string | SecretValue<string> | null | Promise<string | SecretValue<string> | null>;
14
19
  };
15
20
  /**
16
21
  * Thrown (401) when a remote addon's token cannot be resolved: the binding needs credential
@@ -21,5 +26,8 @@ export declare class RemoteAddonAuthError extends PikkuError {
21
26
  readonly namespace: string;
22
27
  constructor(namespace: string, detail: string);
23
28
  }
24
- /** Returns `null` only when no auth is bound; an empty resolved token throws. */
29
+ /**
30
+ * Returns `null` when no auth is bound or `resolve` answers `null`; any other
31
+ * empty token throws.
32
+ */
25
33
  export declare function resolveRemoteAddonToken(auth: RemoteAddonAuthBinding | undefined, services: CoreServices, wire: PikkuRawWire, namespace: string): Promise<string | null>;
@@ -16,7 +16,10 @@ addError(RemoteAddonAuthError, {
16
16
  status: 401,
17
17
  message: 'Remote addon authentication could not be resolved.',
18
18
  });
19
- /** Returns `null` only when no auth is bound; an empty resolved token throws. */
19
+ /**
20
+ * Returns `null` when no auth is bound or `resolve` answers `null`; any other
21
+ * empty token throws.
22
+ */
20
23
  export async function resolveRemoteAddonToken(auth, services, wire, namespace) {
21
24
  if (!auth) {
22
25
  return null;
@@ -33,6 +36,8 @@ export async function resolveRemoteAddonToken(auth, services, wire, namespace) {
33
36
  }
34
37
  else if (typeof auth.resolve === 'function') {
35
38
  token = await auth.resolve(services, wire);
39
+ if (token === null)
40
+ return null;
36
41
  }
37
42
  // The wire is where a secret is meant to end up, so unwrap it here rather
38
43
  // than at each branch — `resolve` and `getCredential` may hand one back too.
@@ -0,0 +1,13 @@
1
+ /** A `wireRemoteAddon` `package` value split into the package name and the optional semver range after it. */
2
+ export type RemoteAddonPackageSpec = {
3
+ /** The package name, e.g. `@pikkustudio/addon-cloud-api`. */
4
+ name: string;
5
+ /** The semver range, e.g. `^1.4.0`. Absent when the spec names only the package. */
6
+ range?: string;
7
+ };
8
+ /**
9
+ * Splits `@scope/name@^1.4.0` into its name and range at the last `@` after
10
+ * the first character, so a scope's leading `@` is never taken for the
11
+ * separator. Throws when either half is empty.
12
+ */
13
+ export declare const parseRemoteAddonPackage: (spec: string) => RemoteAddonPackageSpec;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Splits `@scope/name@^1.4.0` into its name and range at the last `@` after
3
+ * the first character, so a scope's leading `@` is never taken for the
4
+ * separator. Throws when either half is empty.
5
+ */
6
+ export const parseRemoteAddonPackage = (spec) => {
7
+ const trimmed = spec.trim();
8
+ const at = trimmed.lastIndexOf('@');
9
+ if (at <= 0) {
10
+ if (!trimmed || trimmed === '@') {
11
+ throw new Error(`'${spec}' is not a package name`);
12
+ }
13
+ return { name: trimmed };
14
+ }
15
+ const name = trimmed.slice(0, at);
16
+ const range = trimmed.slice(at + 1).trim();
17
+ if (!range) {
18
+ throw new Error(`'${spec}' ends in '@' with no version range after it. Write '${name}@^1.0.0', or drop the '@'.`);
19
+ }
20
+ return { name, range };
21
+ };
@@ -23,10 +23,10 @@ export type WireAddonConfig = {
23
23
  mcp?: boolean | string[];
24
24
  /**
25
25
  * Which of the addon's functions `rpc.exposed` may call, under
26
- * `<name>:<function>`. Unset or `true` keeps the functions the addon itself
27
- * declared `expose: true`; `false` exposes none of them; a list names exactly
28
- * the functions to expose, whether or not the addon declared them, and is
29
- * typed against the addon's function names.
26
+ * `<name>:<function>`. Only functions the addon itself declared
27
+ * `expose: true` can ever be exposed: `true` exposes all of them, a list
28
+ * only those it names, and unset or `false` none. A listed function the
29
+ * addon did not declare stays unexposed.
30
30
  *
31
31
  * knowledge: decisions/security/wire-addon-expose-selects-the-rpc-surface.md
32
32
  */
@@ -106,8 +106,8 @@ export type WireAddonConfig = {
106
106
  export declare const wireAddon: (config: WireAddonConfig) => void;
107
107
  /**
108
108
  * Whether `rpc.exposed` may reach an addon function through the instance that
109
- * resolved it. The wiring's `expose` decides when it is `false` or a list;
110
- * otherwise the addon's own `expose: true` does.
109
+ * resolved it. Both sides must agree: the addon declares the function
110
+ * `expose: true`, and the wiring opts in with `true` or a list naming it.
111
111
  *
112
112
  * knowledge: decisions/security/wire-addon-expose-selects-the-rpc-surface.md
113
113
  */
@@ -39,19 +39,19 @@ export const wireAddon = (config) => {
39
39
  };
40
40
  /**
41
41
  * Whether `rpc.exposed` may reach an addon function through the instance that
42
- * resolved it. The wiring's `expose` decides when it is `false` or a list;
43
- * otherwise the addon's own `expose: true` does.
42
+ * resolved it. Both sides must agree: the addon declares the function
43
+ * `expose: true`, and the wiring opts in with `true` or a list naming it.
44
44
  *
45
45
  * knowledge: decisions/security/wire-addon-expose-selects-the-rpc-surface.md
46
46
  */
47
47
  export const isAddonFunctionExposed = (expose, functionName, declaredExpose) => {
48
+ if (declaredExpose !== true) {
49
+ return false;
50
+ }
48
51
  if (Array.isArray(expose)) {
49
52
  return expose.includes(functionName);
50
53
  }
51
- if (expose === false) {
52
- return false;
53
- }
54
- return declaredExpose === true;
54
+ return expose === true;
55
55
  };
56
56
  /**
57
57
  * The addon configs a running function is governed by: the named instance when
@@ -5,22 +5,56 @@ export type RemoteAddonAuth = {
5
5
  } | {
6
6
  secretId: string;
7
7
  } | {
8
- resolve: (services: CoreServices, wire: PikkuWire) => string | Promise<string>;
8
+ /** `null` sends this call without a credential, for a public surface. */
9
+ resolve: (services: CoreServices, wire: PikkuWire) => string | null | Promise<string | null>;
9
10
  };
11
+ /**
12
+ * Sent on every call to a hosted addon. The host's `rpc.exposed` then answers
13
+ * only functions in the addon's remote contract (`remote: true`), so a consumer
14
+ * holding a token cannot reach the host's other exposed functions through it.
15
+ * It can only narrow what the host answers, never widen it.
16
+ *
17
+ * knowledge: decisions/security/wire-addon-expose-selects-the-rpc-surface.md
18
+ */
19
+ export declare const REMOTE_CONTRACT_HEADER = "x-pikku-remote-contract";
20
+ /** How long a call to a hosted addon may take when `timeoutMs` is not set. */
21
+ export declare const DEFAULT_REMOTE_ADDON_TIMEOUT_MS = 30000;
10
22
  /** Options for `wireRemoteAddon`: the namespace, the contract package, where the addon is deployed and how calls authenticate. */
11
23
  export type WireRemoteAddonConfig = {
12
24
  /** Consumer-facing namespace, e.g. `registry` → `rpc('registry:getOpenApi')` */
13
25
  name: string;
14
- /** Must be installed as a devDependency — `pikku verify` enforces this. */
26
+ /**
27
+ * The contract package, as a string literal. `@scope/name@^1.4.0` pins a
28
+ * range: `pikku all` downloads the addon's SDK from the registry and
29
+ * records it in `pikku-addons.lock`. A bare `@scope/name` must be installed
30
+ * as a devDependency instead — `pikku verify` enforces both.
31
+ */
15
32
  package: string;
16
33
  /** Where the addon is deployed, e.g. `https://registry.example.com`. A function when it varies per environment. */
17
34
  serverUrl: string | ((services: CoreServices) => string | Promise<string>);
18
35
  /** Omit when the addon declares its remote surface public. */
19
36
  auth?: RemoteAddonAuth;
20
- /** Map a consumer-facing fn name → the remote fn name, when they differ (rare). */
37
+ /**
38
+ * Map a consumer-facing fn name → the remote fn name, when they differ (rare).
39
+ * It gets the base name; a versioned call appends `@vN` to what it returns.
40
+ */
21
41
  remoteName?: (fn: string) => string;
22
42
  /** Applied to every function the addon contributes, so tag middleware and permissions reach them. */
23
43
  tags?: string[];
44
+ /**
45
+ * Which of the addon's contract functions this app's `rpc.exposed` — and so
46
+ * its public `POST /rpc/:rpcName` — forwards to the host, under
47
+ * `<name>:<function>`. `true` forwards every contract function, a list only
48
+ * those named, and unset or `false` none: server-side `rpc('<name>:fn')`
49
+ * calls are unaffected.
50
+ *
51
+ * knowledge: decisions/security/wire-addon-expose-selects-the-rpc-surface.md
52
+ */
53
+ expose?: boolean | string[];
54
+ /** Milliseconds a call may take before it is aborted. Defaults to {@link DEFAULT_REMOTE_ADDON_TIMEOUT_MS}. */
55
+ timeoutMs?: number;
56
+ /** Extra headers for every call to the host. Cannot replace the auth, trace or contract headers. */
57
+ headers?: (services: CoreServices, wire: PikkuWire) => Record<string, string> | Promise<Record<string, string>>;
24
58
  };
25
59
  /**
26
60
  * Installs an addon that runs as its own deployed service: the contract is
@@ -1,15 +1,32 @@
1
1
  import { pikkuState } from '../../pikku-state.js';
2
+ import { parseRemoteAddonPackage } from './remote-addon-package.js';
3
+ /**
4
+ * Sent on every call to a hosted addon. The host's `rpc.exposed` then answers
5
+ * only functions in the addon's remote contract (`remote: true`), so a consumer
6
+ * holding a token cannot reach the host's other exposed functions through it.
7
+ * It can only narrow what the host answers, never widen it.
8
+ *
9
+ * knowledge: decisions/security/wire-addon-expose-selects-the-rpc-surface.md
10
+ */
11
+ export const REMOTE_CONTRACT_HEADER = 'x-pikku-remote-contract';
12
+ /** How long a call to a hosted addon may take when `timeoutMs` is not set. */
13
+ export const DEFAULT_REMOTE_ADDON_TIMEOUT_MS = 30_000;
2
14
  /**
3
15
  * Installs an addon that runs as its own deployed service: the contract is
4
16
  * local, the calls go over the wire to the addon's own host.
5
17
  */
6
18
  export const wireRemoteAddon = (config) => {
7
19
  pikkuState(null, 'addons', 'packages').set(config.name, {
8
- package: config.package,
20
+ package: parseRemoteAddonPackage(config.package).name,
9
21
  tags: config.tags,
10
22
  remote: true,
11
23
  serverUrl: config.serverUrl,
24
+ ...(config.expose !== undefined ? { expose: config.expose } : {}),
12
25
  ...(config.auth ? { remoteAuth: config.auth } : {}),
13
26
  ...(config.remoteName ? { remoteName: config.remoteName } : {}),
27
+ ...(config.timeoutMs !== undefined
28
+ ? { remoteTimeoutMs: config.timeoutMs }
29
+ : {}),
30
+ ...(config.headers ? { remoteHeaders: config.headers } : {}),
14
31
  });
15
32
  };
@@ -7,7 +7,7 @@ import { type ChannelRPCValidator } from './channel-rpc.types.js';
7
7
  * inbound route of its own.
8
8
  *
9
9
  * It slots into the existing `services.deploymentService` hole, leaving
10
- * `rpc.remote` resolution, session propagation and traceId plumbing unchanged.
10
+ * `rpc.mesh` resolution, session propagation and traceId plumbing unchanged.
11
11
  */
12
12
  export declare class ChannelDeploymentService implements DeploymentService {
13
13
  private readonly send;
@@ -6,7 +6,7 @@ import { CHANNEL_RPC_REQUEST, CHANNEL_RPC_RESPONSE, ChannelRPCError, isChannelRP
6
6
  * inbound route of its own.
7
7
  *
8
8
  * It slots into the existing `services.deploymentService` hole, leaving
9
- * `rpc.remote` resolution, session propagation and traceId plumbing unchanged.
9
+ * `rpc.mesh` resolution, session propagation and traceId plumbing unchanged.
10
10
  */
11
11
  export class ChannelDeploymentService {
12
12
  send;
@@ -75,6 +75,7 @@ export interface CLICommandMeta {
75
75
  subcommands?: Record<string, CLICommandMeta>;
76
76
  middleware?: MiddlewareMetadata[];
77
77
  isDefault?: boolean;
78
+ hidden?: boolean;
78
79
  }
79
80
  /**
80
81
  * CLI metadata collection (per program)
@@ -209,6 +210,8 @@ export type CoreCLICommandConfig<FuncConfig, PikkuMiddleware extends CorePikkuMi
209
210
  permissions?: any[];
210
211
  /** Runs when the parent is invoked with no subcommand named. */
211
212
  isDefault?: boolean;
213
+ /** Still runs, but is left out of `--help`: for commands a program invokes, not a person. */
214
+ hidden?: boolean;
212
215
  };
213
216
  /**
214
217
  * CLI command definition
@@ -227,6 +230,7 @@ export interface CoreCLICommand<In, Out, PikkuFunctionConfig extends CorePikkuFu
227
230
  tags?: string[];
228
231
  subcommands?: Subcommands;
229
232
  isDefault?: boolean;
233
+ hidden?: boolean;
230
234
  }
231
235
  /**
232
236
  * Shorthand command definition (just a function)
@@ -494,6 +494,8 @@ export function generateCommandHelp(programName, allMeta, commandPath = []) {
494
494
  lines.push('');
495
495
  lines.push('Commands:');
496
496
  for (const [name, cmd] of Object.entries(meta.commands)) {
497
+ if (cmd.hidden)
498
+ continue;
497
499
  const desc = cmd.description || '';
498
500
  const defaultMarker = cmd.isDefault ? ' (default)' : '';
499
501
  lines.push(` ${name.padEnd(20)} ${desc}${defaultMarker}`);
@@ -532,6 +534,8 @@ export function generateCommandHelp(programName, allMeta, commandPath = []) {
532
534
  lines.push('');
533
535
  lines.push('Subcommands:');
534
536
  for (const [name, sub] of Object.entries(commandMeta.subcommands)) {
537
+ if (sub.hidden)
538
+ continue;
535
539
  const desc = sub.description || '';
536
540
  lines.push(` ${name.padEnd(20)} ${desc}`);
537
541
  }
@@ -1,2 +1,2 @@
1
- export { PikkuRPCService, rpcService, RPCNotFoundError, RemoteAddonConfigError, RemoteAddonRequestError, } from './rpc-runner.js';
1
+ export { PikkuRPCService, rpcService, RPCNotFoundError, RemoteAddonConfigError, RemoteAddonFailedError, RemoteAddonTimeoutError, } from './rpc-runner.js';
2
2
  export type { PikkuRPC, RPCMeta } from './rpc-types.js';
@@ -1 +1 @@
1
- export { PikkuRPCService, rpcService, RPCNotFoundError, RemoteAddonConfigError, RemoteAddonRequestError, } from './rpc-runner.js';
1
+ export { PikkuRPCService, rpcService, RPCNotFoundError, RemoteAddonConfigError, RemoteAddonFailedError, RemoteAddonTimeoutError, } from './rpc-runner.js';
@@ -11,16 +11,23 @@ import type { PikkuRPC, ResolvedFunction } from './rpc-types.js';
11
11
  */
12
12
  export declare class RPCNotFoundError extends PikkuError {
13
13
  readonly rpcName: string;
14
- constructor(rpcName: string);
14
+ constructor(rpcName: string, message?: string);
15
15
  }
16
16
  /** Thrown when a remote addon's configuration cannot be used, for example when its `serverUrl` resolves empty. Maps to HTTP 500. */
17
17
  export declare class RemoteAddonConfigError extends PikkuError {
18
18
  constructor(namespace: string, detail: string);
19
19
  }
20
- /** Thrown when a remote addon answers an RPC with a non-2xx status. Maps to HTTP 502; the upstream status is kept on `httpStatus` and the first 300 characters of its body in the message. */
21
- export declare class RemoteAddonRequestError extends PikkuError {
22
- readonly httpStatus: number;
23
- constructor(namespace: string, fnName: string, status: number, detail: string);
20
+ /**
21
+ * Thrown when a remote addon fails on its own side — a 5xx, an unreachable
22
+ * host, or a response that is not JSON. Maps to HTTP 502. What went wrong is
23
+ * logged on this host; none of it reaches the caller.
24
+ */
25
+ export declare class RemoteAddonFailedError extends PikkuError {
26
+ constructor(namespace: string, fnName: string);
27
+ }
28
+ /** Thrown when a remote addon does not answer within the wiring's `timeoutMs`. Maps to HTTP 504. */
29
+ export declare class RemoteAddonTimeoutError extends PikkuError {
30
+ constructor(namespace: string, fnName: string, timeoutMs: number);
24
31
  }
25
32
  export declare class AddonNotUsedError extends PikkuError {
26
33
  constructor(caller: string, target: string);
@@ -72,7 +79,7 @@ export declare class ContextAwareRPCService {
72
79
  * the tree behind it.
73
80
  */
74
81
  get agent(): PikkuRPC['agent'];
75
- remote<In = any, Out = any>(funcName: string, data: In): Promise<Out>;
82
+ mesh<In = any, Out = any>(funcName: string, data: In): Promise<Out>;
76
83
  }
77
84
  /**
78
85
  * Builds the `rpc` object handed to every function on its wire.
@@ -81,7 +88,7 @@ export declare class ContextAwareRPCService {
81
88
  * `DeploymentService` when the function lives in another deployment unit.
82
89
  * `rpc.exposed` does the same but only reaches functions marked `expose: true`
83
90
  * (and, for addons, those the consuming app exposes), which is what the public
84
- * RPC endpoint uses. `rpc.remote` always goes through the `DeploymentService`.
91
+ * RPC endpoint uses. `rpc.mesh` always goes through the `DeploymentService`.
85
92
  * Calls carry the caller's wire, so the session and trace ID propagate. An addon
86
93
  * can only call the addons it lists in `uses`.
87
94
  *
@@ -6,6 +6,8 @@ import { pikkuState } from '../../pikku-state.js';
6
6
  import { PikkuError, addError } from '../../errors/error-handler.js';
7
7
  import { parseVersionedId } from '../../version.js';
8
8
  import { resolveRemoteAddonToken } from '../addon/remote-addon-auth.js';
9
+ import { DEFAULT_REMOTE_ADDON_TIMEOUT_MS, REMOTE_CONTRACT_HEADER, } from '../addon/wire-remote-addon.js';
10
+ import { BadRequestError, ConflictError, ForbiddenError, GoneError, LockedError, NotFoundError, PayloadTooLargeError, PaymentRequiredError, PreconditionFailedError, TooManyRequestsError, UnauthorizedError, UnprocessableContentError, UnsupportedMediaTypeError, } from '../../errors/errors.js';
9
11
  /**
10
12
  * The function runner, reached lazily. `function-runner` builds every
11
13
  * invocation's `wire.rpc` from this module, so a static import back would be a
@@ -31,8 +33,8 @@ const resolveWireSession = async (wire) => typeof wire.getSession === 'function'
31
33
  */
32
34
  export class RPCNotFoundError extends PikkuError {
33
35
  rpcName;
34
- constructor(rpcName) {
35
- super(`RPC function not found: ${rpcName}`);
36
+ constructor(rpcName, message = `RPC function not found: ${rpcName}`) {
37
+ super(message);
36
38
  this.rpcName = rpcName;
37
39
  }
38
40
  }
@@ -51,18 +53,119 @@ addError(RemoteAddonConfigError, {
51
53
  status: 500,
52
54
  message: 'Remote addon is misconfigured.',
53
55
  });
54
- /** Thrown when a remote addon answers an RPC with a non-2xx status. Maps to HTTP 502; the upstream status is kept on `httpStatus` and the first 300 characters of its body in the message. */
55
- export class RemoteAddonRequestError extends PikkuError {
56
- httpStatus;
57
- constructor(namespace, fnName, status, detail) {
58
- super(`Remote addon '${namespace}:${fnName}' returned ${status}${detail ? `: ${detail}` : ''}`);
59
- this.httpStatus = status;
56
+ /**
57
+ * Thrown when a remote addon fails on its own side — a 5xx, an unreachable
58
+ * host, or a response that is not JSON. Maps to HTTP 502. What went wrong is
59
+ * logged on this host; none of it reaches the caller.
60
+ */
61
+ export class RemoteAddonFailedError extends PikkuError {
62
+ constructor(namespace, fnName) {
63
+ super(`Remote addon '${namespace}:${fnName}' failed`);
60
64
  }
61
65
  }
62
- addError(RemoteAddonRequestError, {
66
+ addError(RemoteAddonFailedError, {
63
67
  status: 502,
64
- message: 'Remote addon request failed.',
68
+ message: 'Remote addon failed.',
65
69
  });
70
+ /** Thrown when a remote addon does not answer within the wiring's `timeoutMs`. Maps to HTTP 504. */
71
+ export class RemoteAddonTimeoutError extends PikkuError {
72
+ constructor(namespace, fnName, timeoutMs) {
73
+ super(`Remote addon '${namespace}:${fnName}' did not answer within ${timeoutMs}ms`);
74
+ }
75
+ }
76
+ addError(RemoteAddonTimeoutError, {
77
+ status: 504,
78
+ message: 'Remote addon timed out.',
79
+ });
80
+ /** Headers a wiring's `headers` hook cannot set: the call's auth, trace and contract. */
81
+ const RESERVED_REMOTE_HEADERS = new Set([
82
+ 'authorization',
83
+ 'x-request-id',
84
+ REMOTE_CONTRACT_HEADER,
85
+ 'content-type',
86
+ 'accept',
87
+ ]);
88
+ /**
89
+ * The plain error for each 4xx a remote addon may answer with, used when its
90
+ * body names no error this host knows. Constructed without a message, so the
91
+ * caller gets the registered generic one.
92
+ */
93
+ const GENERIC_CLIENT_ERRORS = {
94
+ 400: BadRequestError,
95
+ 401: UnauthorizedError,
96
+ 402: PaymentRequiredError,
97
+ 403: ForbiddenError,
98
+ 404: NotFoundError,
99
+ 409: ConflictError,
100
+ 410: GoneError,
101
+ 412: PreconditionFailedError,
102
+ 413: PayloadTooLargeError,
103
+ 415: UnsupportedMediaTypeError,
104
+ 422: UnprocessableContentError,
105
+ 423: LockedError,
106
+ 429: TooManyRequestsError,
107
+ };
108
+ /**
109
+ * The registered error class named `name` whose status is `status`, rebuilt
110
+ * without running its constructor — a constructor's arguments are not on the
111
+ * wire, and running one with the wrong ones could throw or mislabel the error.
112
+ */
113
+ const rebuildRegisteredError = (name, status, message) => {
114
+ if (typeof name !== 'string' || !name) {
115
+ return undefined;
116
+ }
117
+ for (const [ErrorClass, details] of pikkuState(null, 'misc', 'errors')) {
118
+ if (ErrorClass.name !== name || details.status !== status) {
119
+ continue;
120
+ }
121
+ const error = Object.create(ErrorClass.prototype);
122
+ Object.defineProperty(error, 'name', {
123
+ value: name,
124
+ writable: true,
125
+ configurable: true,
126
+ });
127
+ Object.defineProperty(error, 'message', {
128
+ value: typeof message === 'string' && message ? message : details.message,
129
+ writable: true,
130
+ configurable: true,
131
+ });
132
+ Error.captureStackTrace?.(error, rebuildRegisteredError);
133
+ return error;
134
+ }
135
+ return undefined;
136
+ };
137
+ /**
138
+ * The error a non-2xx response from a remote addon becomes on this host.
139
+ *
140
+ * A 4xx naming an error registered here with the same status is rebuilt as
141
+ * that error, message included — the addon already chose to show it to its
142
+ * caller. Any other 4xx becomes the generic error for its status. A 5xx, or a
143
+ * status with no generic error, becomes {@link RemoteAddonFailedError}. The
144
+ * response body is never passed through: anything beyond the rebuilt message
145
+ * is logged here and goes no further.
146
+ *
147
+ * knowledge: decisions/security/remote-addon-errors-are-rebuilt-not-passed-through.md
148
+ */
149
+ const remoteAddonResponseError = async (res, namespace, fnName, logger) => {
150
+ const body = await res.text().catch(() => '');
151
+ if (res.status >= 400 && res.status < 500) {
152
+ let parsed;
153
+ try {
154
+ parsed = JSON.parse(body);
155
+ }
156
+ catch { }
157
+ const rebuilt = rebuildRegisteredError(parsed?.name, res.status, parsed?.message);
158
+ if (rebuilt) {
159
+ return rebuilt;
160
+ }
161
+ const GenericError = GENERIC_CLIENT_ERRORS[res.status];
162
+ if (GenericError) {
163
+ return new GenericError();
164
+ }
165
+ }
166
+ logger.error(`Remote addon '${namespace}:${fnName}' answered ${res.status}: ${body.slice(0, 1000)}`);
167
+ return new RemoteAddonFailedError(namespace, fnName);
168
+ };
66
169
  export class AddonNotUsedError extends PikkuError {
67
170
  constructor(caller, target) {
68
171
  super(`Addon '${caller}' called '${target}' but does not list it in its wireAddon 'uses'.`);
@@ -131,6 +234,27 @@ const resolvePikkuFunction = (rpcName, packageName = null) => {
131
234
  }
132
235
  return { pikkuFuncId: rpcMeta, packageName: null };
133
236
  };
237
+ /**
238
+ * A caller from outside names `fn@vN` because it was built against that
239
+ * version. Serving it the latest instead would hand it a contract it does not
240
+ * know, so a version this host no longer has is refused, not resolved to the
241
+ * base name the way an internal caller's is. An unversioned function is its
242
+ * own v1.
243
+ */
244
+ const refuseUnknownVersion = (rpcName) => {
245
+ const { baseName, version } = parseVersionedId(rpcName);
246
+ if (version === null)
247
+ return;
248
+ const rpc = pikkuState(null, 'rpc', 'meta');
249
+ if (rpc[rpcName])
250
+ return;
251
+ const current = rpc[baseName];
252
+ if (!current)
253
+ return;
254
+ if (version === 1 && parseVersionedId(current).version === null)
255
+ return;
256
+ throw new RPCNotFoundError(rpcName, `RPC function not found: ${rpcName}. Version ${version} of '${baseName}' is no longer served here; update the SDK this call was built against.`);
257
+ };
134
258
  /**
135
259
  * Marks an addon RPC name that could not be resolved. Module-private on purpose:
136
260
  * resolution failure must never be conflated with an `RPCNotFoundError` thrown by
@@ -157,22 +281,36 @@ export class ContextAwareRPCService {
157
281
  let resolvedAddon = null;
158
282
  if (funcName.includes(':')) {
159
283
  resolvedAddon = resolveNamespace(funcName);
284
+ if (resolvedAddon?.addonConfig.remote) {
285
+ // A remote addon's functions have no meta on this host. Its contract
286
+ // stands in for the addon's own `expose`, and the host enforces it:
287
+ // every forwarded call carries REMOTE_CONTRACT_HEADER.
288
+ if (!isAddonFunctionExposed(resolvedAddon.addonConfig.expose, parseVersionedId(resolvedAddon.function).baseName, true)) {
289
+ throw new RPCNotFoundError(funcName);
290
+ }
291
+ return await this.rpc(funcName, data);
292
+ }
160
293
  if (resolvedAddon) {
161
294
  functionMeta = pikkuState(resolvedAddon.package, 'function', 'meta')[resolvedAddon.function];
162
295
  }
163
296
  }
164
297
  else {
298
+ if (!this.packageName)
299
+ refuseUnknownVersion(funcName);
165
300
  const resolved = resolvePikkuFunction(funcName, this.packageName);
166
301
  functionMeta = pikkuState(resolved.packageName, 'function', 'meta')[resolved.pikkuFuncId];
167
302
  }
168
303
  if (!functionMeta) {
304
+ // Whether a function is in the remote contract is only known where its
305
+ // meta is, so a contract call never leaves this unit.
306
+ if (this.wire.http?.request?.header(REMOTE_CONTRACT_HEADER)) {
307
+ throw new RPCNotFoundError(funcName);
308
+ }
169
309
  // The addon runs in another deploy unit, which only carries the
170
- // functions the analyzer found exposed. A wiring present here still
171
- // gets to narrow what is forwarded.
310
+ // functions the analyzer found exposed. The wiring present here must
311
+ // still opt in to the function before it is forwarded.
172
312
  const refusedHere = resolvedAddon &&
173
- (resolvedAddon.addonConfig.expose === false ||
174
- (Array.isArray(resolvedAddon.addonConfig.expose) &&
175
- !resolvedAddon.addonConfig.expose.includes(resolvedAddon.function)));
313
+ !isAddonFunctionExposed(resolvedAddon.addonConfig.expose, resolvedAddon.function, true);
176
314
  if (funcName.includes(':') &&
177
315
  this.services.deploymentService &&
178
316
  !refusedHere) {
@@ -186,6 +324,10 @@ export class ContextAwareRPCService {
186
324
  if (!exposed || functionMeta.scenarioStep) {
187
325
  throw new RPCNotFoundError(funcName);
188
326
  }
327
+ if (this.wire.http?.request?.header(REMOTE_CONTRACT_HEADER) &&
328
+ functionMeta.remote !== true) {
329
+ throw new RPCNotFoundError(funcName);
330
+ }
189
331
  return await this.rpc(funcName, data);
190
332
  }
191
333
  async rpc(funcName, data) {
@@ -323,33 +465,76 @@ export class ContextAwareRPCService {
323
465
  if (!serverUrl) {
324
466
  throw new RemoteAddonConfigError(namespace, 'serverUrl resolved empty');
325
467
  }
326
- const remoteFn = cfg.remoteName ? cfg.remoteName(fnName) : fnName;
468
+ // The host serves `fn` as its own latest, which may be newer than the
469
+ // contract this consumer was built against, so a call always names a
470
+ // version: the caller's, or the newest in the SDK (registered by codegen).
471
+ const versionedFn = parseVersionedId(fnName).version !== null
472
+ ? fnName
473
+ : (pikkuState(cfg.package, 'rpc', 'meta')[fnName] ?? fnName);
474
+ // `remoteName` maps the base name; the version rides on the mapped name,
475
+ // so a lookup-table mapping still matches a versioned call.
476
+ const { baseName, version } = parseVersionedId(versionedFn);
477
+ const remoteFn = cfg.remoteName
478
+ ? `${cfg.remoteName(baseName)}${version !== null ? `@v${version}` : ''}`
479
+ : versionedFn;
327
480
  const token = await resolveRemoteAddonToken(cfg.remoteAuth, this.services, this.wire, namespace);
328
481
  const headers = {
329
482
  'content-type': 'application/json',
330
483
  accept: 'application/json',
331
484
  };
485
+ if (cfg.remoteHeaders) {
486
+ for (const [name, value] of Object.entries(await cfg.remoteHeaders(this.services, this.wire))) {
487
+ // Matched case-insensitively: `Authorization` next to `authorization`
488
+ // would reach the host as one combined value.
489
+ if (!RESERVED_REMOTE_HEADERS.has(name.toLowerCase())) {
490
+ headers[name] = value;
491
+ }
492
+ }
493
+ }
494
+ headers[REMOTE_CONTRACT_HEADER] = '1';
332
495
  if (token) {
333
496
  headers.authorization = `Bearer ${token}`;
334
497
  }
335
498
  if (this.wire.traceId) {
336
499
  headers['x-request-id'] = this.wire.traceId;
337
500
  }
501
+ const timeoutMs = cfg.remoteTimeoutMs ?? DEFAULT_REMOTE_ADDON_TIMEOUT_MS;
338
502
  const base = serverUrl.replace(/\/+$/, '');
339
- const res = await fetch(`${base}/remote/rpc/${encodeURIComponent(remoteFn)}`, {
340
- method: 'POST',
341
- headers,
342
- body: JSON.stringify({ rpcName: remoteFn, data }),
343
- });
503
+ let res;
504
+ try {
505
+ // The body the generated `POST /rpc/:rpcName` route reads, the same one
506
+ // the generated fetch client sends.
507
+ res = await fetch(`${base}/rpc/${encodeURIComponent(remoteFn)}`, {
508
+ method: 'POST',
509
+ headers,
510
+ body: JSON.stringify({ rpcName: remoteFn, data }),
511
+ signal: AbortSignal.timeout(timeoutMs),
512
+ });
513
+ }
514
+ catch (e) {
515
+ if (e?.name === 'TimeoutError') {
516
+ throw new RemoteAddonTimeoutError(namespace, remoteFn, timeoutMs);
517
+ }
518
+ this.services.logger.error(`Remote addon '${namespace}:${remoteFn}' could not be reached: ${e instanceof Error ? e.message : e}`);
519
+ throw new RemoteAddonFailedError(namespace, remoteFn);
520
+ }
344
521
  if (!res.ok) {
345
- const detail = (await res.text().catch(() => '')).slice(0, 300);
346
- throw new RemoteAddonRequestError(namespace, remoteFn, res.status, detail);
522
+ throw await remoteAddonResponseError(res, namespace, remoteFn, this.services.logger);
347
523
  }
348
524
  if (res.status === 204) {
349
525
  return undefined;
350
526
  }
351
527
  const text = await res.text();
352
- return (text ? JSON.parse(text) : undefined);
528
+ if (!text) {
529
+ return undefined;
530
+ }
531
+ try {
532
+ return JSON.parse(text);
533
+ }
534
+ catch {
535
+ this.services.logger.error(`Remote addon '${namespace}:${remoteFn}' answered ${res.status} with a body that is not JSON`);
536
+ throw new RemoteAddonFailedError(namespace, remoteFn);
537
+ }
353
538
  }
354
539
  async rpcWithWire(rpcName, data, wire) {
355
540
  const mergedWire = {
@@ -421,10 +606,10 @@ export class ContextAwareRPCService {
421
606
  }
422
607
  return createAgentRPC(this.wire, this.options);
423
608
  }
424
- async remote(funcName, data) {
609
+ async mesh(funcName, data) {
425
610
  if (!this.services.deploymentService) {
426
- throw new Error(`No DeploymentService configured for remote RPC: ${funcName}. ` +
427
- `Set up a DeploymentService to enable remote function calls.`);
611
+ throw new Error(`No DeploymentService configured for mesh RPC: ${funcName}. ` +
612
+ `Set up a DeploymentService to enable mesh function calls.`);
428
613
  }
429
614
  const session = await resolveWireSession(this.wire);
430
615
  return this.services.deploymentService.invoke(funcName, data, session, this.wire.traceId);
@@ -437,7 +622,7 @@ export class ContextAwareRPCService {
437
622
  * `DeploymentService` when the function lives in another deployment unit.
438
623
  * `rpc.exposed` does the same but only reaches functions marked `expose: true`
439
624
  * (and, for addons, those the consuming app exposes), which is what the public
440
- * RPC endpoint uses. `rpc.remote` always goes through the `DeploymentService`.
625
+ * RPC endpoint uses. `rpc.mesh` always goes through the `DeploymentService`.
441
626
  * Calls carry the caller's wire, so the session and trace ID propagate. An addon
442
627
  * can only call the addons it lists in `uses`.
443
628
  *
@@ -473,7 +658,7 @@ class ContextRPCView {
473
658
  depth;
474
659
  global = false;
475
660
  invoke;
476
- remote;
661
+ mesh;
477
662
  exposed;
478
663
  startWorkflow;
479
664
  rpcWithWire;
@@ -481,7 +666,7 @@ class ContextRPCView {
481
666
  this.serviceRPC = serviceRPC;
482
667
  this.depth = depth;
483
668
  this.invoke = serviceRPC.rpc.bind(serviceRPC);
484
- this.remote = serviceRPC.remote.bind(serviceRPC);
669
+ this.mesh = serviceRPC.mesh.bind(serviceRPC);
485
670
  this.exposed = serviceRPC.rpcExposed.bind(serviceRPC);
486
671
  this.startWorkflow = serviceRPC.startWorkflow.bind(serviceRPC);
487
672
  this.rpcWithWire = serviceRPC.rpcWithWire.bind(serviceRPC);
@@ -1,12 +1,12 @@
1
1
  import type { CredentialOverrides } from '../credential/credential-overrides.js';
2
2
  import type { PikkuRawWire } from '../../types/core.types.js';
3
3
  import type { AgentInterruptResult } from '../agent/agent-interrupt.js';
4
- /** The `rpc` object on a function's wire. An app's generated types narrow `invoke`, `remote`, `startWorkflow` and `agent` to its own functions, workflows and agents; see {@link PikkuRPCService} for how each call is routed. */
5
- export type PikkuRPC<Invoke extends (...args: any[]) => any = (...args: any[]) => any, Remote extends (...args: any[]) => any = (...args: any[]) => any, startWorkflow extends (...args: any[]) => any = (...args: any[]) => any, AgentRun extends (...args: any[]) => any = (...args: any[]) => any, AgentStream extends (...args: any[]) => any = (...args: any[]) => any> = {
4
+ /** The `rpc` object on a function's wire. An app's generated types narrow `invoke`, `mesh`, `startWorkflow` and `agent` to its own functions, workflows and agents; see {@link PikkuRPCService} for how each call is routed. */
5
+ export type PikkuRPC<Invoke extends (...args: any[]) => any = (...args: any[]) => any, Mesh extends (...args: any[]) => any = (...args: any[]) => any, startWorkflow extends (...args: any[]) => any = (...args: any[]) => any, AgentRun extends (...args: any[]) => any = (...args: any[]) => any, AgentStream extends (...args: any[]) => any = (...args: any[]) => any> = {
6
6
  depth: number;
7
7
  global: boolean;
8
8
  invoke: Invoke;
9
- remote: Remote;
9
+ mesh: Mesh;
10
10
  exposed: (name: string, data: any) => Promise<any>;
11
11
  /** Invoke an RPC with explicit wire fields merged over the caller's. */
12
12
  rpcWithWire: <In = any, Out = any>(rpcName: string, data: In, wire: PikkuRawWire) => Promise<Out>;
@@ -47,7 +47,7 @@ export interface ResolvedFunction {
47
47
  package: string;
48
48
  auth?: boolean;
49
49
  tags?: string[];
50
- /** Set by the consuming app: which functions `rpc.exposed` may call */
50
+ /** Set by the consuming app: which of the addon's declared-exposed functions `rpc.exposed` may call */
51
51
  expose?: boolean | string[];
52
52
  rpcEndpoint?: string;
53
53
  secretOverrides?: Record<string, string>;
@@ -75,8 +75,11 @@ export interface ResolvedFunction {
75
75
  } | {
76
76
  secretId: string;
77
77
  } | {
78
- resolve: (services: any, wire: any) => string | Promise<string>;
78
+ resolve: (services: any, wire: any) => string | null | Promise<string | null>;
79
79
  };
80
+ /** Maps the base fn name; a versioned call appends `@vN` after it. */
80
81
  remoteName?: (fn: string) => string;
82
+ remoteTimeoutMs?: number;
83
+ remoteHeaders?: (services: any, wire: any) => Record<string, string> | Promise<Record<string, string>>;
81
84
  };
82
85
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.144",
3
+ "version": "0.12.146",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -49,7 +49,7 @@
49
49
  "./channel/local": "./dist/wirings/channel/local/index.js",
50
50
  "./channel/serverless": "./dist/wirings/channel/serverless/index.js",
51
51
  "./http": "./dist/wirings/http/index.js",
52
- "./remote": "./dist/remote.js",
52
+ "./mesh": "./dist/mesh.js",
53
53
  "./queue": "./dist/wirings/queue/index.js",
54
54
  "./scheduler": "./dist/wirings/scheduler/index.js",
55
55
  "./trigger": "./dist/wirings/trigger/index.js",
@@ -118,7 +118,7 @@
118
118
  },
119
119
  "devDependencies": {
120
120
  "@ag-ui/core": "0.0.59",
121
- "@pikku/deploy": "^0.12.16",
121
+ "@pikku/deploy": "^0.12.17",
122
122
  "@types/node": "^26.6.5",
123
123
  "esbuild": "~0.28.2",
124
124
  "tsx": "^4.23.15"