@pikku/inspector 0.12.49 → 0.12.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +501 -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 +156 -7
  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 +210 -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/error-codes.d.ts +10 -1
  19. package/dist/error-codes.js +11 -0
  20. package/dist/index.d.ts +1 -1
  21. package/dist/inspector.js +33 -1
  22. package/dist/types.d.ts +46 -2
  23. package/dist/utils/annotate-http-route-auth.d.ts +22 -0
  24. package/dist/utils/annotate-http-route-auth.js +48 -0
  25. package/dist/utils/extract-function-name.js +13 -4
  26. package/dist/utils/extract-function-node.js +18 -1
  27. package/dist/utils/extract-secret-usage.d.ts +9 -0
  28. package/dist/utils/extract-secret-usage.js +61 -0
  29. package/dist/utils/literal-properties.d.ts +22 -0
  30. package/dist/utils/literal-properties.js +69 -0
  31. package/dist/utils/load-addon-functions-meta.js +26 -1
  32. package/dist/utils/permissions.js +8 -0
  33. package/dist/utils/post-process.d.ts +68 -3
  34. package/dist/utils/post-process.js +243 -13
  35. package/dist/utils/schema-generator.d.ts +24 -0
  36. package/dist/utils/schema-generator.js +118 -19
  37. package/dist/utils/secret-alias-services.d.ts +8 -0
  38. package/dist/utils/secret-alias-services.js +45 -0
  39. package/dist/utils/secret-brokers.d.ts +2 -0
  40. package/dist/utils/secret-brokers.js +9 -0
  41. package/dist/utils/serialize-inspector-state.d.ts +10 -0
  42. package/dist/utils/serialize-inspector-state.js +18 -0
  43. package/dist/utils/serialize-middleware-groups-meta.js +3 -0
  44. package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
  45. package/dist/utils/validate-exposed-functions-gated.js +85 -0
  46. package/dist/utils/validate-tags-resolve-to-middleware.d.ts +20 -0
  47. package/dist/utils/validate-tags-resolve-to-middleware.js +41 -0
  48. package/dist/visit.js +4 -0
  49. package/package.json +3 -3
  50. package/src/add/add-auth.test.ts +5 -3
  51. package/src/add/add-auth.ts +1 -1
  52. package/src/add/add-cli.ts +8 -0
  53. package/src/add/add-credential.ts +1 -1
  54. package/src/add/add-functions.test.ts +263 -1
  55. package/src/add/add-functions.ts +219 -9
  56. package/src/add/add-http-route.ts +20 -7
  57. package/src/add/add-middleware.test.ts +196 -0
  58. package/src/add/add-middleware.ts +64 -4
  59. package/src/add/add-personas.test.ts +268 -0
  60. package/src/add/add-personas.ts +276 -0
  61. package/src/add/add-scenario-step.test.ts +49 -3
  62. package/src/add/add-scope.test.ts +30 -30
  63. package/src/add/add-scope.ts +1 -1
  64. package/src/add/add-secret.ts +1 -1
  65. package/src/add/add-subject-scenario-steps.test.ts +161 -0
  66. package/src/add/add-system-role.test.ts +214 -0
  67. package/src/add/add-system-role.ts +164 -0
  68. package/src/add/add-variable.ts +1 -1
  69. package/src/add/add-wire-addon.test.ts +111 -0
  70. package/src/add/add-wire-addon.ts +29 -0
  71. package/src/add/addon-bans.test.ts +4 -4
  72. package/src/add/config-value-metadata.test.ts +11 -11
  73. package/src/add/wire-name-literal.test.ts +2 -2
  74. package/src/error-codes.ts +13 -0
  75. package/src/index.ts +4 -1
  76. package/src/inspector.ts +39 -0
  77. package/src/types.ts +47 -2
  78. package/src/utils/annotate-http-route-auth.test.ts +163 -0
  79. package/src/utils/annotate-http-route-auth.ts +52 -0
  80. package/src/utils/extract-function-name.ts +14 -4
  81. package/src/utils/extract-function-node.ts +18 -6
  82. package/src/utils/extract-secret-usage.test.ts +81 -0
  83. package/src/utils/extract-secret-usage.ts +73 -0
  84. package/src/utils/literal-properties.ts +97 -0
  85. package/src/utils/load-addon-functions-meta.ts +32 -2
  86. package/src/utils/permissions-shorthand.test.ts +79 -0
  87. package/src/utils/permissions.ts +7 -0
  88. package/src/utils/post-process.test.ts +133 -0
  89. package/src/utils/post-process.ts +310 -12
  90. package/src/utils/schema-cache.test.ts +124 -0
  91. package/src/utils/schema-generator.test.ts +62 -1
  92. package/src/utils/schema-generator.ts +138 -21
  93. package/src/utils/secret-alias-services.test.ts +146 -0
  94. package/src/utils/secret-alias-services.ts +58 -0
  95. package/src/utils/secret-brokers.test.ts +53 -0
  96. package/src/utils/secret-brokers.ts +11 -0
  97. package/src/utils/serialize-inspector-state.ts +28 -0
  98. package/src/utils/serialize-middleware-groups-meta.ts +3 -0
  99. package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
  100. package/src/utils/validate-exposed-functions-gated.ts +94 -0
  101. package/src/utils/validate-tags-resolve-to-middleware.test.ts +105 -0
  102. package/src/utils/validate-tags-resolve-to-middleware.ts +48 -0
  103. package/src/visit.ts +4 -0
  104. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The two step kinds whose subject is not a person: the app acting on itself,
