@pikku/inspector 0.12.63 → 0.12.65
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 +63 -0
- package/dist/add/add-auth.js +85 -10
- package/dist/add/add-cli.js +3 -11
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/utils/post-process.js +10 -11
- package/dist/utils/resolve-core-type.d.ts +21 -0
- package/dist/utils/resolve-core-type.js +29 -0
- package/package.json +3 -3
- package/src/add/add-auth.test.ts +120 -0
- package/src/add/add-auth.ts +105 -10
- package/src/add/add-cli.ts +6 -12
- package/src/index.ts +1 -0
- package/src/utils/post-process.test.ts +122 -0
- package/src/utils/post-process.ts +21 -11
- package/src/utils/resolve-core-type.test.ts +142 -0
- package/src/utils/resolve-core-type.ts +35 -0
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,66 @@
|
|
|
1
|
+
## 0.12.65
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- b5e79c1: Resolve `Config`, `SingletonServices` and `Services` by declaration rather than
|
|
6
|
+
by name.
|
|
7
|
+
|
|
8
|
+
Service extraction read `typesLookup` under the hardcoded names the scaffold
|
|
9
|
+
happens to use, but the lookup is keyed by whatever the project named its
|
|
10
|
+
interface. A project that renamed one satisfied every required-type check and
|
|
11
|
+
then resolved to no services at all, surfacing much later as PKU724 or as every
|
|
12
|
+
singleton service turning optional. These now go through the import maps, which
|
|
13
|
+
carry the real name.
|
|
14
|
+
|
|
15
|
+
`pikku workflow` carried the same lookup as its fallback when aggregation came
|
|
16
|
+
back empty, so a project with a renamed interface was told
|
|
17
|
+
`WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED` while holding a perfectly good
|
|
18
|
+
`workflowService`.
|
|
19
|
+
|
|
20
|
+
- Updated dependencies [1cc50ef]
|
|
21
|
+
- Updated dependencies [a3deea4]
|
|
22
|
+
- Updated dependencies [2a02288]
|
|
23
|
+
- @pikku/core@0.12.95
|
|
24
|
+
|
|
25
|
+
## 0.12.64
|
|
26
|
+
|
|
27
|
+
### Patch Changes
|
|
28
|
+
|
|
29
|
+
- 0b1bf53: Refuse better-auth's `admin()` plugin, and point at `ban()`.
|
|
30
|
+
|
|
31
|
+
`admin()` authorizes its endpoints against a `user.role` column while pikku
|
|
32
|
+
authorizes on scopes, so wiring it means running two authorization models and
|
|
33
|
+
projecting one onto the other — coarsely, since any single `admin:users:*` scope
|
|
34
|
+
has to project to `role='admin'` and thereby unlocks every one of its endpoints
|
|
35
|
+
underneath. Pikku dropped that projection when user management moved to
|
|
36
|
+
`@pikku/addon-admin`'s scoped RPCs, so nothing reads the column any more.
|
|
37
|
+
|
|
38
|
+
The inspector now throws when `admin()` appears in a `betterAuth({ plugins })`
|
|
39
|
+
array, naming the replacement:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { ban } from '@pikku/better-auth'
|
|
43
|
+
betterAuth({ plugins: [ban()] })
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`ban()` keeps the one capability `admin()` had that pikku cannot supply from
|
|
47
|
+
outside better-auth: the `banned`/`banReason`/`banExpires` columns and the
|
|
48
|
+
session hook that refuses a banned user a session.
|
|
49
|
+
|
|
50
|
+
`pikku validate` reports the same thing without a prebuild, and additionally
|
|
51
|
+
warns about the quieter half of the migration — an app that dropped `admin()`
|
|
52
|
+
and never wired `ban()`, which keeps its ban columns and its ban UI while
|
|
53
|
+
silently enforcing nothing.
|
|
54
|
+
|
|
55
|
+
Both resolve the plugin's provenance before applying the policy: the entry has
|
|
56
|
+
to be better-auth's `admin`, imported from `better-auth/plugins` — by name, by
|
|
57
|
+
alias or through a namespace — and actually present in the `plugins` array. A
|
|
58
|
+
project's own helper called `admin` passes, and an import left behind after the
|
|
59
|
+
call was removed configures nothing.
|
|
60
|
+
|
|
61
|
+
- Updated dependencies [0b1bf53]
|
|
62
|
+
- @pikku/core@0.12.94
|
|
63
|
+
|
|
1
64
|
## 0.12.63
|
|
2
65
|
|
|
3
66
|
### Patch Changes
|
package/dist/add/add-auth.js
CHANGED
|
@@ -60,15 +60,52 @@ const readArrayProp = (obj, name) => {
|
|
|
60
60
|
return prop.initializer;
|
|
61
61
|
return undefined;
|
|
62
62
|
};
|
|
63
|
+
/** better-auth's own plugin entrypoint. */
|
|
64
|
+
const BETTER_AUTH_PLUGINS_MODULE = 'better-auth/plugins';
|
|
65
|
+
const readPluginBindings = (source) => {
|
|
66
|
+
const named = new Map();
|
|
67
|
+
const namespaces = new Set();
|
|
68
|
+
for (const statement of source.statements) {
|
|
69
|
+
if (!ts.isImportDeclaration(statement) ||
|
|
70
|
+
!ts.isStringLiteral(statement.moduleSpecifier) ||
|
|
71
|
+
statement.moduleSpecifier.text !== BETTER_AUTH_PLUGINS_MODULE) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const bindings = statement.importClause?.namedBindings;
|
|
75
|
+
if (!bindings)
|
|
76
|
+
continue;
|
|
77
|
+
if (ts.isNamespaceImport(bindings)) {
|
|
78
|
+
namespaces.add(bindings.name.text);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
for (const element of bindings.elements) {
|
|
82
|
+
named.set(element.name.text, (element.propertyName ?? element.name).text);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { named, namespaces };
|
|
86
|
+
};
|
|
63
87
|
/**
|
|
64
|
-
* Read
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
88
|
+
* Read a `plugins: [...]` entry. better-auth plugins are factory calls —
|
|
89
|
+
* `bearer()`, `twoFactor({ ... })`, `ban()`, or `plugins.bearer()` through a
|
|
90
|
+
* namespace import; non-call entries are ignored. `betterAuthExport` is the
|
|
91
|
+
* name better-auth exports the plugin under, and is absent for a plugin that
|
|
92
|
+
* came from somewhere else, which no policy here applies to.
|
|
68
93
|
*/
|
|
69
|
-
const
|
|
70
|
-
if (ts.isCallExpression(el)
|
|
71
|
-
return
|
|
94
|
+
const readPluginEntry = (el, bindings) => {
|
|
95
|
+
if (!ts.isCallExpression(el))
|
|
96
|
+
return undefined;
|
|
97
|
+
const callee = el.expression;
|
|
98
|
+
if (ts.isIdentifier(callee)) {
|
|
99
|
+
return {
|
|
100
|
+
id: callee.text,
|
|
101
|
+
betterAuthExport: bindings.named.get(callee.text),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (ts.isPropertyAccessExpression(callee) &&
|
|
105
|
+
ts.isIdentifier(callee.expression) &&
|
|
106
|
+
bindings.namespaces.has(callee.expression.text)) {
|
|
107
|
+
return { id: callee.name.text, betterAuthExport: callee.name.text };
|
|
108
|
+
}
|
|
72
109
|
return undefined;
|
|
73
110
|
};
|
|
74
111
|
/**
|
|
@@ -101,6 +138,38 @@ const isInsideGlobalMiddlewareRegistration = (node) => {
|
|
|
101
138
|
}
|
|
102
139
|
return false;
|
|
103
140
|
};
|
|
141
|
+
/**
|
|
142
|
+
* Refusal for better-auth's `admin()`, which pikku does not support.
|
|
143
|
+
*
|
|
144
|
+
* `admin()` authorizes its endpoints against a `user.role` column, so wiring it
|
|
145
|
+
* means running a second authorization model alongside pikku's scopes and
|
|
146
|
+
* keeping one projected onto the other — coarsely, since any single
|
|
147
|
+
* `admin:users:*` scope has to project to `role='admin'` and thereby unlocks
|
|
148
|
+
* every one of its endpoints underneath. Pikku dropped that projection: user
|
|
149
|
+
* management is `@pikku/addon-admin`'s scoped RPCs, and the one capability
|
|
150
|
+
* `admin()` had that nothing else did — refusing a session to a banned user —
|
|
151
|
+
* is `ban()`.
|
|
152
|
+
*
|
|
153
|
+
* Thrown rather than warned because the failure it prevents is silent. An app
|
|
154
|
+
* that drops `admin()` without wiring `ban()` keeps its ban columns and its ban
|
|
155
|
+
* UI, and simply stops enforcing bans.
|
|
156
|
+
*/
|
|
157
|
+
const UNSUPPORTED_ADMIN_PLUGIN = (sourceFile) => `better-auth's admin() plugin is not supported by pikku (${sourceFile}).
|
|
158
|
+
|
|
159
|
+
It authorizes against a 'user.role' column, while pikku authorizes on scopes —
|
|
160
|
+
wiring both means one has to be projected onto the other, and the projection is
|
|
161
|
+
coarser than the scopes it stands in for.
|
|
162
|
+
|
|
163
|
+
Use ban() instead:
|
|
164
|
+
|
|
165
|
+
import { ban } from '@pikku/better-auth'
|
|
166
|
+
betterAuth({ plugins: [ban()] })
|
|
167
|
+
|
|
168
|
+
ban() keeps the part of admin() that pikku cannot supply from outside
|
|
169
|
+
better-auth — the banned/banReason/banExpires columns and the session hook that
|
|
170
|
+
refuses a banned user a session. Everything else admin() offered is already
|
|
171
|
+
scoped RPCs in @pikku/addon-admin: list, create, ban, remove, revoke sessions
|
|
172
|
+
and set password.`;
|
|
104
173
|
/**
|
|
105
174
|
* Detects `pikkuBetterAuth((services) => betterAuth({...}))` calls.
|
|
106
175
|
*
|
|
@@ -234,10 +303,16 @@ export const addAuth = (logger, node, _checker, state) => {
|
|
|
234
303
|
}
|
|
235
304
|
const plugins = readArrayProp(config, 'plugins');
|
|
236
305
|
if (plugins) {
|
|
306
|
+
const bindings = readPluginBindings(node.getSourceFile());
|
|
237
307
|
for (const el of plugins.elements) {
|
|
238
|
-
const
|
|
239
|
-
if (
|
|
240
|
-
|
|
308
|
+
const entry = readPluginEntry(el, bindings);
|
|
309
|
+
if (!entry)
|
|
310
|
+
continue;
|
|
311
|
+
if (entry.betterAuthExport === 'admin') {
|
|
312
|
+
throw new Error(UNSUPPORTED_ADMIN_PLUGIN(sourceFile));
|
|
313
|
+
}
|
|
314
|
+
if (!state.auth.plugins.includes(entry.id)) {
|
|
315
|
+
state.auth.plugins.push(entry.id);
|
|
241
316
|
}
|
|
242
317
|
}
|
|
243
318
|
}
|
package/dist/add/add-cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import ts from 'typescript';
|
|
2
2
|
import { extractFunctionName, makeContextBasedId, } from '../utils/extract-function-name.js';
|
|
3
|
+
import { resolveCoreType } from '../utils/resolve-core-type.js';
|
|
3
4
|
import { resolveMiddleware } from '../utils/middleware.js';
|
|
4
5
|
import { resolveFunctionMeta } from '../utils/resolve-function-meta.js';
|
|
5
6
|
import { extractWireNames } from '../utils/post-process.js';
|
|
@@ -538,8 +539,8 @@ function extractEnumFromPropertyType(type, propertyName, typeChecker) {
|
|
|
538
539
|
*/
|
|
539
540
|
function extractEnumFromConfigType(logger, propertyName, typeChecker, inspectorState, _inspectorOptions) {
|
|
540
541
|
// Look for Config type in typesLookup
|
|
541
|
-
const
|
|
542
|
-
if (!
|
|
542
|
+
const configType = resolveCoreType(inspectorState.typesLookup, inspectorState.configTypeImportMap);
|
|
543
|
+
if (!configType) {
|
|
543
544
|
// Only warn once per CLI file to avoid spamming logs
|
|
544
545
|
if (!configTypeWarningShown.has('missing-config-type')) {
|
|
545
546
|
configTypeWarningShown.add('missing-config-type');
|
|
@@ -548,15 +549,6 @@ function extractEnumFromConfigType(logger, propertyName, typeChecker, inspectorS
|
|
|
548
549
|
}
|
|
549
550
|
return null;
|
|
550
551
|
}
|
|
551
|
-
// Use the first Config type (there should only be one)
|
|
552
|
-
const configType = configTypes[0];
|
|
553
|
-
if (!configType) {
|
|
554
|
-
if (!configTypeWarningShown.has('undefined-config-type')) {
|
|
555
|
-
configTypeWarningShown.add('undefined-config-type');
|
|
556
|
-
logger.warn(`Config type is undefined in typesLookup.`);
|
|
557
|
-
}
|
|
558
|
-
return null;
|
|
559
|
-
}
|
|
560
552
|
// Extract enum from the property
|
|
561
553
|
return extractEnumFromPropertyType(configType, propertyName, typeChecker);
|
|
562
554
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -23,3 +23,4 @@ export { deserializeDslWorkflow, deserializeGraphWorkflow, deserializeAllDslWork
|
|
|
23
23
|
export { getFilesAndMethods } from './utils/get-files-and-methods.js';
|
|
24
24
|
export { resolveFunctionMeta } from './utils/resolve-function-meta.js';
|
|
25
25
|
export type { SerializedWorkflowGraph, SerializedWorkflowGraphs, } from './utils/workflow/graph/index.js';
|
|
26
|
+
export { resolveCoreType } from './utils/resolve-core-type.js';
|
package/dist/index.js
CHANGED
|
@@ -12,3 +12,4 @@ export { serializeMCPJson } from './utils/serialize-mcp-json.js';
|
|
|
12
12
|
export { deserializeDslWorkflow, deserializeGraphWorkflow, deserializeAllDslWorkflows, } from './utils/workflow/dsl/index.js';
|
|
13
13
|
export { getFilesAndMethods } from './utils/get-files-and-methods.js';
|
|
14
14
|
export { resolveFunctionMeta } from './utils/resolve-function-meta.js';
|
|
15
|
+
export { resolveCoreType } from './utils/resolve-core-type.js';
|
|
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { extractTypeKeys } from './type-utils.js';
|
|
5
5
|
import { ErrorCode } from '../error-codes.js';
|
|
6
6
|
import { findSecretAliasServices } from './secret-alias-services.js';
|
|
7
|
+
import { resolveCoreType } from './resolve-core-type.js';
|
|
7
8
|
import { relative } from 'node:path';
|
|
8
9
|
import { AUTH_HANDLER_FUNC_ID } from '../add/add-auth.js';
|
|
9
10
|
import { flattenScopeDefinitions } from '@pikku/core/scope';
|
|
@@ -79,18 +80,16 @@ function extractAllServices(state) {
|
|
|
79
80
|
return;
|
|
80
81
|
}
|
|
81
82
|
// Extract all singleton services from the SingletonServices type
|
|
82
|
-
const
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
state.serviceAggregation.allSingletonServices = singletonServiceNames.sort();
|
|
83
|
+
const singletonServicesType = resolveCoreType(state.typesLookup, state.singletonServicesTypeImportMap);
|
|
84
|
+
if (singletonServicesType) {
|
|
85
|
+
state.serviceAggregation.allSingletonServices = extractTypeKeys(singletonServicesType).sort();
|
|
86
86
|
}
|
|
87
87
|
// Extract all services from the Services type
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
90
|
-
const allServiceNames = extractTypeKeys(servicesTypes[0]);
|
|
88
|
+
const servicesType = resolveCoreType(state.typesLookup, state.wireServicesTypeImportMap);
|
|
89
|
+
if (servicesType) {
|
|
91
90
|
// Wire services are those in Services but not in SingletonServices
|
|
92
91
|
const singletonSet = new Set(state.serviceAggregation.allSingletonServices);
|
|
93
|
-
state.serviceAggregation.allWireServices =
|
|
92
|
+
state.serviceAggregation.allWireServices = extractTypeKeys(servicesType)
|
|
94
93
|
.filter((name) => !singletonSet.has(name))
|
|
95
94
|
.sort();
|
|
96
95
|
}
|
|
@@ -898,11 +897,11 @@ export function validateNoSecretAliasServices(logger, checker, state) {
|
|
|
898
897
|
if (!('typesLookup' in state)) {
|
|
899
898
|
return;
|
|
900
899
|
}
|
|
901
|
-
const
|
|
902
|
-
if (!
|
|
900
|
+
const singletonServicesType = resolveCoreType(state.typesLookup, state.singletonServicesTypeImportMap);
|
|
901
|
+
if (!singletonServicesType) {
|
|
903
902
|
return;
|
|
904
903
|
}
|
|
905
|
-
const aliases = findSecretAliasServices(
|
|
904
|
+
const aliases = findSecretAliasServices(singletonServicesType, checker);
|
|
906
905
|
if (aliases.length === 0) {
|
|
907
906
|
return;
|
|
908
907
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type ts from 'typescript';
|
|
2
|
+
import type { PathToNameAndType } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Resolves the one interface extending a core type (`CoreConfig`,
|
|
5
|
+
* `CoreSingletonServices`, `CoreServices`) to the `ts.Type` the AST walk
|
|
6
|
+
* recorded for it.
|
|
7
|
+
*
|
|
8
|
+
* `typesLookup` is keyed by whatever the project named the interface, so
|
|
9
|
+
* reading it under a hardcoded `'SingletonServices'` only works for projects
|
|
10
|
+
* that follow the scaffold's naming. A project that renamed the interface still
|
|
11
|
+
* satisfies every required-type check and then resolves to no services at all,
|
|
12
|
+
* which surfaces far downstream as PKU724 or as every service turning optional.
|
|
13
|
+
* The import map carries the real name, so no name matching is needed.
|
|
14
|
+
*
|
|
15
|
+
* A project is only ever meant to declare one of each: `getMetaTypes` records
|
|
16
|
+
* `More than one ... found` when it sees a second, which `checkRequiredTypes`
|
|
17
|
+
* then reports. That check is the caller's to run, and extraction happens
|
|
18
|
+
* first, so where two declarations do exist this takes the first in traversal
|
|
19
|
+
* order rather than diagnosing the ambiguity itself.
|
|
20
|
+
*/
|
|
21
|
+
export declare const resolveCoreType: (typesLookup: Map<string, ts.Type[]>, importMap: PathToNameAndType) => ts.Type | undefined;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves the one interface extending a core type (`CoreConfig`,
|
|
3
|
+
* `CoreSingletonServices`, `CoreServices`) to the `ts.Type` the AST walk
|
|
4
|
+
* recorded for it.
|
|
5
|
+
*
|
|
6
|
+
* `typesLookup` is keyed by whatever the project named the interface, so
|
|
7
|
+
* reading it under a hardcoded `'SingletonServices'` only works for projects
|
|
8
|
+
* that follow the scaffold's naming. A project that renamed the interface still
|
|
9
|
+
* satisfies every required-type check and then resolves to no services at all,
|
|
10
|
+
* which surfaces far downstream as PKU724 or as every service turning optional.
|
|
11
|
+
* The import map carries the real name, so no name matching is needed.
|
|
12
|
+
*
|
|
13
|
+
* A project is only ever meant to declare one of each: `getMetaTypes` records
|
|
14
|
+
* `More than one ... found` when it sees a second, which `checkRequiredTypes`
|
|
15
|
+
* then reports. That check is the caller's to run, and extraction happens
|
|
16
|
+
* first, so where two declarations do exist this takes the first in traversal
|
|
17
|
+
* order rather than diagnosing the ambiguity itself.
|
|
18
|
+
*/
|
|
19
|
+
export const resolveCoreType = (typesLookup, importMap) => {
|
|
20
|
+
for (const entries of importMap.values()) {
|
|
21
|
+
for (const { type } of entries) {
|
|
22
|
+
const resolved = type ? typesLookup.get(type)?.[0] : undefined;
|
|
23
|
+
if (resolved) {
|
|
24
|
+
return resolved;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/inspector",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.65",
|
|
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.95"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@pikku/core": "^0.12.
|
|
51
|
+
"@pikku/core": "^0.12.95",
|
|
52
52
|
"@types/node": "^24.13.3"
|
|
53
53
|
},
|
|
54
54
|
"engines": {
|
package/src/add/add-auth.test.ts
CHANGED
|
@@ -217,6 +217,126 @@ describe('addAuth inspector', () => {
|
|
|
217
217
|
}
|
|
218
218
|
})
|
|
219
219
|
|
|
220
|
+
test('refuses better-auth admin() and points at ban()', async () => {
|
|
221
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-admin-'))
|
|
222
|
+
const file = join(rootDir, 'auth.ts')
|
|
223
|
+
|
|
224
|
+
await writeFile(
|
|
225
|
+
file,
|
|
226
|
+
[
|
|
227
|
+
"import { pikkuBetterAuth } from '@pikku/better-auth'",
|
|
228
|
+
"import { betterAuth } from 'better-auth'",
|
|
229
|
+
"import { admin, bearer } from 'better-auth/plugins'",
|
|
230
|
+
'export const auth = pikkuBetterAuth(() =>',
|
|
231
|
+
' betterAuth({',
|
|
232
|
+
' plugins: [bearer(), admin()],',
|
|
233
|
+
' })',
|
|
234
|
+
')',
|
|
235
|
+
].join('\n')
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
239
|
+
try {
|
|
240
|
+
await assert.rejects(
|
|
241
|
+
() => inspect(makeLogger(criticals), [file], { rootDir }),
|
|
242
|
+
(error: Error) => {
|
|
243
|
+
assert.match(error.message, /admin\(\) plugin is not supported/)
|
|
244
|
+
assert.match(error.message, /ban\(\)/)
|
|
245
|
+
assert.match(error.message, /@pikku\/addon-admin/)
|
|
246
|
+
return true
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
} finally {
|
|
250
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('refuses admin() reached through a namespace import', async () => {
|
|
255
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-ns-admin-'))
|
|
256
|
+
const file = join(rootDir, 'auth.ts')
|
|
257
|
+
|
|
258
|
+
await writeFile(
|
|
259
|
+
file,
|
|
260
|
+
[
|
|
261
|
+
"import { pikkuBetterAuth } from '@pikku/better-auth'",
|
|
262
|
+
"import { betterAuth } from 'better-auth'",
|
|
263
|
+
"import * as plugins from 'better-auth/plugins'",
|
|
264
|
+
'export const auth = pikkuBetterAuth(() =>',
|
|
265
|
+
' betterAuth({',
|
|
266
|
+
' plugins: [plugins.bearer(), plugins.admin()],',
|
|
267
|
+
' })',
|
|
268
|
+
')',
|
|
269
|
+
].join('\n')
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
273
|
+
try {
|
|
274
|
+
await assert.rejects(
|
|
275
|
+
() => inspect(makeLogger(criticals), [file], { rootDir }),
|
|
276
|
+
/admin\(\) plugin is not supported/
|
|
277
|
+
)
|
|
278
|
+
} finally {
|
|
279
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('allows a local plugin of its own named admin', async () => {
|
|
284
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-local-admin-'))
|
|
285
|
+
const file = join(rootDir, 'auth.ts')
|
|
286
|
+
|
|
287
|
+
await writeFile(
|
|
288
|
+
file,
|
|
289
|
+
[
|
|
290
|
+
"import { pikkuBetterAuth } from '@pikku/better-auth'",
|
|
291
|
+
"import { betterAuth } from 'better-auth'",
|
|
292
|
+
"import { bearer } from 'better-auth/plugins'",
|
|
293
|
+
'const admin = () => ({ id: "admin" })',
|
|
294
|
+
'export const auth = pikkuBetterAuth(() =>',
|
|
295
|
+
' betterAuth({',
|
|
296
|
+
' plugins: [bearer(), admin()],',
|
|
297
|
+
' })',
|
|
298
|
+
')',
|
|
299
|
+
].join('\n')
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
303
|
+
try {
|
|
304
|
+
const state = await inspect(makeLogger(criticals), [file], { rootDir })
|
|
305
|
+
assert.equal(criticals.length, 0)
|
|
306
|
+
assert.deepEqual(state.auth.plugins, ['bearer', 'admin'])
|
|
307
|
+
} finally {
|
|
308
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('records a namespace-imported plugin under its exported name', async () => {
|
|
313
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-ns-'))
|
|
314
|
+
const file = join(rootDir, 'auth.ts')
|
|
315
|
+
|
|
316
|
+
await writeFile(
|
|
317
|
+
file,
|
|
318
|
+
[
|
|
319
|
+
"import { pikkuBetterAuth } from '@pikku/better-auth'",
|
|
320
|
+
"import { betterAuth } from 'better-auth'",
|
|
321
|
+
"import * as plugins from 'better-auth/plugins'",
|
|
322
|
+
'export const auth = pikkuBetterAuth(() =>',
|
|
323
|
+
' betterAuth({',
|
|
324
|
+
' plugins: [plugins.bearer()],',
|
|
325
|
+
' })',
|
|
326
|
+
')',
|
|
327
|
+
].join('\n')
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
331
|
+
try {
|
|
332
|
+
const state = await inspect(makeLogger(criticals), [file], { rootDir })
|
|
333
|
+
assert.equal(criticals.length, 0)
|
|
334
|
+
assert.deepEqual(state.auth.plugins, ['bearer'])
|
|
335
|
+
} finally {
|
|
336
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
337
|
+
}
|
|
338
|
+
})
|
|
339
|
+
|
|
220
340
|
test('records no plugins when the plugins array is absent', async () => {
|
|
221
341
|
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-noplugins-'))
|
|
222
342
|
const file = join(rootDir, 'auth.ts')
|
package/src/add/add-auth.ts
CHANGED
|
@@ -87,15 +87,71 @@ const readArrayProp = (
|
|
|
87
87
|
return undefined
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** better-auth's own plugin entrypoint. */
|
|
91
|
+
const BETTER_AUTH_PLUGINS_MODULE = 'better-auth/plugins'
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* What the names used in a `plugins: [...]` array were bound to by the file's
|
|
95
|
+
* imports of {@link BETTER_AUTH_PLUGINS_MODULE}, so a project's local helper
|
|
96
|
+
* called `admin` is not mistaken for better-auth's.
|
|
97
|
+
*/
|
|
98
|
+
type PluginBindings = {
|
|
99
|
+
/** Local name → the name it is exported under by better-auth. */
|
|
100
|
+
named: Map<string, string>
|
|
101
|
+
/** Locals bound by `import * as x from 'better-auth/plugins'`. */
|
|
102
|
+
namespaces: Set<string>
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const readPluginBindings = (source: ts.SourceFile): PluginBindings => {
|
|
106
|
+
const named = new Map<string, string>()
|
|
107
|
+
const namespaces = new Set<string>()
|
|
108
|
+
for (const statement of source.statements) {
|
|
109
|
+
if (
|
|
110
|
+
!ts.isImportDeclaration(statement) ||
|
|
111
|
+
!ts.isStringLiteral(statement.moduleSpecifier) ||
|
|
112
|
+
statement.moduleSpecifier.text !== BETTER_AUTH_PLUGINS_MODULE
|
|
113
|
+
) {
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
const bindings = statement.importClause?.namedBindings
|
|
117
|
+
if (!bindings) continue
|
|
118
|
+
if (ts.isNamespaceImport(bindings)) {
|
|
119
|
+
namespaces.add(bindings.name.text)
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
for (const element of bindings.elements) {
|
|
123
|
+
named.set(element.name.text, (element.propertyName ?? element.name).text)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { named, namespaces }
|
|
127
|
+
}
|
|
128
|
+
|
|
90
129
|
/**
|
|
91
|
-
* Read
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
130
|
+
* Read a `plugins: [...]` entry. better-auth plugins are factory calls —
|
|
131
|
+
* `bearer()`, `twoFactor({ ... })`, `ban()`, or `plugins.bearer()` through a
|
|
132
|
+
* namespace import; non-call entries are ignored. `betterAuthExport` is the
|
|
133
|
+
* name better-auth exports the plugin under, and is absent for a plugin that
|
|
134
|
+
* came from somewhere else, which no policy here applies to.
|
|
95
135
|
*/
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
136
|
+
const readPluginEntry = (
|
|
137
|
+
el: ts.Expression,
|
|
138
|
+
bindings: PluginBindings
|
|
139
|
+
): { id: string; betterAuthExport?: string } | undefined => {
|
|
140
|
+
if (!ts.isCallExpression(el)) return undefined
|
|
141
|
+
const callee = el.expression
|
|
142
|
+
if (ts.isIdentifier(callee)) {
|
|
143
|
+
return {
|
|
144
|
+
id: callee.text,
|
|
145
|
+
betterAuthExport: bindings.named.get(callee.text),
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (
|
|
149
|
+
ts.isPropertyAccessExpression(callee) &&
|
|
150
|
+
ts.isIdentifier(callee.expression) &&
|
|
151
|
+
bindings.namespaces.has(callee.expression.text)
|
|
152
|
+
) {
|
|
153
|
+
return { id: callee.name.text, betterAuthExport: callee.name.text }
|
|
154
|
+
}
|
|
99
155
|
return undefined
|
|
100
156
|
}
|
|
101
157
|
|
|
@@ -128,6 +184,40 @@ const isInsideGlobalMiddlewareRegistration = (node: ts.Node): boolean => {
|
|
|
128
184
|
return false
|
|
129
185
|
}
|
|
130
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Refusal for better-auth's `admin()`, which pikku does not support.
|
|
189
|
+
*
|
|
190
|
+
* `admin()` authorizes its endpoints against a `user.role` column, so wiring it
|
|
191
|
+
* means running a second authorization model alongside pikku's scopes and
|
|
192
|
+
* keeping one projected onto the other — coarsely, since any single
|
|
193
|
+
* `admin:users:*` scope has to project to `role='admin'` and thereby unlocks
|
|
194
|
+
* every one of its endpoints underneath. Pikku dropped that projection: user
|
|
195
|
+
* management is `@pikku/addon-admin`'s scoped RPCs, and the one capability
|
|
196
|
+
* `admin()` had that nothing else did — refusing a session to a banned user —
|
|
197
|
+
* is `ban()`.
|
|
198
|
+
*
|
|
199
|
+
* Thrown rather than warned because the failure it prevents is silent. An app
|
|
200
|
+
* that drops `admin()` without wiring `ban()` keeps its ban columns and its ban
|
|
201
|
+
* UI, and simply stops enforcing bans.
|
|
202
|
+
*/
|
|
203
|
+
const UNSUPPORTED_ADMIN_PLUGIN = (sourceFile: string): string =>
|
|
204
|
+
`better-auth's admin() plugin is not supported by pikku (${sourceFile}).
|
|
205
|
+
|
|
206
|
+
It authorizes against a 'user.role' column, while pikku authorizes on scopes —
|
|
207
|
+
wiring both means one has to be projected onto the other, and the projection is
|
|
208
|
+
coarser than the scopes it stands in for.
|
|
209
|
+
|
|
210
|
+
Use ban() instead:
|
|
211
|
+
|
|
212
|
+
import { ban } from '@pikku/better-auth'
|
|
213
|
+
betterAuth({ plugins: [ban()] })
|
|
214
|
+
|
|
215
|
+
ban() keeps the part of admin() that pikku cannot supply from outside
|
|
216
|
+
better-auth — the banned/banReason/banExpires columns and the session hook that
|
|
217
|
+
refuses a banned user a session. Everything else admin() offered is already
|
|
218
|
+
scoped RPCs in @pikku/addon-admin: list, create, ban, remove, revoke sessions
|
|
219
|
+
and set password.`
|
|
220
|
+
|
|
131
221
|
/**
|
|
132
222
|
* Detects `pikkuBetterAuth((services) => betterAuth({...}))` calls.
|
|
133
223
|
*
|
|
@@ -304,10 +394,15 @@ export const addAuth: AddWiring = (logger, node, _checker, state) => {
|
|
|
304
394
|
|
|
305
395
|
const plugins = readArrayProp(config, 'plugins')
|
|
306
396
|
if (plugins) {
|
|
397
|
+
const bindings = readPluginBindings(node.getSourceFile())
|
|
307
398
|
for (const el of plugins.elements) {
|
|
308
|
-
const
|
|
309
|
-
if (
|
|
310
|
-
|
|
399
|
+
const entry = readPluginEntry(el, bindings)
|
|
400
|
+
if (!entry) continue
|
|
401
|
+
if (entry.betterAuthExport === 'admin') {
|
|
402
|
+
throw new Error(UNSUPPORTED_ADMIN_PLUGIN(sourceFile))
|
|
403
|
+
}
|
|
404
|
+
if (!state.auth.plugins.includes(entry.id)) {
|
|
405
|
+
state.auth.plugins.push(entry.id)
|
|
311
406
|
}
|
|
312
407
|
}
|
|
313
408
|
}
|
package/src/add/add-cli.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
extractFunctionName,
|
|
12
12
|
makeContextBasedId,
|
|
13
13
|
} from '../utils/extract-function-name.js'
|
|
14
|
+
import { resolveCoreType } from '../utils/resolve-core-type.js'
|
|
14
15
|
import { resolveMiddleware } from '../utils/middleware.js'
|
|
15
16
|
import { resolveFunctionMeta } from '../utils/resolve-function-meta.js'
|
|
16
17
|
import { extractWireNames } from '../utils/post-process.js'
|
|
@@ -825,8 +826,11 @@ function extractEnumFromConfigType(
|
|
|
825
826
|
_inspectorOptions: InspectorOptions
|
|
826
827
|
): string[] | null {
|
|
827
828
|
// Look for Config type in typesLookup
|
|
828
|
-
const
|
|
829
|
-
|
|
829
|
+
const configType = resolveCoreType(
|
|
830
|
+
inspectorState.typesLookup,
|
|
831
|
+
inspectorState.configTypeImportMap
|
|
832
|
+
)
|
|
833
|
+
if (!configType) {
|
|
830
834
|
// Only warn once per CLI file to avoid spamming logs
|
|
831
835
|
if (!configTypeWarningShown.has('missing-config-type')) {
|
|
832
836
|
configTypeWarningShown.add('missing-config-type')
|
|
@@ -838,16 +842,6 @@ function extractEnumFromConfigType(
|
|
|
838
842
|
return null
|
|
839
843
|
}
|
|
840
844
|
|
|
841
|
-
// Use the first Config type (there should only be one)
|
|
842
|
-
const configType = configTypes[0]
|
|
843
|
-
if (!configType) {
|
|
844
|
-
if (!configTypeWarningShown.has('undefined-config-type')) {
|
|
845
|
-
configTypeWarningShown.add('undefined-config-type')
|
|
846
|
-
logger.warn(`Config type is undefined in typesLookup.`)
|
|
847
|
-
}
|
|
848
|
-
return null
|
|
849
|
-
}
|
|
850
|
-
|
|
851
845
|
// Extract enum from the property
|
|
852
846
|
return extractEnumFromPropertyType(configType, propertyName, typeChecker)
|
|
853
847
|
}
|
package/src/index.ts
CHANGED