@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.
Files changed (116) hide show
  1. package/CHANGELOG.md +743 -0
  2. package/OPTIMIZATION-PLAN.md +9 -3
  3. package/dist/add/add-auth.d.ts +1 -1
  4. package/dist/add/add-auth.js +1 -1
  5. package/dist/add/add-cli.js +7 -0
  6. package/dist/add/add-credential.js +1 -1
  7. package/dist/add/add-functions.js +199 -8
  8. package/dist/add/add-http-route.js +16 -6
  9. package/dist/add/add-middleware.js +46 -3
  10. package/dist/add/add-personas.d.ts +13 -0
  11. package/dist/add/add-personas.js +216 -0
  12. package/dist/add/add-scope.js +1 -1
  13. package/dist/add/add-secret.js +1 -1
  14. package/dist/add/add-system-role.d.ts +2 -0
  15. package/dist/add/add-system-role.js +102 -0
  16. package/dist/add/add-variable.js +1 -1
  17. package/dist/add/add-wire-addon.js +31 -0
  18. package/dist/add/add-workflow.js +12 -1
  19. package/dist/error-codes.d.ts +17 -1
  20. package/dist/error-codes.js +23 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/inspector.js +33 -1
  23. package/dist/types.d.ts +55 -2
  24. package/dist/utils/annotate-http-route-auth.d.ts +22 -0
  25. package/dist/utils/annotate-http-route-auth.js +48 -0
  26. package/dist/utils/check-secret-sinks.d.ts +31 -0
  27. package/dist/utils/check-secret-sinks.js +117 -0
  28. package/dist/utils/extract-function-name.js +13 -4
  29. package/dist/utils/extract-function-node.js +18 -1
  30. package/dist/utils/extract-secret-usage.d.ts +9 -0
  31. package/dist/utils/extract-secret-usage.js +61 -0
  32. package/dist/utils/literal-properties.d.ts +22 -0
  33. package/dist/utils/literal-properties.js +69 -0
  34. package/dist/utils/load-addon-functions-meta.js +26 -1
  35. package/dist/utils/permissions.js +8 -0
  36. package/dist/utils/post-process.d.ts +68 -3
  37. package/dist/utils/post-process.js +246 -13
  38. package/dist/utils/secret-alias-services.d.ts +8 -0
  39. package/dist/utils/secret-alias-services.js +45 -0
  40. package/dist/utils/secret-brokers.d.ts +2 -0
  41. package/dist/utils/secret-brokers.js +9 -0
  42. package/dist/utils/serialize-inspector-state.d.ts +10 -0
  43. package/dist/utils/serialize-inspector-state.js +18 -0
  44. package/dist/utils/serialize-middleware-groups-meta.js +3 -0
  45. package/dist/utils/single-declaration.d.ts +32 -0
  46. package/dist/utils/single-declaration.js +43 -0
  47. package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
  48. package/dist/utils/validate-exposed-functions-gated.js +93 -0
  49. package/dist/utils/validate-tags-resolve-to-middleware.d.ts +20 -0
  50. package/dist/utils/validate-tags-resolve-to-middleware.js +41 -0
  51. package/dist/utils/workflow/dsl/extract-dsl-workflow.d.ts +6 -0
  52. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +48 -1
  53. package/dist/utils/workflow/dsl/patterns.d.ts +9 -4
  54. package/dist/utils/workflow/dsl/patterns.js +26 -4
  55. package/dist/visit.js +4 -0
  56. package/package.json +3 -3
  57. package/src/add/add-auth.test.ts +5 -3
  58. package/src/add/add-auth.ts +1 -1
  59. package/src/add/add-cli.ts +8 -0
  60. package/src/add/add-credential.ts +1 -1
  61. package/src/add/add-functions.test.ts +263 -1
  62. package/src/add/add-functions.ts +273 -11
  63. package/src/add/add-http-route.ts +20 -7
  64. package/src/add/add-middleware.test.ts +196 -0
  65. package/src/add/add-middleware.ts +64 -4
  66. package/src/add/add-personas.test.ts +382 -0
  67. package/src/add/add-personas.ts +285 -0
  68. package/src/add/add-scenario-step.test.ts +87 -5
  69. package/src/add/add-scope.test.ts +103 -30
  70. package/src/add/add-scope.ts +1 -1
  71. package/src/add/add-secret.ts +1 -1
  72. package/src/add/add-subject-scenario-steps.test.ts +161 -0
  73. package/src/add/add-system-role.test.ts +297 -0
  74. package/src/add/add-system-role.ts +164 -0
  75. package/src/add/add-variable.ts +1 -1
  76. package/src/add/add-wire-addon.test.ts +111 -0
  77. package/src/add/add-wire-addon.ts +29 -0
  78. package/src/add/add-workflow.ts +21 -1
  79. package/src/add/addon-bans.test.ts +4 -4
  80. package/src/add/allow-escape-hatches.test.ts +144 -0
  81. package/src/add/audit-meta.test.ts +75 -0
  82. package/src/add/config-value-metadata.test.ts +11 -11
  83. package/src/add/secret-sink-check.test.ts +211 -0
  84. package/src/add/wire-name-literal.test.ts +2 -2
  85. package/src/error-codes.ts +26 -0
  86. package/src/index.ts +4 -1
  87. package/src/inspector.ts +39 -0
  88. package/src/types.ts +56 -2
  89. package/src/utils/annotate-http-route-auth.test.ts +163 -0
  90. package/src/utils/annotate-http-route-auth.ts +52 -0
  91. package/src/utils/check-secret-sinks.ts +145 -0
  92. package/src/utils/extract-function-name.ts +14 -4
  93. package/src/utils/extract-function-node.ts +18 -6
  94. package/src/utils/extract-secret-usage.test.ts +81 -0
  95. package/src/utils/extract-secret-usage.ts +73 -0
  96. package/src/utils/literal-properties.ts +97 -0
  97. package/src/utils/load-addon-functions-meta.ts +32 -2
  98. package/src/utils/permissions-shorthand.test.ts +79 -0
  99. package/src/utils/permissions.ts +7 -0
  100. package/src/utils/post-process.test.ts +133 -0
  101. package/src/utils/post-process.ts +313 -12
  102. package/src/utils/secret-alias-services.test.ts +146 -0
  103. package/src/utils/secret-alias-services.ts +58 -0
  104. package/src/utils/secret-brokers.test.ts +53 -0
  105. package/src/utils/secret-brokers.ts +11 -0
  106. package/src/utils/serialize-inspector-state.ts +28 -0
  107. package/src/utils/serialize-middleware-groups-meta.ts +3 -0
  108. package/src/utils/single-declaration.ts +57 -0
  109. package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
  110. package/src/utils/validate-exposed-functions-gated.ts +103 -0
  111. package/src/utils/validate-tags-resolve-to-middleware.test.ts +105 -0
  112. package/src/utils/validate-tags-resolve-to-middleware.ts +48 -0
  113. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +57 -0
  114. package/src/utils/workflow/dsl/patterns.ts +33 -4
  115. package/src/visit.ts +4 -0
  116. 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
+ }
@@ -1,7 +1,7 @@
1
1
  import { createAddKeyedWiring } from './add-keyed-wiring.js'
2
2
 
3
3
  export const addVariable = createAddKeyedWiring({
4
- functionName: 'wireVariable',
4
+ functionName: 'defineVariable',
5
5
  idField: 'variableId',
6
6
  label: 'Variable',
7
7
  schemaPrefix: 'VariableSchema',
@@ -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,
@@ -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 = (logger, node, checker, state) => {
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