3
+ * and a third-party system acting on it.
4
+ *
5
+ * What is being pinned here is mostly negative — that neither can declare a
6
+ * surface, and that both are marked in a way the virtual-user catalogue can
7
+ * refuse. The second is not tidiness: a virtual user that could invoke "Stripe's
8
+ * webhook arrives" would forge its own payment success, and every finding
9
+ * downstream of that is worthless.
10
+ */
11
+ import { strict as assert } from 'assert'
12
+ import { describe, test } from 'node:test'
13
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
14
+ import { tmpdir } from 'node:os'
15
+ import { join } from 'node:path'
16
+ import { inspect } from '../inspector.js'
17
+ import type { InspectorLogger } from '../types.js'
18
+
19
+ async function run(source: string) {
20
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-subject-step-'))
21
+ const file = join(rootDir, 'subjects.steps.ts')
22
+ await writeFile(file, source)
23
+ const errors: string[] = []
24
+ const logger = {
25
+ debug: () => {},
26
+ info: () => {},
27
+ warn: () => {},
28
+ error: (message: string) => {
29
+ errors.push(message)
30
+ },
31
+ diagnostic: () => {},
32
+ critical: (_code: any, message: string) => {
33
+ errors.push(message)
34
+ },
35
+ hasCriticalErrors: () => false,
36
+ } as unknown as InspectorLogger
37
+
38
+ const state = await inspect(logger, [file], { rootDir })
39
+ return {
40
+ state,
41
+ errors,
42
+ cleanup: () => rm(rootDir, { recursive: true, force: true }),
43
+ }
44
+ }
45
+
46
+ const IMPORTS =
47
+ "import { pikkuPlatformScenarioStep, pikkuAddonScenarioStep } from '@pikku/core/workflow'"
48
+
49
+ describe('pikkuPlatformScenarioStep', () => {
50
+ test('registers under its declared name, marked as the platform acting', async () => {
51
+ const { state, errors, cleanup } = await run(
52
+ [
53
+ IMPORTS,
54
+ 'export const trialHasExpired = pikkuPlatformScenarioStep({',
55
+ " name: 'trialHasExpired',",
56
+ " description: 'the platform has expired the trial',",
57
+ ' func: async ({ logger }, data: { orgId: string }) => ({ ok: !!data.orgId }),',
58
+ '})',
59
+ ].join('\n')
60
+ )
61
+ try {
62
+ assert.deepEqual(errors, [])
63
+ const meta = state.functions.meta['trialHasExpired']
64
+ assert.ok(meta, 'expected a function registered as trialHasExpired')
65
+ assert.equal(meta.scenarioStepKind, 'platform')
66
+ // Held out of every deployed unit, exactly like a persona step.
67
+ assert.equal(meta.scenarioStep, true)
68
+ assert.equal(state.rpc.exposedMeta['trialHasExpired'], undefined)
69
+ assert.equal(state.rpc.internalMeta['trialHasExpired'], undefined)
70
+ } finally {
71
+ await cleanup()
72
+ }
73
+ })
74
+
75
+ test('has exactly one witness: a surface binding is refused', async () => {
76
+ const { state, errors, cleanup } = await run(
77
+ [
78
+ IMPORTS,
79
+ 'export const trialHasExpired = pikkuPlatformScenarioStep({',
80
+ " name: 'trialHasExpired',",
81
+ ' browser: async ({ logger }) => ({ ok: true }),',
82
+ '})',
83
+ ].join('\n')
84
+ )
85
+ try {
86
+ assert.ok(
87
+ errors.some((message) => message.includes('browser')),
88
+ `expected the browser binding to be refused, got: ${errors.join(' | ')}`
89
+ )
90
+ assert.equal(state.functions.meta['trialHasExpired'], undefined)
91
+ } finally {
92
+ await cleanup()
93
+ }
94
+ })
95
+
96
+ test('declares one surface, so a browser run never demands an actor for it', async () => {
97
+ const { state, cleanup } = await run(
98
+ [
99
+ IMPORTS,
100
+ 'export const trialHasExpired = pikkuPlatformScenarioStep({',
101
+ " name: 'trialHasExpired',",
102
+ ' func: async ({ logger }) => ({ ok: true }),',
103
+ '})',
104
+ ].join('\n')
105
+ )
106
+ try {
107
+ assert.deepEqual(
108
+ state.functions.meta['trialHasExpired']?.scenarioStepSurfaces,
109
+ ['default']
110
+ )
111
+ } finally {
112
+ await cleanup()
113
+ }
114
+ })
115
+ })
116
+
117
+ describe('pikkuAddonScenarioStep', () => {
118
+ test('records the addon whose system acts', async () => {
119
+ const { state, errors, cleanup } = await run(
120
+ [
121
+ IMPORTS,
122
+ 'export const stripeWebhookArrives = pikkuAddonScenarioStep({',
123
+ " name: 'stripeWebhookArrives',",
124
+ " addon: 'stripe',",
125
+ " template: 'Stripe sends {event}',",
126
+ ' func: async ({ logger }, data: { event: string }) => ({ ok: !!data.event }),',
127
+ '})',
128
+ ].join('\n')
129
+ )
130
+ try {
131
+ assert.deepEqual(errors, [])
132
+ const meta = state.functions.meta['stripeWebhookArrives']
133
+ assert.equal(meta?.scenarioStepKind, 'addon')
134
+ assert.equal(meta?.scenarioStepAddon, 'stripe')
135
+ assert.equal(meta?.scenarioStepTemplate, 'Stripe sends {event}')
136
+ } finally {
137
+ await cleanup()
138
+ }
139
+ })
140
+
141
+ test('without an addon it is refused — nobody can say whose system acted', async () => {
142
+ const { state, errors, cleanup } = await run(
143
+ [
144
+ IMPORTS,
145
+ 'export const stripeWebhookArrives = pikkuAddonScenarioStep({',
146
+ " name: 'stripeWebhookArrives',",
147
+ ' func: async ({ logger }) => ({ ok: true }),',
148
+ '})',
149
+ ].join('\n')
150
+ )
151
+ try {
152
+ assert.ok(
153
+ errors.some((message) => message.includes('addon')),
154
+ `expected a missing-addon error, got: ${errors.join(' | ')}`
155
+ )
156
+ assert.equal(state.functions.meta['stripeWebhookArrives'], undefined)
157
+ } finally {
158
+ await cleanup()
159
+ }
160
+ })
161
+ })
@@ -0,0 +1,214 @@
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
+ const IMPORTS = [
39
+ "import { defineScope } from '@pikku/core/scope'",
40
+ "import { defineSystemRole } from '@pikku/core/role'",
41
+ ]
42
+
43
+ const withScopes = (...lines: string[]) =>
44
+ [
45
+ ...IMPORTS,
46
+ 'defineScope({',
47
+ ' catalogue: { scopes: { read: {} } },',
48
+ ' orders: { scopes: { create: {} } },',
49
+ ' admin: { scopes: { users: { scopes: { ban: {} } } } },',
50
+ '})',
51
+ ...lines,
52
+ ].join('\n')
53
+
54
+ describe('addSystemRole inspector', () => {
55
+ test('extracts a role with its scopes', async () => {
56
+ const { state, criticals } = await inspectSource(
57
+ withScopes(
58
+ 'defineSystemRole({',
59
+ ' buyer: {',
60
+ " displayName: 'Buyer',",
61
+ " description: 'Browses and orders',",
62
+ " scopes: ['catalogue:read', 'orders:create'],",
63
+ ' },',
64
+ '})'
65
+ )
66
+ )
67
+
68
+ assert.deepEqual(criticals, [])
69
+ assert.equal(state.systemRoles.definitions.length, 1)
70
+ const role = state.systemRoles.definitions[0]!
71
+ assert.equal(role.name, 'buyer')
72
+ assert.equal(role.displayName, 'Buyer')
73
+ assert.equal(role.description, 'Browses and orders')
74
+ assert.deepEqual(role.scopes, ['catalogue:read', 'orders:create'])
75
+ })
76
+
77
+ test('extracts several roles from one call', async () => {
78
+ const { state, criticals } = await inspectSource(
79
+ withScopes(
80
+ 'defineSystemRole({',
81
+ " buyer: { scopes: ['catalogue:read'] },",
82
+ " admin: { scopes: ['admin'] },",
83
+ '})'
84
+ )
85
+ )
86
+
87
+ assert.deepEqual(criticals, [])
88
+ assert.deepEqual(
89
+ state.systemRoles.definitions.map((r) => r.name),
90
+ ['buyer', 'admin']
91
+ )
92
+ })
93
+
94
+ test('a role granting nothing is legitimate, said explicitly', async () => {
95
+ const { state, criticals } = await inspectSource(
96
+ withScopes('defineSystemRole({', ' guest: { scopes: [] },', '})')
97
+ )
98
+
99
+ assert.deepEqual(criticals, [])
100
+ assert.deepEqual(state.systemRoles.definitions[0]!.scopes, [])
101
+ })
102
+
103
+ // Omitting `scopes` is not the same as declaring none — one is a decision,
104
+ // the other is a role nobody finished writing.
105
+ test('omitting scopes entirely is refused', async () => {
106
+ const { state, criticals } = await inspectSource(
107
+ withScopes('defineSystemRole({', " guest: { description: 'x' },", '})')
108
+ )
109
+
110
+ assert.equal(state.systemRoles.definitions.length, 0)
111
+ assert.ok(criticals.some((c) => /has no 'scopes'/.test(c.message)))
112
+ })
113
+
114
+ test('a name containing the scope separator is refused', async () => {
115
+ const { state, criticals } = await inspectSource(
116
+ withScopes('defineSystemRole({', " 'admin:users': { scopes: [] },", '})')
117
+ )
118
+
119
+ assert.equal(state.systemRoles.definitions.length, 0)
120
+ assert.ok(criticals.some((c) => /separator/.test(c.message)))
121
+ })
122
+
123
+ // Scopes are read by AST, so a computed entry cannot be checked against the
124
+ // declared set — accepting it silently would let a role grant anything.
125
+ test('a computed scope entry is refused', async () => {
126
+ const { criticals } = await inspectSource(
127
+ withScopes(
128
+ 'const s = "catalogue:read"',
129
+ 'defineSystemRole({',
130
+ ' buyer: { scopes: [s] },',
131
+ '})'
132
+ )
133
+ )
134
+
135
+ assert.ok(criticals.some((c) => /not a string literal/.test(c.message)))
136
+ })
137
+
138
+ test('`as const` does not hide the declaration', async () => {
139
+ const { state, criticals } = await inspectSource(
140
+ withScopes(
141
+ 'defineSystemRole({',
142
+ " buyer: { scopes: ['catalogue:read'] },",
143
+ '} as const)'
144
+ )
145
+ )
146
+
147
+ assert.deepEqual(criticals, [])
148
+ assert.equal(state.systemRoles.definitions.length, 1)
149
+ })
150
+ })
151
+
152
+ describe('validateSystemRoleScopes', () => {
153
+ test('accepts declared scopes, including an intermediate node', async () => {
154
+ const { criticals } = await inspectSource(
155
+ withScopes(
156
+ 'defineSystemRole({',
157
+ " admin: { scopes: ['admin', 'admin:users:ban'] },",
158
+ '})'
159
+ )
160
+ )
161
+
162
+ assert.deepEqual(criticals, [])
163
+ })
164
+
165
+ test('accepts a subtree wildcard whose node exists', async () => {
166
+ const { criticals } = await inspectSource(
167
+ withScopes(
168
+ 'defineSystemRole({',
169
+ " admin: { scopes: ['admin:*'] },",
170
+ '})'
171
+ )
172
+ )
173
+
174
+ assert.deepEqual(criticals, [])
175
+ })
176
+
177
+ // The failure this exists to prevent: no function checks `orders:refund`, so
178
+ // a persona holding the role passes every call and the boundary the role
179
+ // describes is never exercised.
180
+ test('an undeclared scope fails the build and lists what is available', async () => {
181
+ const { criticals } = await inspectSource(
182
+ withScopes(
183
+ 'defineSystemRole({',
184
+ " buyer: { scopes: ['orders:refund'] },",
185
+ '})'
186
+ )
187
+ )
188
+
189
+ const critical = criticals.find((c) => /orders:refund/.test(c.message))
190
+ assert.ok(critical, `expected a critical, got ${JSON.stringify(criticals)}`)
191
+ assert.match(critical!.message, /is not declared/)
192
+ assert.match(critical!.message, /catalogue:read/)
193
+ })
194
+
195
+ test('a subtree wildcard on an undeclared node fails', async () => {
196
+ const { criticals } = await inspectSource(
197
+ withScopes(
198
+ 'defineSystemRole({',
199
+ " buyer: { scopes: ['nonsense:*'] },",
200
+ '})'
201
+ )
202
+ )
203
+
204
+ assert.ok(criticals.some((c) => /nonsense:\*/.test(c.message)))
205
+ })
206
+
207
+ test('the bare wildcard is refused — it hides what the role confers', async () => {
208
+ const { criticals } = await inspectSource(
209
+ withScopes('defineSystemRole({', " god: { scopes: ['*'] },", '})')
210
+ )
211
+
212
+ assert.ok(criticals.some((c) => /bare wildcard/.test(c.message)))
213
+ })
214
+ })
@@ -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
+ })