@pikku/inspector 0.12.50 → 0.12.54
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 +743 -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 +199 -8
- 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 +216 -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/add/add-workflow.js +12 -1
- package/dist/error-codes.d.ts +17 -1
- package/dist/error-codes.js +23 -0
- package/dist/index.d.ts +1 -1
- package/dist/inspector.js +33 -1
- package/dist/types.d.ts +55 -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/check-secret-sinks.d.ts +31 -0
- package/dist/utils/check-secret-sinks.js +117 -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 +246 -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/single-declaration.d.ts +32 -0
- package/dist/utils/single-declaration.js +43 -0
- package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
- package/dist/utils/validate-exposed-functions-gated.js +93 -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/utils/workflow/dsl/extract-dsl-workflow.d.ts +6 -0
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +48 -1
- package/dist/utils/workflow/dsl/patterns.d.ts +9 -4
- package/dist/utils/workflow/dsl/patterns.js +26 -4
- 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 +273 -11
- 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 +382 -0
- package/src/add/add-personas.ts +285 -0
- package/src/add/add-scenario-step.test.ts +87 -5
- package/src/add/add-scope.test.ts +103 -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 +297 -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/add-workflow.ts +21 -1
- package/src/add/addon-bans.test.ts +4 -4
- package/src/add/allow-escape-hatches.test.ts +144 -0
- package/src/add/audit-meta.test.ts +75 -0
- package/src/add/config-value-metadata.test.ts +11 -11
- package/src/add/secret-sink-check.test.ts +211 -0
- package/src/add/wire-name-literal.test.ts +2 -2
- package/src/error-codes.ts +26 -0
- package/src/index.ts +4 -1
- package/src/inspector.ts +39 -0
- package/src/types.ts +56 -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/check-secret-sinks.ts +145 -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 +313 -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/single-declaration.ts +57 -0
- package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
- package/src/utils/validate-exposed-functions-gated.ts +103 -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/utils/workflow/dsl/extract-dsl-workflow.ts +57 -0
- package/src/utils/workflow/dsl/patterns.ts +33 -4
- package/src/visit.ts +4 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { strict as assert } from 'assert'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { inspect } from '../inspector.js'
|
|
7
|
+
import type { ErrorCode } from '../error-codes.js'
|
|
8
|
+
import type { InspectorLogger } from '../types.js'
|
|
9
|
+
|
|
10
|
+
const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
|
|
11
|
+
({
|
|
12
|
+
debug: () => {},
|
|
13
|
+
info: () => {},
|
|
14
|
+
warn: () => {},
|
|
15
|
+
error: () => {},
|
|
16
|
+
diagnostic: ({ code, message }) => {
|
|
17
|
+
criticals.push({ code, message })
|
|
18
|
+
},
|
|
19
|
+
critical: (code: ErrorCode, message: string) => {
|
|
20
|
+
criticals.push({ code, message })
|
|
21
|
+
},
|
|
22
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
23
|
+
}) satisfies InspectorLogger
|
|
24
|
+
|
|
25
|
+
const inspectSource = async (source: string) => {
|
|
26
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-role-'))
|
|
27
|
+
const file = join(rootDir, 'roles.ts')
|
|
28
|
+
await writeFile(file, source)
|
|
29
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
30
|
+
try {
|
|
31
|
+
const state = await inspect(makeLogger(criticals), [file], { rootDir })
|
|
32
|
+
return { state, criticals, file }
|
|
33
|
+
} finally {
|
|
34
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Inspects several files at once, keyed by basename. */
|
|
39
|
+
const inspectSources = async (sources: Record<string, string>) => {
|
|
40
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-role-multi-'))
|
|
41
|
+
const files: Record<string, string> = {}
|
|
42
|
+
for (const [name, source] of Object.entries(sources)) {
|
|
43
|
+
files[name] = join(rootDir, name)
|
|
44
|
+
await writeFile(files[name]!, source)
|
|
45
|
+
}
|
|
46
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
47
|
+
try {
|
|
48
|
+
const state = await inspect(makeLogger(criticals), Object.values(files), {
|
|
49
|
+
rootDir,
|
|
50
|
+
})
|
|
51
|
+
return { state, criticals, files }
|
|
52
|
+
} finally {
|
|
53
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const IMPORTS = [
|
|
58
|
+
"import { defineScope } from '@pikku/core/scope'",
|
|
59
|
+
"import { defineSystemRole } from '@pikku/core/role'",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
const withScopes = (...lines: string[]) =>
|
|
63
|
+
[
|
|
64
|
+
...IMPORTS,
|
|
65
|
+
'defineScope({',
|
|
66
|
+
' catalogue: { scopes: { read: {} } },',
|
|
67
|
+
' orders: { scopes: { create: {} } },',
|
|
68
|
+
' admin: { scopes: { users: { scopes: { ban: {} } } } },',
|
|
69
|
+
'})',
|
|
70
|
+
...lines,
|
|
71
|
+
].join('\n')
|
|
72
|
+
|
|
73
|
+
describe('addSystemRole inspector', () => {
|
|
74
|
+
test('extracts a role with its scopes', async () => {
|
|
75
|
+
const { state, criticals } = await inspectSource(
|
|
76
|
+
withScopes(
|
|
77
|
+
'defineSystemRole({',
|
|
78
|
+
' buyer: {',
|
|
79
|
+
" displayName: 'Buyer',",
|
|
80
|
+
" description: 'Browses and orders',",
|
|
81
|
+
" scopes: ['catalogue:read', 'orders:create'],",
|
|
82
|
+
' },',
|
|
83
|
+
'})'
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
assert.deepEqual(criticals, [])
|
|
88
|
+
assert.equal(state.systemRoles.definitions.length, 1)
|
|
89
|
+
const role = state.systemRoles.definitions[0]!
|
|
90
|
+
assert.equal(role.name, 'buyer')
|
|
91
|
+
assert.equal(role.displayName, 'Buyer')
|
|
92
|
+
assert.equal(role.description, 'Browses and orders')
|
|
93
|
+
assert.deepEqual(role.scopes, ['catalogue:read', 'orders:create'])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('extracts several roles from one call', async () => {
|
|
97
|
+
const { state, criticals } = await inspectSource(
|
|
98
|
+
withScopes(
|
|
99
|
+
'defineSystemRole({',
|
|
100
|
+
" buyer: { scopes: ['catalogue:read'] },",
|
|
101
|
+
" admin: { scopes: ['admin'] },",
|
|
102
|
+
'})'
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
assert.deepEqual(criticals, [])
|
|
107
|
+
assert.deepEqual(
|
|
108
|
+
state.systemRoles.definitions.map((r) => r.name),
|
|
109
|
+
['buyer', 'admin']
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('extracts roles from several calls in one file', async () => {
|
|
114
|
+
const { state } = await inspectSource(
|
|
115
|
+
withScopes(
|
|
116
|
+
"defineSystemRole({ buyer: { scopes: ['catalogue:read'] } })",
|
|
117
|
+
"defineSystemRole({ admin: { scopes: ['admin'] } })"
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
assert.deepEqual(
|
|
122
|
+
state.systemRoles.definitions.map((r) => r.name).sort(),
|
|
123
|
+
['admin', 'buyer']
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// Roles follow scopes: a project scaffolding user-admin has a generated
|
|
128
|
+
// declaration beside its own, so every file's roles have to survive.
|
|
129
|
+
test('declarations in separate files all survive', async () => {
|
|
130
|
+
const { state, criticals } = await inspectSources({
|
|
131
|
+
'roles.ts': withScopes(
|
|
132
|
+
"defineSystemRole({ buyer: { scopes: ['catalogue:read'] } })"
|
|
133
|
+
),
|
|
134
|
+
'more-roles.ts': [
|
|
135
|
+
"import { defineSystemRole } from '@pikku/core/role'",
|
|
136
|
+
"defineSystemRole({ admin: { scopes: ['admin'] } })",
|
|
137
|
+
].join('\n'),
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
assert.deepEqual(
|
|
141
|
+
criticals.filter((c) => /Only one defineSystemRole/.test(c.message)),
|
|
142
|
+
[],
|
|
143
|
+
`expected no duplicate critical, got ${JSON.stringify(criticals)}`
|
|
144
|
+
)
|
|
145
|
+
assert.deepEqual(
|
|
146
|
+
state.systemRoles.definitions.map((r) => r.name).sort(),
|
|
147
|
+
['admin', 'buyer']
|
|
148
|
+
)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// A generated file is not the one place a person adds a role to, so it neither
|
|
152
|
+
// takes the single slot nor collides with the app's own declaration.
|
|
153
|
+
test('a generated declaration neither claims the slot nor conflicts', async () => {
|
|
154
|
+
const { state, criticals } = await inspectSources({
|
|
155
|
+
'roles.gen.ts': [
|
|
156
|
+
"import { defineSystemRole } from '@pikku/core/role'",
|
|
157
|
+
'defineSystemRole({ admin: { scopes: [] } })',
|
|
158
|
+
].join('\n'),
|
|
159
|
+
'roles.ts': withScopes(
|
|
160
|
+
"defineSystemRole({ buyer: { scopes: ['catalogue:read'] } })"
|
|
161
|
+
),
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
assert.deepEqual(
|
|
165
|
+
criticals.filter(
|
|
166
|
+
(c) => c.code === ErrorCode.DUPLICATE_SYSTEM_ROLE_DEFINITION
|
|
167
|
+
),
|
|
168
|
+
[],
|
|
169
|
+
`expected no duplicate critical, got ${JSON.stringify(criticals)}`
|
|
170
|
+
)
|
|
171
|
+
assert.deepEqual(state.systemRoles.definitions.map((r) => r.name).sort(), [
|
|
172
|
+
'admin',
|
|
173
|
+
'buyer',
|
|
174
|
+
])
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('a role granting nothing is legitimate, said explicitly', async () => {
|
|
178
|
+
const { state, criticals } = await inspectSource(
|
|
179
|
+
withScopes('defineSystemRole({', ' guest: { scopes: [] },', '})')
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
assert.deepEqual(criticals, [])
|
|
183
|
+
assert.deepEqual(state.systemRoles.definitions[0]!.scopes, [])
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// Omitting `scopes` is not the same as declaring none — one is a decision,
|
|
187
|
+
// the other is a role nobody finished writing.
|
|
188
|
+
test('omitting scopes entirely is refused', async () => {
|
|
189
|
+
const { state, criticals } = await inspectSource(
|
|
190
|
+
withScopes('defineSystemRole({', " guest: { description: 'x' },", '})')
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
assert.equal(state.systemRoles.definitions.length, 0)
|
|
194
|
+
assert.ok(criticals.some((c) => /has no 'scopes'/.test(c.message)))
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('a name containing the scope separator is refused', async () => {
|
|
198
|
+
const { state, criticals } = await inspectSource(
|
|
199
|
+
withScopes('defineSystemRole({', " 'admin:users': { scopes: [] },", '})')
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
assert.equal(state.systemRoles.definitions.length, 0)
|
|
203
|
+
assert.ok(criticals.some((c) => /separator/.test(c.message)))
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// Scopes are read by AST, so a computed entry cannot be checked against the
|
|
207
|
+
// declared set — accepting it silently would let a role grant anything.
|
|
208
|
+
test('a computed scope entry is refused', async () => {
|
|
209
|
+
const { criticals } = await inspectSource(
|
|
210
|
+
withScopes(
|
|
211
|
+
'const s = "catalogue:read"',
|
|
212
|
+
'defineSystemRole({',
|
|
213
|
+
' buyer: { scopes: [s] },',
|
|
214
|
+
'})'
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
assert.ok(criticals.some((c) => /not a string literal/.test(c.message)))
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('`as const` does not hide the declaration', async () => {
|
|
222
|
+
const { state, criticals } = await inspectSource(
|
|
223
|
+
withScopes(
|
|
224
|
+
'defineSystemRole({',
|
|
225
|
+
" buyer: { scopes: ['catalogue:read'] },",
|
|
226
|
+
'} as const)'
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
assert.deepEqual(criticals, [])
|
|
231
|
+
assert.equal(state.systemRoles.definitions.length, 1)
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
describe('validateSystemRoleScopes', () => {
|
|
236
|
+
test('accepts declared scopes, including an intermediate node', async () => {
|
|
237
|
+
const { criticals } = await inspectSource(
|
|
238
|
+
withScopes(
|
|
239
|
+
'defineSystemRole({',
|
|
240
|
+
" admin: { scopes: ['admin', 'admin:users:ban'] },",
|
|
241
|
+
'})'
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
assert.deepEqual(criticals, [])
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('accepts a subtree wildcard whose node exists', async () => {
|
|
249
|
+
const { criticals } = await inspectSource(
|
|
250
|
+
withScopes(
|
|
251
|
+
'defineSystemRole({',
|
|
252
|
+
" admin: { scopes: ['admin:*'] },",
|
|
253
|
+
'})'
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
assert.deepEqual(criticals, [])
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
// The failure this exists to prevent: no function checks `orders:refund`, so
|
|
261
|
+
// a persona holding the role passes every call and the boundary the role
|
|
262
|
+
// describes is never exercised.
|
|
263
|
+
test('an undeclared scope fails the build and lists what is available', async () => {
|
|
264
|
+
const { criticals } = await inspectSource(
|
|
265
|
+
withScopes(
|
|
266
|
+
'defineSystemRole({',
|
|
267
|
+
" buyer: { scopes: ['orders:refund'] },",
|
|
268
|
+
'})'
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
const critical = criticals.find((c) => /orders:refund/.test(c.message))
|
|
273
|
+
assert.ok(critical, `expected a critical, got ${JSON.stringify(criticals)}`)
|
|
274
|
+
assert.match(critical!.message, /is not declared/)
|
|
275
|
+
assert.match(critical!.message, /catalogue:read/)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
test('a subtree wildcard on an undeclared node fails', async () => {
|
|
279
|
+
const { criticals } = await inspectSource(
|
|
280
|
+
withScopes(
|
|
281
|
+
'defineSystemRole({',
|
|
282
|
+
" buyer: { scopes: ['nonsense:*'] },",
|
|
283
|
+
'})'
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
assert.ok(criticals.some((c) => /nonsense:\*/.test(c.message)))
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('the bare wildcard is refused — it hides what the role confers', async () => {
|
|
291
|
+
const { criticals } = await inspectSource(
|
|
292
|
+
withScopes('defineSystemRole({', " god: { scopes: ['*'] },", '})')
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
assert.ok(criticals.some((c) => /bare wildcard/.test(c.message)))
|
|
296
|
+
})
|
|
297
|
+
})
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import * as ts from 'typescript'
|
|
2
|
+
import { getPropertyValue } from '../utils/get-property-value.js'
|
|
3
|
+
import type { AddWiring } from '../types.js'
|
|
4
|
+
import { ErrorCode } from '../error-codes.js'
|
|
5
|
+
|
|
6
|
+
const SEPARATOR = ':'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Unwraps `x as const` / `x satisfies T` so a cast declaration is still
|
|
10
|
+
* extracted rather than silently skipped.
|
|
11
|
+
*/
|
|
12
|
+
const unwrapAs = (node: ts.Expression): ts.Expression =>
|
|
13
|
+
ts.isAsExpression(node) || ts.isSatisfiesExpression(node)
|
|
14
|
+
? unwrapAs(node.expression)
|
|
15
|
+
: node
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Reads a `scopes: [...]` array of string literals.
|
|
19
|
+
*
|
|
20
|
+
* Returns `undefined` rather than an empty array when the property is absent
|
|
21
|
+
* or unreadable, so the caller can tell "declared no scopes" from "we could
|
|
22
|
+
* not see the scopes" — the first is a legitimate placeholder role, the second
|
|
23
|
+
* would silently produce one.
|
|
24
|
+
*/
|
|
25
|
+
const extractScopes = (
|
|
26
|
+
obj: ts.ObjectLiteralExpression,
|
|
27
|
+
roleName: string,
|
|
28
|
+
logger: Parameters<AddWiring>[0]
|
|
29
|
+
): string[] | undefined => {
|
|
30
|
+
const prop = obj.properties.find(
|
|
31
|
+
(p) =>
|
|
32
|
+
ts.isPropertyAssignment(p) &&
|
|
33
|
+
(ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) &&
|
|
34
|
+
p.name.text === 'scopes'
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if (!prop) {
|
|
38
|
+
logger.critical(
|
|
39
|
+
ErrorCode.INVALID_VALUE,
|
|
40
|
+
`System role '${roleName}' has no 'scopes'. A role that grants nothing should say so with 'scopes: []'.`
|
|
41
|
+
)
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (
|
|
46
|
+
!ts.isPropertyAssignment(prop) ||
|
|
47
|
+
!ts.isArrayLiteralExpression(prop.initializer)
|
|
48
|
+
) {
|
|
49
|
+
logger.critical(
|
|
50
|
+
ErrorCode.INVALID_VALUE,
|
|
51
|
+
`System role '${roleName}' must declare 'scopes' as an array literal.`
|
|
52
|
+
)
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const scopes: string[] = []
|
|
57
|
+
for (const element of prop.initializer.elements) {
|
|
58
|
+
const unwrapped = unwrapAs(element)
|
|
59
|
+
if (!ts.isStringLiteral(unwrapped)) {
|
|
60
|
+
logger.critical(
|
|
61
|
+
ErrorCode.INVALID_VALUE,
|
|
62
|
+
`System role '${roleName}' lists a scope that is not a string literal. ` +
|
|
63
|
+
`Scopes are extracted by AST, so a computed value cannot be checked against the declared set.`
|
|
64
|
+
)
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
scopes.push(unwrapped.text)
|
|
68
|
+
}
|
|
69
|
+
return scopes
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const addSystemRole: AddWiring = (
|
|
73
|
+
logger,
|
|
74
|
+
node,
|
|
75
|
+
_checker,
|
|
76
|
+
state,
|
|
77
|
+
_options
|
|
78
|
+
) => {
|
|
79
|
+
if (!ts.isCallExpression(node)) {
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const expression = node.expression
|
|
84
|
+
if (!ts.isIdentifier(expression) || expression.text !== 'defineSystemRole') {
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const firstArg = node.arguments[0]
|
|
89
|
+
if (!firstArg) {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const unwrapped = unwrapAs(firstArg)
|
|
94
|
+
if (!ts.isObjectLiteralExpression(unwrapped)) {
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const sourceFile = node.getSourceFile().fileName
|
|
99
|
+
|
|
100
|
+
for (const prop of unwrapped.properties) {
|
|
101
|
+
if (!ts.isPropertyAssignment(prop)) {
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let name: string | undefined
|
|
106
|
+
if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) {
|
|
107
|
+
name = prop.name.text
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (name === undefined) {
|
|
111
|
+
logger.critical(
|
|
112
|
+
ErrorCode.NON_LITERAL_WIRE_NAME,
|
|
113
|
+
'A system role is declared with a key that is not a literal.'
|
|
114
|
+
)
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (name.length === 0) {
|
|
119
|
+
logger.critical(
|
|
120
|
+
ErrorCode.INVALID_VALUE,
|
|
121
|
+
'A system role is declared with an empty name.'
|
|
122
|
+
)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (name.includes(SEPARATOR)) {
|
|
127
|
+
logger.critical(
|
|
128
|
+
ErrorCode.INVALID_VALUE,
|
|
129
|
+
`System role '${name}' contains the '${SEPARATOR}' separator. ` +
|
|
130
|
+
`'${SEPARATOR}' delimits scope ids; a role named like a scope reads as one.`
|
|
131
|
+
)
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!ts.isObjectLiteralExpression(prop.initializer)) {
|
|
136
|
+
logger.critical(
|
|
137
|
+
ErrorCode.INVALID_VALUE,
|
|
138
|
+
`System role '${name}' must be an object literal.`
|
|
139
|
+
)
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const scopes = extractScopes(prop.initializer, name, logger)
|
|
144
|
+
if (scopes === undefined) {
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const displayName = getPropertyValue(prop.initializer, 'displayName') as
|
|
149
|
+
| string
|
|
150
|
+
| null
|
|
151
|
+
const description = getPropertyValue(prop.initializer, 'description') as
|
|
152
|
+
| string
|
|
153
|
+
| null
|
|
154
|
+
|
|
155
|
+
state.systemRoles.files.add(sourceFile)
|
|
156
|
+
state.systemRoles.definitions.push({
|
|
157
|
+
name,
|
|
158
|
+
displayName: displayName || undefined,
|
|
159
|
+
description: description || undefined,
|
|
160
|
+
scopes,
|
|
161
|
+
sourceFile,
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
}
|
package/src/add/add-variable.ts
CHANGED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, test, beforeEach } from 'node:test'
|
|
3
|
+
import * as ts from 'typescript'
|
|
4
|
+
import { addWireAddon } from './add-wire-addon.js'
|
|
5
|
+
|
|
6
|
+
let state: any
|
|
7
|
+
|
|
8
|
+
const logger = {
|
|
9
|
+
debug: () => {},
|
|
10
|
+
info: () => {},
|
|
11
|
+
warn: () => {},
|
|
12
|
+
error: () => {},
|
|
13
|
+
} as any
|
|
14
|
+
|
|
15
|
+
/** Parses a source snippet and runs addWireAddon over every call expression. */
|
|
16
|
+
const inspect = (source: string) => {
|
|
17
|
+
const file = ts.createSourceFile(
|
|
18
|
+
'wiring.ts',
|
|
19
|
+
source,
|
|
20
|
+
ts.ScriptTarget.Latest,
|
|
21
|
+
true
|
|
22
|
+
)
|
|
23
|
+
const visit = (node: ts.Node) => {
|
|
24
|
+
addWireAddon(node, state, logger)
|
|
25
|
+
ts.forEachChild(node, visit)
|
|
26
|
+
}
|
|
27
|
+
visit(file)
|
|
28
|
+
return state.rpc.wireAddonDeclarations
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
state = {
|
|
33
|
+
rpc: {
|
|
34
|
+
wireAddonDeclarations: new Map(),
|
|
35
|
+
usedAddons: new Set(),
|
|
36
|
+
wireAddonFiles: new Set(),
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('addWireAddon', () => {
|
|
42
|
+
test('captures the addon gates alongside its plumbing', () => {
|
|
43
|
+
const declarations = inspect(`
|
|
44
|
+
wireAddon({
|
|
45
|
+
name: 'console',
|
|
46
|
+
package: '@pikku/addon-console',
|
|
47
|
+
rpcEndpoint: '/rpc',
|
|
48
|
+
auth: true,
|
|
49
|
+
tags: ['admin', 'internal'],
|
|
50
|
+
scopes: ['admin'],
|
|
51
|
+
})
|
|
52
|
+
`)
|
|
53
|
+
|
|
54
|
+
assert.deepEqual(declarations.get('console'), {
|
|
55
|
+
package: '@pikku/addon-console',
|
|
56
|
+
rpcEndpoint: '/rpc',
|
|
57
|
+
mcp: undefined,
|
|
58
|
+
auth: true,
|
|
59
|
+
tags: ['admin', 'internal'],
|
|
60
|
+
scopes: ['admin'],
|
|
61
|
+
secretOverrides: undefined,
|
|
62
|
+
variableOverrides: undefined,
|
|
63
|
+
credentialOverrides: undefined,
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('leaves the gates undefined when none are declared', () => {
|
|
68
|
+
const declarations = inspect(`
|
|
69
|
+
wireAddon({ name: 'console', package: '@pikku/addon-console' })
|
|
70
|
+
`)
|
|
71
|
+
|
|
72
|
+
const declaration = declarations.get('console')
|
|
73
|
+
assert.equal(declaration.auth, undefined)
|
|
74
|
+
assert.equal(declaration.tags, undefined)
|
|
75
|
+
assert.equal(declaration.scopes, undefined)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('records auth: false rather than dropping it', () => {
|
|
79
|
+
const declarations = inspect(`
|
|
80
|
+
wireAddon({ name: 'console', package: '@x/y', auth: false })
|
|
81
|
+
`)
|
|
82
|
+
|
|
83
|
+
assert.equal(declarations.get('console').auth, false)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('drops a scopes array that is not statically knowable', () => {
|
|
87
|
+
// A partial list would read as the complete set of gates, so an array with
|
|
88
|
+
// any non-literal entry is reported as unknown instead.
|
|
89
|
+
const declarations = inspect(`
|
|
90
|
+
wireAddon({ name: 'console', package: '@x/y', scopes: ['admin', ...EXTRA] })
|
|
91
|
+
`)
|
|
92
|
+
|
|
93
|
+
assert.equal(declarations.get('console').scopes, undefined)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('drops a scopes value that is a reference rather than an array', () => {
|
|
97
|
+
const declarations = inspect(`
|
|
98
|
+
wireAddon({ name: 'console', package: '@x/y', scopes: ADMIN_SCOPES })
|
|
99
|
+
`)
|
|
100
|
+
|
|
101
|
+
assert.equal(declarations.get('console').scopes, undefined)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('keeps an explicitly empty scopes array distinct from an absent one', () => {
|
|
105
|
+
const declarations = inspect(`
|
|
106
|
+
wireAddon({ name: 'console', package: '@x/y', scopes: [] })
|
|
107
|
+
`)
|
|
108
|
+
|
|
109
|
+
assert.deepEqual(declarations.get('console').scopes, [])
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import * as ts from 'typescript'
|
|
2
2
|
import type { InspectorState, InspectorLogger } from '../types.js'
|
|
3
3
|
|
|
4
|
+
function parseStringArray(node: ts.Expression): string[] | undefined {
|
|
5
|
+
if (!ts.isArrayLiteralExpression(node)) return undefined
|
|
6
|
+
const values: string[] = []
|
|
7
|
+
for (const element of node.elements) {
|
|
8
|
+
// A non-literal entry (a spread, a const reference) is not statically
|
|
9
|
+
// knowable. Dropping the whole array rather than a silent partial keeps a
|
|
10
|
+
// consumer from reading a short list as the complete set of gates.
|
|
11
|
+
if (!ts.isStringLiteral(element)) return undefined
|
|
12
|
+
values.push(element.text)
|
|
13
|
+
}
|
|
14
|
+
return values
|
|
15
|
+
}
|
|
16
|
+
|
|
4
17
|
function parseStringRecord(
|
|
5
18
|
obj: ts.ObjectLiteralExpression
|
|
6
19
|
): Record<string, string> {
|
|
@@ -41,6 +54,9 @@ export function addWireAddon(
|
|
|
41
54
|
let pkg: string | undefined
|
|
42
55
|
let rpcEndpoint: string | undefined
|
|
43
56
|
let mcp: boolean | undefined
|
|
57
|
+
let auth: boolean | undefined
|
|
58
|
+
let tags: string[] | undefined
|
|
59
|
+
let scopes: string[] | undefined
|
|
44
60
|
let secretOverrides: Record<string, string> | undefined
|
|
45
61
|
let variableOverrides: Record<string, string> | undefined
|
|
46
62
|
let credentialOverrides: Record<string, string> | undefined
|
|
@@ -61,6 +77,16 @@ export function addWireAddon(
|
|
|
61
77
|
prop.initializer.kind === ts.SyntaxKind.FalseKeyword)
|
|
62
78
|
) {
|
|
63
79
|
mcp = prop.initializer.kind === ts.SyntaxKind.TrueKeyword
|
|
80
|
+
} else if (
|
|
81
|
+
key === 'auth' &&
|
|
82
|
+
(prop.initializer.kind === ts.SyntaxKind.TrueKeyword ||
|
|
83
|
+
prop.initializer.kind === ts.SyntaxKind.FalseKeyword)
|
|
84
|
+
) {
|
|
85
|
+
auth = prop.initializer.kind === ts.SyntaxKind.TrueKeyword
|
|
86
|
+
} else if (key === 'tags') {
|
|
87
|
+
tags = parseStringArray(prop.initializer)
|
|
88
|
+
} else if (key === 'scopes') {
|
|
89
|
+
scopes = parseStringArray(prop.initializer)
|
|
64
90
|
} else if (
|
|
65
91
|
key === 'secretOverrides' &&
|
|
66
92
|
ts.isObjectLiteralExpression(prop.initializer)
|
|
@@ -86,6 +112,9 @@ export function addWireAddon(
|
|
|
86
112
|
package: pkg,
|
|
87
113
|
rpcEndpoint,
|
|
88
114
|
mcp,
|
|
115
|
+
auth,
|
|
116
|
+
tags,
|
|
117
|
+
scopes,
|
|
89
118
|
secretOverrides,
|
|
90
119
|
variableOverrides,
|
|
91
120
|
credentialOverrides,
|
package/src/add/add-workflow.ts
CHANGED
|
@@ -229,7 +229,13 @@ function getWorkflowInvocations(
|
|
|
229
229
|
* Inspector for pikkuWorkflowFunc(), pikkuWorkflowComplexFunc() and
|
|
230
230
|
* pikkuScenario() calls. Detects workflow registration and extracts metadata.
|
|
231
231
|
*/
|
|
232
|
-
export const addWorkflow: AddWiring = (
|
|
232
|
+
export const addWorkflow: AddWiring = (
|
|
233
|
+
logger,
|
|
234
|
+
node,
|
|
235
|
+
checker,
|
|
236
|
+
state,
|
|
237
|
+
options
|
|
238
|
+
) => {
|
|
233
239
|
if (!ts.isCallExpression(node)) {
|
|
234
240
|
return
|
|
235
241
|
}
|
|
@@ -246,6 +252,17 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
|
|
|
246
252
|
if (expression.text === 'pikkuWorkflowFunc') {
|
|
247
253
|
wrapperType = 'dsl'
|
|
248
254
|
} else if (expression.text === 'pikkuWorkflowComplexFunc') {
|
|
255
|
+
if (!options.allow?.complexWorkflows) {
|
|
256
|
+
logger.critical(
|
|
257
|
+
ErrorCode.COMPLEX_WORKFLOW_NOT_ALLOWED,
|
|
258
|
+
`'${expression.text}' is used here, but this project has not enabled complex workflows. ` +
|
|
259
|
+
`Their inline steps cannot be serialized, so they are absent from the workflow graph and ` +
|
|
260
|
+
`cannot be replayed or migrated across a definition change. Express the workflow with ` +
|
|
261
|
+
`pikkuWorkflowFunc and the step DSL, or set \`allow: { complexWorkflows: true }\` in ` +
|
|
262
|
+
`pikku.config.json if the project accepts that trade.`
|
|
263
|
+
)
|
|
264
|
+
return
|
|
265
|
+
}
|
|
249
266
|
wrapperType = 'complex'
|
|
250
267
|
} else if (expression.text === 'pikkuScenario') {
|
|
251
268
|
// A scenario is a complex workflow whose steps run as actors over the
|
|
@@ -351,6 +368,7 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
|
|
|
351
368
|
let steps: WorkflowStepMeta[] = []
|
|
352
369
|
let context: WorkflowContext | undefined = undefined
|
|
353
370
|
let dsl: boolean | undefined = undefined
|
|
371
|
+
let asserts: boolean | undefined = undefined
|
|
354
372
|
|
|
355
373
|
// Try DSL workflow extraction first
|
|
356
374
|
// Pass the whole CallExpression node so findWorkflowFunction can find the arrow function
|
|
@@ -362,6 +380,7 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
|
|
|
362
380
|
// Extraction succeeded
|
|
363
381
|
steps = result.steps
|
|
364
382
|
context = result.context
|
|
383
|
+
asserts = result.asserts
|
|
365
384
|
|
|
366
385
|
// Check if workflow contains inline steps (non-serializable)
|
|
367
386
|
if (hasInlineSteps(steps)) {
|
|
@@ -449,6 +468,7 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
|
|
|
449
468
|
expose,
|
|
450
469
|
skip,
|
|
451
470
|
scenario: wrapperType === 'scenario' ? true : undefined,
|
|
471
|
+
asserts,
|
|
452
472
|
actors: actorNames.length > 0 ? actorNames : undefined,
|
|
453
473
|
}
|
|
454
474
|
|