@pikku/inspector 0.12.50 → 0.12.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +441 -0
- package/OPTIMIZATION-PLAN.md +9 -3
- package/dist/add/add-auth.d.ts +1 -1
- package/dist/add/add-auth.js +1 -1
- package/dist/add/add-cli.js +7 -0
- package/dist/add/add-credential.js +1 -1
- package/dist/add/add-functions.js +156 -7
- package/dist/add/add-http-route.js +16 -6
- package/dist/add/add-middleware.js +46 -3
- package/dist/add/add-personas.d.ts +13 -0
- package/dist/add/add-personas.js +210 -0
- package/dist/add/add-scope.js +1 -1
- package/dist/add/add-secret.js +1 -1
- package/dist/add/add-system-role.d.ts +2 -0
- package/dist/add/add-system-role.js +102 -0
- package/dist/add/add-variable.js +1 -1
- package/dist/add/add-wire-addon.js +31 -0
- package/dist/error-codes.d.ts +10 -1
- package/dist/error-codes.js +11 -0
- package/dist/index.d.ts +1 -1
- package/dist/inspector.js +33 -1
- package/dist/types.d.ts +46 -2
- package/dist/utils/annotate-http-route-auth.d.ts +22 -0
- package/dist/utils/annotate-http-route-auth.js +48 -0
- package/dist/utils/extract-function-name.js +13 -4
- package/dist/utils/extract-function-node.js +18 -1
- package/dist/utils/extract-secret-usage.d.ts +9 -0
- package/dist/utils/extract-secret-usage.js +61 -0
- package/dist/utils/literal-properties.d.ts +22 -0
- package/dist/utils/literal-properties.js +69 -0
- package/dist/utils/load-addon-functions-meta.js +26 -1
- package/dist/utils/permissions.js +8 -0
- package/dist/utils/post-process.d.ts +68 -3
- package/dist/utils/post-process.js +243 -13
- package/dist/utils/secret-alias-services.d.ts +8 -0
- package/dist/utils/secret-alias-services.js +45 -0
- package/dist/utils/secret-brokers.d.ts +2 -0
- package/dist/utils/secret-brokers.js +9 -0
- package/dist/utils/serialize-inspector-state.d.ts +10 -0
- package/dist/utils/serialize-inspector-state.js +18 -0
- package/dist/utils/serialize-middleware-groups-meta.js +3 -0
- package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
- package/dist/utils/validate-exposed-functions-gated.js +85 -0
- package/dist/utils/validate-tags-resolve-to-middleware.d.ts +20 -0
- package/dist/utils/validate-tags-resolve-to-middleware.js +41 -0
- package/dist/visit.js +4 -0
- package/package.json +3 -3
- package/src/add/add-auth.test.ts +5 -3
- package/src/add/add-auth.ts +1 -1
- package/src/add/add-cli.ts +8 -0
- package/src/add/add-credential.ts +1 -1
- package/src/add/add-functions.test.ts +263 -1
- package/src/add/add-functions.ts +219 -9
- package/src/add/add-http-route.ts +20 -7
- package/src/add/add-middleware.test.ts +196 -0
- package/src/add/add-middleware.ts +64 -4
- package/src/add/add-personas.test.ts +268 -0
- package/src/add/add-personas.ts +276 -0
- package/src/add/add-scenario-step.test.ts +49 -3
- package/src/add/add-scope.test.ts +30 -30
- package/src/add/add-scope.ts +1 -1
- package/src/add/add-secret.ts +1 -1
- package/src/add/add-subject-scenario-steps.test.ts +161 -0
- package/src/add/add-system-role.test.ts +214 -0
- package/src/add/add-system-role.ts +164 -0
- package/src/add/add-variable.ts +1 -1
- package/src/add/add-wire-addon.test.ts +111 -0
- package/src/add/add-wire-addon.ts +29 -0
- package/src/add/addon-bans.test.ts +4 -4
- package/src/add/config-value-metadata.test.ts +11 -11
- package/src/add/wire-name-literal.test.ts +2 -2
- package/src/error-codes.ts +13 -0
- package/src/index.ts +4 -1
- package/src/inspector.ts +39 -0
- package/src/types.ts +47 -2
- package/src/utils/annotate-http-route-auth.test.ts +163 -0
- package/src/utils/annotate-http-route-auth.ts +52 -0
- package/src/utils/extract-function-name.ts +14 -4
- package/src/utils/extract-function-node.ts +18 -6
- package/src/utils/extract-secret-usage.test.ts +81 -0
- package/src/utils/extract-secret-usage.ts +73 -0
- package/src/utils/literal-properties.ts +97 -0
- package/src/utils/load-addon-functions-meta.ts +32 -2
- package/src/utils/permissions-shorthand.test.ts +79 -0
- package/src/utils/permissions.ts +7 -0
- package/src/utils/post-process.test.ts +133 -0
- package/src/utils/post-process.ts +310 -12
- package/src/utils/secret-alias-services.test.ts +146 -0
- package/src/utils/secret-alias-services.ts +58 -0
- package/src/utils/secret-brokers.test.ts +53 -0
- package/src/utils/secret-brokers.ts +11 -0
- package/src/utils/serialize-inspector-state.ts +28 -0
- package/src/utils/serialize-middleware-groups-meta.ts +3 -0
- package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
- package/src/utils/validate-exposed-functions-gated.ts +94 -0
- package/src/utils/validate-tags-resolve-to-middleware.test.ts +105 -0
- package/src/utils/validate-tags-resolve-to-middleware.ts +48 -0
- package/src/visit.ts +4 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SecretUsage } from './extract-secret-usage.js';
|
|
1
2
|
import type { JSONValue } from '@pikku/core';
|
|
2
3
|
import type { InspectorDiagnostic, InspectorFeature, InspectorState } from '../types.js';
|
|
3
4
|
/**
|
|
@@ -228,6 +229,7 @@ export interface SerializableInspectorState {
|
|
|
228
229
|
secrets: {
|
|
229
230
|
definitions: InspectorState['secrets']['definitions'];
|
|
230
231
|
files: string[];
|
|
232
|
+
usage: Record<string, SecretUsage>;
|
|
231
233
|
};
|
|
232
234
|
credentials: {
|
|
233
235
|
definitions: InspectorState['credentials']['definitions'];
|
|
@@ -237,6 +239,14 @@ export interface SerializableInspectorState {
|
|
|
237
239
|
definitions: InspectorState['scopes']['definitions'];
|
|
238
240
|
files: string[];
|
|
239
241
|
};
|
|
242
|
+
systemRoles: {
|
|
243
|
+
definitions: InspectorState['systemRoles']['definitions'];
|
|
244
|
+
files: string[];
|
|
245
|
+
};
|
|
246
|
+
personas: {
|
|
247
|
+
definitions: InspectorState['personas']['definitions'];
|
|
248
|
+
files: string[];
|
|
249
|
+
};
|
|
240
250
|
variables: {
|
|
241
251
|
definitions: InspectorState['variables']['definitions'];
|
|
242
252
|
files: string[];
|
|
@@ -112,6 +112,7 @@ export function serializeInspectorState(state) {
|
|
|
112
112
|
secrets: {
|
|
113
113
|
definitions: state.secrets.definitions,
|
|
114
114
|
files: Array.from(state.secrets.files),
|
|
115
|
+
usage: Object.fromEntries(state.secrets.usage),
|
|
115
116
|
},
|
|
116
117
|
credentials: {
|
|
117
118
|
definitions: state.credentials.definitions,
|
|
@@ -121,6 +122,14 @@ export function serializeInspectorState(state) {
|
|
|
121
122
|
definitions: state.scopes.definitions,
|
|
122
123
|
files: Array.from(state.scopes.files),
|
|
123
124
|
},
|
|
125
|
+
systemRoles: {
|
|
126
|
+
definitions: state.systemRoles.definitions,
|
|
127
|
+
files: Array.from(state.systemRoles.files),
|
|
128
|
+
},
|
|
129
|
+
personas: {
|
|
130
|
+
definitions: state.personas.definitions,
|
|
131
|
+
files: Array.from(state.personas.files),
|
|
132
|
+
},
|
|
124
133
|
variables: {
|
|
125
134
|
definitions: state.variables.definitions,
|
|
126
135
|
files: Array.from(state.variables.files),
|
|
@@ -276,6 +285,7 @@ export function deserializeInspectorState(data) {
|
|
|
276
285
|
secrets: {
|
|
277
286
|
definitions: data.secrets?.definitions || [],
|
|
278
287
|
files: new Set(data.secrets?.files || []),
|
|
288
|
+
usage: new Map(Object.entries(data.secrets?.usage || {})),
|
|
279
289
|
},
|
|
280
290
|
credentials: {
|
|
281
291
|
definitions: data.credentials?.definitions || [],
|
|
@@ -285,6 +295,14 @@ export function deserializeInspectorState(data) {
|
|
|
285
295
|
definitions: data.scopes?.definitions || [],
|
|
286
296
|
files: new Set(data.scopes?.files || []),
|
|
287
297
|
},
|
|
298
|
+
systemRoles: {
|
|
299
|
+
definitions: data.systemRoles?.definitions || [],
|
|
300
|
+
files: new Set(data.systemRoles?.files || []),
|
|
301
|
+
},
|
|
302
|
+
personas: {
|
|
303
|
+
definitions: data.personas?.definitions || [],
|
|
304
|
+
files: new Set(data.personas?.files || []),
|
|
305
|
+
},
|
|
288
306
|
variables: {
|
|
289
307
|
definitions: data.variables?.definitions || [],
|
|
290
308
|
files: new Set(data.variables?.files || []),
|
|
@@ -9,6 +9,9 @@ const serializeGroupMap = (groupMap) => {
|
|
|
9
9
|
count: meta.count,
|
|
10
10
|
instanceIds: meta.instanceIds,
|
|
11
11
|
isFactory: meta.isFactory,
|
|
12
|
+
...(meta.additionalRegistrations && {
|
|
13
|
+
additionalRegistrations: meta.additionalRegistrations,
|
|
14
|
+
}),
|
|
12
15
|
};
|
|
13
16
|
}
|
|
14
17
|
return result;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { InspectorLogger, InspectorState } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* A function reachable through the generated `POST /rpc/:rpcName` dispatcher
|
|
4
|
+
* that nothing will stop.
|
|
5
|
+
*
|
|
6
|
+
* `rpc.exposed` refuses anything without `expose: true`, so the population is
|
|
7
|
+
* small and deliberate — someone typed it. What makes one dangerous is the
|
|
8
|
+
* combination: exposed, sessionless, and carrying no gate of its own or from
|
|
9
|
+
* the addon it belongs to. The dispatcher cannot help, because it does not know
|
|
10
|
+
* what it is dispatching to.
|
|
11
|
+
*
|
|
12
|
+
* knowledge: decisions/security/console-addon-privileged-functions-gate-themselves.md
|
|
13
|
+
*/
|
|
14
|
+
export declare function validateExposedFunctionsGated(logger: InspectorLogger, state: InspectorState): void;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { ErrorCode } from '../error-codes.js';
|
|
2
|
+
/**
|
|
3
|
+
* A function reachable through the generated `POST /rpc/:rpcName` dispatcher
|
|
4
|
+
* that nothing will stop.
|
|
5
|
+
*
|
|
6
|
+
* `rpc.exposed` refuses anything without `expose: true`, so the population is
|
|
7
|
+
* small and deliberate — someone typed it. What makes one dangerous is the
|
|
8
|
+
* combination: exposed, sessionless, and carrying no gate of its own or from
|
|
9
|
+
* the addon it belongs to. The dispatcher cannot help, because it does not know
|
|
10
|
+
* what it is dispatching to.
|
|
11
|
+
*
|
|
12
|
+
* knowledge: decisions/security/console-addon-privileged-functions-gate-themselves.md
|
|
13
|
+
*/
|
|
14
|
+
export function validateExposedFunctionsGated(logger, state) {
|
|
15
|
+
const ungated = [];
|
|
16
|
+
for (const [funcName, meta] of Object.entries(state.functions.meta)) {
|
|
17
|
+
if (!meta.expose)
|
|
18
|
+
continue;
|
|
19
|
+
// A pikkuFunc always requires a session, on every path.
|
|
20
|
+
if (meta.sessionless === false)
|
|
21
|
+
continue;
|
|
22
|
+
// Scenario steps are refused by rpcExposed regardless of `expose`.
|
|
23
|
+
if (meta.scenarioStep)
|
|
24
|
+
continue;
|
|
25
|
+
// The author declared that the body authorizes its own callers — a gate
|
|
26
|
+
// this check has no way to see. Taking the claim at face value is the
|
|
27
|
+
// point: without it the warning fires forever on functions that are fine,
|
|
28
|
+
// and a warning that is usually wrong stops being read.
|
|
29
|
+
if (meta.selfAuthenticated === true)
|
|
30
|
+
continue;
|
|
31
|
+
// The function gates itself.
|
|
32
|
+
if (meta.auth === true)
|
|
33
|
+
continue;
|
|
34
|
+
if (meta.scopes && meta.scopes.length > 0)
|
|
35
|
+
continue;
|
|
36
|
+
if (meta.permissions && meta.permissions.length > 0)
|
|
37
|
+
continue;
|
|
38
|
+
if (isGatedByItsAddon(state, meta.packageName))
|
|
39
|
+
continue;
|
|
40
|
+
ungated.push(funcName);
|
|
41
|
+
}
|
|
42
|
+
if (ungated.length === 0)
|
|
43
|
+
return;
|
|
44
|
+
const shown = ungated.slice(0, 10);
|
|
45
|
+
const rest = ungated.length - shown.length;
|
|
46
|
+
logger.diagnostic({
|
|
47
|
+
severity: 'warn',
|
|
48
|
+
code: ErrorCode.EXPOSED_FUNCTION_HAS_NO_GATE,
|
|
49
|
+
message: `${ungated.length} exposed function${ungated.length === 1 ? '' : 's'} ` +
|
|
50
|
+
`require neither a session nor any permission or scope, and ` +
|
|
51
|
+
`${ungated.length === 1 ? 'is' : 'are'} reachable by anyone through ` +
|
|
52
|
+
`POST /rpc/:rpcName: ${shown.join(', ')}` +
|
|
53
|
+
(rest > 0 ? `, and ${rest} more` : '') +
|
|
54
|
+
`. Add scopes or permissions to the function, gate the whole addon with ` +
|
|
55
|
+
`wireAddon({ scopes: [...] }), or drop \`expose: true\` if it was not ` +
|
|
56
|
+
`meant to be callable from outside.`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Whether every function in this package is already gated by its `wireAddon`.
|
|
61
|
+
* Those gates are applied by `runPikkuFunc` on every wiring path, so a function
|
|
62
|
+
* with no gate of its own is still covered by them.
|
|
63
|
+
*
|
|
64
|
+
* An addon whose `scopes`/`auth` were not statically knowable reads as
|
|
65
|
+
* unknown, and unknown is treated as gated — a false negative here costs a
|
|
66
|
+
* missing warning, a false positive costs trust in every warning.
|
|
67
|
+
*
|
|
68
|
+
* knowledge: decisions/security/addon-scopes-are-resolved-where-the-function-runs.md
|
|
69
|
+
*/
|
|
70
|
+
function isGatedByItsAddon(state, packageName) {
|
|
71
|
+
if (!packageName)
|
|
72
|
+
return false;
|
|
73
|
+
const declarations = state.rpc?.wireAddonDeclarations;
|
|
74
|
+
if (!declarations)
|
|
75
|
+
return false;
|
|
76
|
+
for (const declaration of declarations.values()) {
|
|
77
|
+
if (declaration.package !== packageName)
|
|
78
|
+
continue;
|
|
79
|
+
if (declaration.auth === true)
|
|
80
|
+
return true;
|
|
81
|
+
if (declaration.scopes && declaration.scopes.length > 0)
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { InspectorLogger, InspectorState } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* An addon tag naming middleware nobody registered.
|
|
4
|
+
*
|
|
5
|
+
* `wireAddon({ tags: ['admin'] })` reads like a gate and is applied like one —
|
|
6
|
+
* right up until no `addTagMiddleware('admin', …)` exists, at which point it
|
|
7
|
+
* resolves to an empty list and every request sails through. Nothing at runtime
|
|
8
|
+
* distinguishes "this tag runs three middlewares" from "this tag does nothing".
|
|
9
|
+
*
|
|
10
|
+
* Only **addon** tags are reported. A tag on a function is just as likely to be
|
|
11
|
+
* organizational — grouping, documentation, OpenAPI — and warning about every
|
|
12
|
+
* one of those would bury the case that matters. A tag on `wireAddon` is
|
|
13
|
+
* different: it is the documented way to apply a gate across a whole addon, so
|
|
14
|
+
* an inert one is never decorative. An exposed function relying on a tag it
|
|
15
|
+
* has no other gate behind is already reported by
|
|
16
|
+
* `validateExposedFunctionsGated`, which does not count tags as a gate.
|
|
17
|
+
*
|
|
18
|
+
* knowledge: decisions/security/addon-auth-and-tags-only-tighten.md
|
|
19
|
+
*/
|
|
20
|
+
export declare function validateTagsResolveToMiddleware(logger: InspectorLogger, state: InspectorState): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ErrorCode } from '../error-codes.js';
|
|
2
|
+
/**
|
|
3
|
+
* An addon tag naming middleware nobody registered.
|
|
4
|
+
*
|
|
5
|
+
* `wireAddon({ tags: ['admin'] })` reads like a gate and is applied like one —
|
|
6
|
+
* right up until no `addTagMiddleware('admin', …)` exists, at which point it
|
|
7
|
+
* resolves to an empty list and every request sails through. Nothing at runtime
|
|
8
|
+
* distinguishes "this tag runs three middlewares" from "this tag does nothing".
|
|
9
|
+
*
|
|
10
|
+
* Only **addon** tags are reported. A tag on a function is just as likely to be
|
|
11
|
+
* organizational — grouping, documentation, OpenAPI — and warning about every
|
|
12
|
+
* one of those would bury the case that matters. A tag on `wireAddon` is
|
|
13
|
+
* different: it is the documented way to apply a gate across a whole addon, so
|
|
14
|
+
* an inert one is never decorative. An exposed function relying on a tag it
|
|
15
|
+
* has no other gate behind is already reported by
|
|
16
|
+
* `validateExposedFunctionsGated`, which does not count tags as a gate.
|
|
17
|
+
*
|
|
18
|
+
* knowledge: decisions/security/addon-auth-and-tags-only-tighten.md
|
|
19
|
+
*/
|
|
20
|
+
export function validateTagsResolveToMiddleware(logger, state) {
|
|
21
|
+
const registered = new Set([
|
|
22
|
+
...state.middleware.tagMiddleware.keys(),
|
|
23
|
+
...state.channelMiddleware.tagMiddleware.keys(),
|
|
24
|
+
]);
|
|
25
|
+
for (const [namespace, declaration] of state.rpc?.wireAddonDeclarations ??
|
|
26
|
+
[]) {
|
|
27
|
+
for (const tag of declaration.tags ?? []) {
|
|
28
|
+
if (registered.has(tag))
|
|
29
|
+
continue;
|
|
30
|
+
logger.diagnostic({
|
|
31
|
+
severity: 'warn',
|
|
32
|
+
code: ErrorCode.TAG_RESOLVES_TO_NO_MIDDLEWARE,
|
|
33
|
+
message: `wireAddon('${namespace}') declares tag '${tag}', but no ` +
|
|
34
|
+
`addTagMiddleware('${tag}', …) registers anything for it. The tag ` +
|
|
35
|
+
`resolves to an empty middleware list, so it applies no gate to the ` +
|
|
36
|
+
`addon at all. Register middleware for it, or drop the tag if it is ` +
|
|
37
|
+
`only meant to group and document.`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/visit.js
CHANGED
|
@@ -23,6 +23,8 @@ import { addAuth } from './add/add-auth.js';
|
|
|
23
23
|
import { addSecret } from './add/add-secret.js';
|
|
24
24
|
import { addCredential } from './add/add-credential.js';
|
|
25
25
|
import { addScope } from './add/add-scope.js';
|
|
26
|
+
import { addSystemRole } from './add/add-system-role.js';
|
|
27
|
+
import { addPersonas } from './add/add-personas.js';
|
|
26
28
|
import { addVariable } from './add/add-variable.js';
|
|
27
29
|
import { addWorkflowGraph } from './add/add-workflow-graph.js';
|
|
28
30
|
import { addFeature } from './add/add-feature.js';
|
|
@@ -69,6 +71,8 @@ export const visitRoutes = (logger, checker, node, state, options) => {
|
|
|
69
71
|
addSecret(logger, node, checker, state, nextOptions);
|
|
70
72
|
addCredential(logger, node, checker, state, nextOptions);
|
|
71
73
|
addScope(logger, node, checker, state, nextOptions);
|
|
74
|
+
addSystemRole(logger, node, checker, state, nextOptions);
|
|
75
|
+
addPersonas(logger, node, checker, state, nextOptions);
|
|
72
76
|
addVariable(logger, node, checker, state, nextOptions);
|
|
73
77
|
addHTTPRoute(logger, node, checker, state, nextOptions);
|
|
74
78
|
addHTTPRoutes(logger, node, checker, state, nextOptions);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/inspector",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.52",
|
|
4
4
|
"description": "TypeScript inspector for Pikku — discovers functions and wirings to drive code generation",
|
|
5
5
|
"author": "yasser.fadl@gmail.com",
|
|
6
6
|
"license": "BUSL-1.1",
|
|
@@ -45,10 +45,10 @@
|
|
|
45
45
|
"zod-to-ts": "^2.1.0"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@pikku/core": "^0.12.
|
|
48
|
+
"@pikku/core": "^0.12.74"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@pikku/core": "^0.12.
|
|
51
|
+
"@pikku/core": "^0.12.74",
|
|
52
52
|
"@types/node": "^24.11.0"
|
|
53
53
|
},
|
|
54
54
|
"engines": {
|
package/src/add/add-auth.test.ts
CHANGED
|
@@ -13,8 +13,10 @@ const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
|
|
|
13
13
|
info: () => {},
|
|
14
14
|
warn: () => {},
|
|
15
15
|
error: () => {},
|
|
16
|
-
diagnostic: ({ code, message }) => {
|
|
17
|
-
|
|
16
|
+
diagnostic: ({ severity, code, message }) => {
|
|
17
|
+
if (severity !== 'warn') {
|
|
18
|
+
criticals.push({ code, message })
|
|
19
|
+
}
|
|
18
20
|
},
|
|
19
21
|
critical: (code: ErrorCode, message: string) => {
|
|
20
22
|
criticals.push({ code, message })
|
|
@@ -111,7 +113,7 @@ describe('addAuth inspector', () => {
|
|
|
111
113
|
'export const auth = pikkuBetterAuth(() =>',
|
|
112
114
|
' betterAuth({',
|
|
113
115
|
' session: { cookieCache: { enabled: true } },',
|
|
114
|
-
|
|
116
|
+
' emailAndPassword: { enabled: true },',
|
|
115
117
|
' })',
|
|
116
118
|
')',
|
|
117
119
|
].join('\n')
|
package/src/add/add-auth.ts
CHANGED
|
@@ -139,7 +139,7 @@ const isInsideGlobalMiddlewareRegistration = (node: ts.Node): boolean => {
|
|
|
139
139
|
*
|
|
140
140
|
* The pikku CLI discovers that single export and generates a catch-all
|
|
141
141
|
* `auth.gen.ts` that wires `${basePath}/**` to one shared handler, registers the
|
|
142
|
-
* better-auth session middleware, and emits a `
|
|
142
|
+
* better-auth session middleware, and emits a `defineSecret` for every configured
|
|
143
143
|
* social provider — so the auth routes and secret requirements flow through
|
|
144
144
|
* normal inspection into the deploy manifest.
|
|
145
145
|
*
|
package/src/add/add-cli.ts
CHANGED
|
@@ -153,6 +153,14 @@ function processCLIConfig(
|
|
|
153
153
|
// Already handled in first pass
|
|
154
154
|
break
|
|
155
155
|
|
|
156
|
+
case 'auth': {
|
|
157
|
+
const value = getPropertyValue(node, 'auth')
|
|
158
|
+
if (typeof value === 'boolean') {
|
|
159
|
+
programMeta.auth = value
|
|
160
|
+
}
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
|
|
156
164
|
case 'commands':
|
|
157
165
|
if (ts.isObjectLiteralExpression(prop.initializer)) {
|
|
158
166
|
programMeta.commands = processCommands(
|
|
@@ -24,7 +24,7 @@ export const addCredential: AddWiring = (
|
|
|
24
24
|
const firstArg = args[0]
|
|
25
25
|
const expression = node.expression
|
|
26
26
|
|
|
27
|
-
if (!ts.isIdentifier(expression) || expression.text !== '
|
|
27
|
+
if (!ts.isIdentifier(expression) || expression.text !== 'defineCredential') {
|
|
28
28
|
return
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { strict as assert } from 'assert'
|
|
2
2
|
import { describe, test } from 'node:test'
|
|
3
|
-
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { inspect } from '../inspector.js'
|
|
@@ -381,3 +381,265 @@ describe('pikkuChannelConnectionFunc generic mapping', () => {
|
|
|
381
381
|
}
|
|
382
382
|
})
|
|
383
383
|
})
|
|
384
|
+
|
|
385
|
+
describe('pikkuRemoteChannelFunc', () => {
|
|
386
|
+
const quietLogger = (): InspectorLogger & {
|
|
387
|
+
errors: string[]
|
|
388
|
+
criticals: Array<{ code: ErrorCode; message: string }>
|
|
389
|
+
} => {
|
|
390
|
+
const errors: string[] = []
|
|
391
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
392
|
+
return {
|
|
393
|
+
debug: () => {},
|
|
394
|
+
info: () => {},
|
|
395
|
+
warn: () => {},
|
|
396
|
+
error: (message: string) => {
|
|
397
|
+
errors.push(message)
|
|
398
|
+
},
|
|
399
|
+
diagnostic: ({ code, message }) => {
|
|
400
|
+
criticals.push({ code, message })
|
|
401
|
+
},
|
|
402
|
+
critical: (code: ErrorCode, message: string) => {
|
|
403
|
+
criticals.push({ code, message })
|
|
404
|
+
},
|
|
405
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
406
|
+
errors,
|
|
407
|
+
criticals,
|
|
408
|
+
} as any
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The vendor check keys off the declaration file's path, so a stub under
|
|
413
|
+
* `node_modules/zod` is indistinguishable from the real thing and costs this
|
|
414
|
+
* suite no dependency.
|
|
415
|
+
*/
|
|
416
|
+
const writeZodStub = async (rootDir: string) => {
|
|
417
|
+
const dir = join(rootDir, 'node_modules', 'zod')
|
|
418
|
+
await mkdir(dir, { recursive: true })
|
|
419
|
+
await writeFile(
|
|
420
|
+
join(dir, 'package.json'),
|
|
421
|
+
JSON.stringify({ name: 'zod', version: '3.0.0', types: 'index.d.ts' })
|
|
422
|
+
)
|
|
423
|
+
await writeFile(
|
|
424
|
+
join(dir, 'index.d.ts'),
|
|
425
|
+
[
|
|
426
|
+
'export interface ZodType<Output = unknown> {',
|
|
427
|
+
' _output: Output',
|
|
428
|
+
'}',
|
|
429
|
+
'export declare const z: {',
|
|
430
|
+
' object(shape: Record<string, ZodType>): ZodType<Record<string, unknown>>',
|
|
431
|
+
' string(): ZodType<string>',
|
|
432
|
+
'}',
|
|
433
|
+
'',
|
|
434
|
+
].join('\n')
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const DECLARE_WRAPPER = [
|
|
439
|
+
"import { z } from 'zod'",
|
|
440
|
+
'declare function pikkuRemoteChannelFunc(config: any): any',
|
|
441
|
+
'declare function pikkuSessionlessFunc(config: any): any',
|
|
442
|
+
]
|
|
443
|
+
|
|
444
|
+
const withProject = async (
|
|
445
|
+
label: string,
|
|
446
|
+
files: Record<string, string[]>,
|
|
447
|
+
assertions: (
|
|
448
|
+
state: Awaited<ReturnType<typeof inspect>>,
|
|
449
|
+
logger: ReturnType<typeof quietLogger>
|
|
450
|
+
) => void
|
|
451
|
+
) => {
|
|
452
|
+
const rootDir = await mkdtemp(join(tmpdir(), `pikku-${label}-`))
|
|
453
|
+
await writeZodStub(rootDir)
|
|
454
|
+
const paths: string[] = []
|
|
455
|
+
for (const [name, lines] of Object.entries(files)) {
|
|
456
|
+
const path = join(rootDir, name)
|
|
457
|
+
await writeFile(path, [...DECLARE_WRAPPER, ...lines, ''].join('\n'))
|
|
458
|
+
paths.push(path)
|
|
459
|
+
}
|
|
460
|
+
const logger = quietLogger()
|
|
461
|
+
try {
|
|
462
|
+
assertions(await inspect(logger, paths, { rootDir }), logger)
|
|
463
|
+
} finally {
|
|
464
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
test('a declaration with no func is registered rather than rejected', async () => {
|
|
469
|
+
await withProject(
|
|
470
|
+
'remote-channel-registers',
|
|
471
|
+
{
|
|
472
|
+
'capabilities.ts': [
|
|
473
|
+
'export const localCheckoutOutput = z.object({ sha: z.string() })',
|
|
474
|
+
'export const localCheckout = pikkuRemoteChannelFunc({',
|
|
475
|
+
" title: 'localCheckout',",
|
|
476
|
+
" description: 'Read the current commit of your working tree',",
|
|
477
|
+
' output: localCheckoutOutput,',
|
|
478
|
+
'})',
|
|
479
|
+
],
|
|
480
|
+
},
|
|
481
|
+
(state, logger) => {
|
|
482
|
+
// The ordinary path logs "No valid 'func' property" for a config with
|
|
483
|
+
// no callable func, which is what a contract-only declaration is.
|
|
484
|
+
assert.deepStrictEqual(logger.errors, [])
|
|
485
|
+
assert.deepStrictEqual(logger.criticals, [])
|
|
486
|
+
|
|
487
|
+
const meta = state.functions.meta['localCheckout']
|
|
488
|
+
assert.ok(meta, 'the declaration should be registered')
|
|
489
|
+
assert.strictEqual(meta!.functionType, 'remote')
|
|
490
|
+
assert.strictEqual(meta!.funcWrapper, 'pikkuRemoteChannelFunc')
|
|
491
|
+
assert.strictEqual(
|
|
492
|
+
meta!.description,
|
|
493
|
+
'Read the current commit of your working tree'
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
)
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
test('the declared schemas are what each side gets checked against', async () => {
|
|
500
|
+
await withProject(
|
|
501
|
+
'remote-channel-schemas',
|
|
502
|
+
{
|
|
503
|
+
'capabilities.ts': [
|
|
504
|
+
'export const runCommandInput = z.object({ argv: z.string() })',
|
|
505
|
+
'export const runCommandOutput = z.object({ stdout: z.string() })',
|
|
506
|
+
'export const runCommand = pikkuRemoteChannelFunc({',
|
|
507
|
+
' input: runCommandInput,',
|
|
508
|
+
' output: runCommandOutput,',
|
|
509
|
+
'})',
|
|
510
|
+
],
|
|
511
|
+
},
|
|
512
|
+
(state) => {
|
|
513
|
+
const meta = state.functions.meta['runCommand']
|
|
514
|
+
assert.strictEqual(meta!.inputSchemaName, 'RunCommandInput')
|
|
515
|
+
assert.strictEqual(meta!.outputSchemaName, 'RunCommandOutput')
|
|
516
|
+
assert.deepStrictEqual(meta!.outputs, ['RunCommandOutput'])
|
|
517
|
+
assert.strictEqual(
|
|
518
|
+
state.schemaLookup.get('RunCommandOutput')?.variableName,
|
|
519
|
+
'runCommandOutput'
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
)
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
test('the name resolves through the RPC map, which is what channel.remote reads', async () => {
|
|
526
|
+
await withProject(
|
|
527
|
+
'remote-channel-rpc-map',
|
|
528
|
+
{
|
|
529
|
+
'capabilities.ts': [
|
|
530
|
+
'export const localCheckoutOutput = z.object({ sha: z.string() })',
|
|
531
|
+
'export const localCheckout = pikkuRemoteChannelFunc({',
|
|
532
|
+
' output: localCheckoutOutput,',
|
|
533
|
+
'})',
|
|
534
|
+
],
|
|
535
|
+
},
|
|
536
|
+
(state) => {
|
|
537
|
+
// Both validators look the name up here and skip validation on a miss,
|
|
538
|
+
// so an unregistered name silently disables the schema check.
|
|
539
|
+
assert.strictEqual(
|
|
540
|
+
state.rpc.internalMeta['localCheckout'],
|
|
541
|
+
'localCheckout'
|
|
542
|
+
)
|
|
543
|
+
// Imported, so a local call reaches the factory's thrower rather than
|
|
544
|
+
// missing the registry.
|
|
545
|
+
assert.strictEqual(
|
|
546
|
+
state.rpc.internalFiles.get('localCheckout')?.exportedName,
|
|
547
|
+
'localCheckout'
|
|
548
|
+
)
|
|
549
|
+
}
|
|
550
|
+
)
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
test('an unexported declaration is skipped, the way any unexported function is', async () => {
|
|
554
|
+
await withProject(
|
|
555
|
+
'remote-channel-unexported',
|
|
556
|
+
{
|
|
557
|
+
'capabilities.ts': [
|
|
558
|
+
'const localCheckoutOutput = z.object({ sha: z.string() })',
|
|
559
|
+
'const localCheckout = pikkuRemoteChannelFunc({',
|
|
560
|
+
' output: localCheckoutOutput,',
|
|
561
|
+
'})',
|
|
562
|
+
'void localCheckout',
|
|
563
|
+
],
|
|
564
|
+
},
|
|
565
|
+
(state) => {
|
|
566
|
+
// No export and no `override` leaves nothing to name it by, so it is
|
|
567
|
+
// dropped upstream and never reaches the remote-channel path.
|
|
568
|
+
assert.strictEqual(state.rpc.internalMeta['localCheckout'], undefined)
|
|
569
|
+
assert.strictEqual(state.functions.meta['localCheckout'], undefined)
|
|
570
|
+
}
|
|
571
|
+
)
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
test('a name claimed by override still has to be exported for the server to import it', async () => {
|
|
575
|
+
await withProject(
|
|
576
|
+
'remote-channel-override-unexported',
|
|
577
|
+
{
|
|
578
|
+
'capabilities.ts': [
|
|
579
|
+
'const localCheckoutOutput = z.object({ sha: z.string() })',
|
|
580
|
+
'const localCheckout = pikkuRemoteChannelFunc({',
|
|
581
|
+
" override: 'localCheckout',",
|
|
582
|
+
' output: localCheckoutOutput,',
|
|
583
|
+
'})',
|
|
584
|
+
'void localCheckout',
|
|
585
|
+
],
|
|
586
|
+
},
|
|
587
|
+
(state, logger) => {
|
|
588
|
+
assert.strictEqual(
|
|
589
|
+
logger.errors.length,
|
|
590
|
+
1,
|
|
591
|
+
`expected one error, got ${JSON.stringify(logger.errors)}`
|
|
592
|
+
)
|
|
593
|
+
assert.match(logger.errors[0]!, /not exported/)
|
|
594
|
+
assert.strictEqual(state.rpc.internalMeta['localCheckout'], undefined)
|
|
595
|
+
}
|
|
596
|
+
)
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
test('two capabilities cannot share a name', async () => {
|
|
600
|
+
await withProject(
|
|
601
|
+
'remote-channel-duplicate',
|
|
602
|
+
{
|
|
603
|
+
'a.ts': [
|
|
604
|
+
'export const aOutput = z.object({ sha: z.string() })',
|
|
605
|
+
'export const localCheckout = pikkuRemoteChannelFunc({',
|
|
606
|
+
' output: aOutput,',
|
|
607
|
+
'})',
|
|
608
|
+
],
|
|
609
|
+
'b.ts': [
|
|
610
|
+
'export const bOutput = z.object({ sha: z.string() })',
|
|
611
|
+
'export const localCheckout = pikkuRemoteChannelFunc({',
|
|
612
|
+
' output: bOutput,',
|
|
613
|
+
'})',
|
|
614
|
+
],
|
|
615
|
+
},
|
|
616
|
+
(_state, logger) => {
|
|
617
|
+
const collision = logger.criticals.find(
|
|
618
|
+
(entry) => entry.code === ErrorCode.DUPLICATE_FUNCTION_NAME
|
|
619
|
+
)
|
|
620
|
+
assert.ok(collision, 'a name collision should be reported')
|
|
621
|
+
assert.match(collision!.message, /not unique/)
|
|
622
|
+
}
|
|
623
|
+
)
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
test('the ordinary path still rejects a config with no callable func', async () => {
|
|
627
|
+
await withProject(
|
|
628
|
+
'remote-channel-ordinary-path',
|
|
629
|
+
{
|
|
630
|
+
'capabilities.ts': [
|
|
631
|
+
'export const broken = pikkuSessionlessFunc({ auth: false })',
|
|
632
|
+
],
|
|
633
|
+
},
|
|
634
|
+
(state, logger) => {
|
|
635
|
+
assert.strictEqual(
|
|
636
|
+
logger.errors.length,
|
|
637
|
+
1,
|
|
638
|
+
`expected one error, got ${JSON.stringify(logger.errors)}`
|
|
639
|
+
)
|
|
640
|
+
assert.match(logger.errors[0]!, /No valid 'func' property/)
|
|
641
|
+
assert.strictEqual(state.functions.meta['broken']?.functionType, 'user')
|
|
642
|
+
}
|
|
643
|
+
)
|
|
644
|
+
})
|
|
645
|
+
})
|