@pikku/core 0.12.145 → 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.
- package/dist/crypto-utils.d.ts +3 -3
- package/dist/crypto-utils.js +3 -3
- package/dist/function/function-meta.types.d.ts +1 -1
- package/dist/{remote.d.ts → mesh.d.ts} +2 -2
- package/dist/{remote.js → mesh.js} +12 -12
- package/dist/middleware/index.d.ts +2 -2
- package/dist/middleware/index.js +2 -2
- package/dist/middleware/{remote-auth.d.ts → mesh-auth.d.ts} +6 -6
- package/dist/middleware/{remote-auth.js → mesh-auth.js} +20 -20
- package/dist/types/core.types.d.ts +3 -3
- package/dist/types/state.types.d.ts +7 -3
- package/dist/wirings/addon/remote-addon-auth.d.ts +10 -2
- package/dist/wirings/addon/remote-addon-auth.js +6 -1
- package/dist/wirings/addon/remote-addon-package.d.ts +13 -0
- package/dist/wirings/addon/remote-addon-package.js +21 -0
- package/dist/wirings/addon/wire-addon.d.ts +6 -6
- package/dist/wirings/addon/wire-addon.js +6 -6
- package/dist/wirings/addon/wire-remote-addon.d.ts +37 -3
- package/dist/wirings/addon/wire-remote-addon.js +18 -1
- package/dist/wirings/channel/channel-rpc-service.d.ts +1 -1
- package/dist/wirings/channel/channel-rpc-service.js +1 -1
- package/dist/wirings/rpc/index.d.ts +1 -1
- package/dist/wirings/rpc/index.js +1 -1
- package/dist/wirings/rpc/rpc-runner.d.ts +14 -7
- package/dist/wirings/rpc/rpc-runner.js +215 -30
- package/dist/wirings/rpc/rpc-types.d.ts +8 -5
- package/package.json +2 -2
package/dist/crypto-utils.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
package/dist/crypto-utils.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
* `
|
|
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
|
|
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,
|
|
1
|
+
import { assertStrongKeyMaterial, encryptWithKeyMaterial, MESH_SESSION_INFO, } from './crypto-utils.js';
|
|
2
2
|
/**
|
|
3
|
-
* An unset secret means
|
|
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
|
|
7
|
+
const readMeshSecret = async (secrets) => {
|
|
8
8
|
if (!secrets) {
|
|
9
9
|
return undefined;
|
|
10
10
|
}
|
|
11
11
|
try {
|
|
12
|
-
if (!(await secrets.hasSecret('
|
|
12
|
+
if (!(await secrets.hasSecret('PIKKU_MESH_SECRET'))) {
|
|
13
13
|
return undefined;
|
|
14
14
|
}
|
|
15
|
-
return (await secrets.getSecret('
|
|
15
|
+
return (await secrets.getSecret('PIKKU_MESH_SECRET')).reveal();
|
|
16
16
|
}
|
|
17
17
|
catch (cause) {
|
|
18
|
-
throw new Error('Could not read
|
|
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
|
-
* `
|
|
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
|
|
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
|
|
31
|
+
const secret = await readMeshSecret(secrets);
|
|
32
32
|
if (secret && jwt) {
|
|
33
|
-
assertStrongKeyMaterial('
|
|
33
|
+
assertStrongKeyMaterial('PIKKU_MESH_SECRET', secret);
|
|
34
34
|
const sessionEnc = session
|
|
35
|
-
? await encryptWithKeyMaterial('
|
|
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-
|
|
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
|
|
4
|
+
export { addonScopeCap, ADDON_HEADER } from './addon-scope-cap.js';
|
|
5
5
|
export { remoteJobsSecret } from './remote-jobs-secret.js';
|
|
6
|
-
export {
|
|
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';
|
package/dist/middleware/index.js
CHANGED
|
@@ -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
|
|
4
|
+
export { addonScopeCap, ADDON_HEADER } from './addon-scope-cap.js';
|
|
5
5
|
export { remoteJobsSecret } from './remote-jobs-secret.js';
|
|
6
|
-
export {
|
|
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
|
-
*
|
|
4
|
-
* `Bearer` JWT with audience `pikku-
|
|
5
|
-
* is only accepted on that function's `/
|
|
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-
|
|
9
|
-
* `/
|
|
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
|
|
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,
|
|
4
|
-
const
|
|
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
|
|
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 `/
|
|
9
|
-
* `/
|
|
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
|
|
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
|
-
*
|
|
16
|
-
* `Bearer` JWT with audience `pikku-
|
|
17
|
-
* is only accepted on that function's `/
|
|
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-
|
|
21
|
-
* `/
|
|
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
|
|
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('
|
|
34
|
+
secret = (await secrets.getSecret('PIKKU_MESH_SECRET')).reveal();
|
|
35
35
|
}
|
|
36
36
|
catch {
|
|
37
|
-
if (
|
|
37
|
+
if (isMeshRpcPath(http.request.path())) {
|
|
38
38
|
throw new UnauthorizedError();
|
|
39
39
|
}
|
|
40
40
|
return next();
|
|
41
41
|
}
|
|
42
|
-
assertStrongKeyMaterial('
|
|
42
|
+
assertStrongKeyMaterial('PIKKU_MESH_SECRET', secret);
|
|
43
43
|
if (!jwt) {
|
|
44
|
-
throw new Error('
|
|
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-
|
|
62
|
+
if (payload?.aud !== 'pikku-mesh') {
|
|
63
63
|
throw new UnauthorizedError();
|
|
64
64
|
}
|
|
65
|
-
if (payload?.fn &&
|
|
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(
|
|
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('
|
|
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
|
|
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['
|
|
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['
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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>`.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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.
|
|
110
|
-
*
|
|
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.
|
|
43
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
9
|
+
* `rpc.mesh` resolution, session propagation and traceId plumbing unchanged.
|
|
10
10
|
*/
|
|
11
11
|
export class ChannelDeploymentService {
|
|
12
12
|
send;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { PikkuRPCService, rpcService, RPCNotFoundError, RemoteAddonConfigError,
|
|
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,
|
|
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
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
-
/**
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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(
|
|
66
|
+
addError(RemoteAddonFailedError, {
|
|
63
67
|
status: 502,
|
|
64
|
-
message: 'Remote addon
|
|
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.
|
|
171
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
609
|
+
async mesh(funcName, data) {
|
|
425
610
|
if (!this.services.deploymentService) {
|
|
426
|
-
throw new Error(`No DeploymentService configured for
|
|
427
|
-
`Set up a DeploymentService to enable
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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`, `
|
|
5
|
-
export type PikkuRPC<Invoke 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
|
-
|
|
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.
|
|
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
|
-
"./
|
|
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",
|