@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
@@ -104,17 +104,17 @@ describe('addon authoring bans', () => {
104
104
  })
105
105
  })
106
106
 
107
- test('wireSecret inside an addon is allowed', async () => {
107
+ test('defineSecret inside an addon is allowed', async () => {
108
108
  const source = [
109
- "import { wireSecret } from '@pikku/core'",
110
- "wireSecret({ name: 'example', secretId: 'EXAMPLE' } as any)",
109
+ "import { defineSecret } from '@pikku/core'",
110
+ "defineSecret({ name: 'example', secretId: 'EXAMPLE' } as any)",
111
111
  ].join('\n')
112
112
  await withTempApp(source, async (file, rootDir) => {
113
113
  const { logger, criticals } = recordingLogger()
114
114
  await inspect(logger, [file], { rootDir, isAddon: true })
115
115
  assert.ok(
116
116
  !criticals.some((c) => c.code === ErrorCode.ADDON_WIRING_NOT_ALLOWED),
117
- `expected wireSecret to be allowed, got ${JSON.stringify(criticals)}`
117
+ `expected defineSecret to be allowed, got ${JSON.stringify(criticals)}`
118
118
  )
119
119
  })
120
120
  })
@@ -0,0 +1,144 @@
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 { ErrorCode } from '../error-codes.js'
8
+ import type { InspectorLogger, InspectorOptions } from '../types.js'
9
+
10
+ function makeLogger(
11
+ criticals: Array<{ code: string; message: string }>
12
+ ): InspectorLogger {
13
+ return {
14
+ debug: () => {},
15
+ info: () => {},
16
+ warn: () => {},
17
+ error: () => {},
18
+ diagnostic: ({ code, message }: any) => criticals.push({ code, message }),
19
+ critical: (code: any, message: string) => criticals.push({ code, message }),
20
+ hasCriticalErrors: () => criticals.length > 0,
21
+ } as InspectorLogger
22
+ }
23
+
24
+ async function run(source: string, allow: InspectorOptions['allow'] = {}) {
25
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-allow-'))
26
+ const file = join(rootDir, 'subject.ts')
27
+ await writeFile(file, source)
28
+ const criticals: Array<{ code: string; message: string }> = []
29
+ const state = await inspect(makeLogger(criticals), [file], {
30
+ rootDir,
31
+ allow,
32
+ })
33
+ await rm(rootDir, { recursive: true, force: true })
34
+ return { state, criticals }
35
+ }
36
+
37
+ const has = (criticals: Array<{ code: string }>, code: ErrorCode) =>
38
+ criticals.some((c) => c.code === code)
39
+
40
+ const PERMISSIONS_IN_BODY = [
41
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
42
+ 'export const stripeWebhook = pikkuSessionlessFunc({',
43
+ ' expose: true,',
44
+ ' permissionsInBody: true,',
45
+ ' func: async () => ({ ok: true }),',
46
+ '})',
47
+ ].join('\n')
48
+
49
+ const COMPLEX_WORKFLOW = [
50
+ "import { pikkuWorkflowComplexFunc } from '@pikku/core/workflow'",
51
+ 'export const cleanup = pikkuWorkflowComplexFunc(',
52
+ ' async (_services, _input, { workflow }: any) => {',
53
+ ' for (const id of [1, 2, 3]) {',
54
+ " await workflow.step('touch', { id })",
55
+ ' }',
56
+ ' return { ok: true }',
57
+ ' }',
58
+ ')',
59
+ ].join('\n')
60
+
61
+ describe('allow: escape hatches are refused unless opted into', () => {
62
+ test('permissionsInBody is a critical when not allowed', async () => {
63
+ const { criticals } = await run(PERMISSIONS_IN_BODY)
64
+ assert.ok(
65
+ has(criticals, ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED),
66
+ `expected PKU576, got ${JSON.stringify(criticals)}`
67
+ )
68
+ })
69
+
70
+ test('the message names the flag that would permit it', async () => {
71
+ const { criticals } = await run(PERMISSIONS_IN_BODY)
72
+ const found = criticals.find(
73
+ (c) => c.code === ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED
74
+ )
75
+ assert.match(found!.message, /allow: \{ permissionsInBody: true \}/)
76
+ assert.match(found!.message, /stripeWebhook/)
77
+ })
78
+
79
+ test('permissionsInBody is accepted once allowed', async () => {
80
+ const { criticals, state } = await run(PERMISSIONS_IN_BODY, {
81
+ permissionsInBody: true,
82
+ })
83
+ assert.equal(
84
+ has(criticals, ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED),
85
+ false
86
+ )
87
+ const meta = Object.values(state.functions.meta) as any[]
88
+ assert.equal(
89
+ meta.find((m) => m.name === 'stripeWebhook')?.permissionsInBody,
90
+ true
91
+ )
92
+ })
93
+
94
+ test('a refused function is not registered at all', async () => {
95
+ const { state } = await run(PERMISSIONS_IN_BODY)
96
+ const meta = Object.values(state.functions.meta) as any[]
97
+ assert.equal(
98
+ meta.some((m) => m.name === 'stripeWebhook'),
99
+ false,
100
+ 'a refused function must not reach the meta, or the build would emit it anyway'
101
+ )
102
+ })
103
+
104
+ test('complexWorkflows is a critical when not allowed', async () => {
105
+ const { criticals } = await run(COMPLEX_WORKFLOW)
106
+ assert.ok(
107
+ has(criticals, ErrorCode.COMPLEX_WORKFLOW_NOT_ALLOWED),
108
+ `expected PKU643, got ${JSON.stringify(criticals)}`
109
+ )
110
+ })
111
+
112
+ test('the complex-workflow message points at the DSL', async () => {
113
+ const { criticals } = await run(COMPLEX_WORKFLOW)
114
+ const found = criticals.find(
115
+ (c) => c.code === ErrorCode.COMPLEX_WORKFLOW_NOT_ALLOWED
116
+ )
117
+ assert.match(found!.message, /pikkuWorkflowFunc/)
118
+ assert.match(found!.message, /allow: \{ complexWorkflows: true \}/)
119
+ })
120
+
121
+ test('complexWorkflows is accepted once allowed', async () => {
122
+ const { criticals } = await run(COMPLEX_WORKFLOW, {
123
+ complexWorkflows: true,
124
+ })
125
+ assert.equal(has(criticals, ErrorCode.COMPLEX_WORKFLOW_NOT_ALLOWED), false)
126
+ })
127
+
128
+ test('an ordinary function and DSL workflow are unaffected', async () => {
129
+ const { criticals } = await run(
130
+ [
131
+ "import { pikkuFunc } from '@pikku/core'",
132
+ 'export const ordinary = pikkuFunc({',
133
+ ' expose: true,',
134
+ ' func: async () => ({ ok: true }),',
135
+ '})',
136
+ ].join('\n')
137
+ )
138
+ assert.equal(
139
+ has(criticals, ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED),
140
+ false
141
+ )
142
+ assert.equal(has(criticals, ErrorCode.COMPLEX_WORKFLOW_NOT_ALLOWED), false)
143
+ })
144
+ })
@@ -0,0 +1,75 @@
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 { InspectorLogger } from '../types.js'
8
+
9
+ const silentLogger = (): InspectorLogger =>
10
+ ({
11
+ debug: () => {},
12
+ info: () => {},
13
+ warn: () => {},
14
+ error: () => {},
15
+ diagnostic: () => {},
16
+ critical: () => {},
17
+ hasCriticalErrors: () => false,
18
+ }) as InspectorLogger
19
+
20
+ async function metaFor(source: string) {
21
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-audit-'))
22
+ const file = join(rootDir, 'subject.ts')
23
+ await writeFile(file, source)
24
+ const state = await inspect(silentLogger(), [file], { rootDir })
25
+ await rm(rootDir, { recursive: true, force: true })
26
+ return Object.values(state.functions.meta) as any[]
27
+ }
28
+
29
+ const func = (name: string, auditLine: string | null) =>
30
+ [
31
+ "import { pikkuFunc } from '@pikku/core'",
32
+ `export const ${name} = pikkuFunc({`,
33
+ ...(auditLine ? [` ${auditLine}`] : []),
34
+ ' func: async () => ({ ok: true }),',
35
+ '})',
36
+ ].join('\n')
37
+
38
+ describe('the audit flag reaches the function meta', () => {
39
+ test('`audit: true` resolves to best-effort', async () => {
40
+ const meta = await metaFor(func('cancelInvoice', 'audit: true,'))
41
+ assert.deepEqual(meta.find((m) => m.name === 'cancelInvoice')?.audit, {
42
+ durability: 'best-effort',
43
+ })
44
+ })
45
+
46
+ test('an explicit durability is carried through', async () => {
47
+ const meta = await metaFor(
48
+ func('chargeCard', "audit: { durability: 'transactional' },")
49
+ )
50
+ assert.deepEqual(meta.find((m) => m.name === 'chargeCard')?.audit, {
51
+ durability: 'transactional',
52
+ })
53
+ })
54
+
55
+ test('an object without a durability defaults to best-effort', async () => {
56
+ const meta = await metaFor(func('refund', 'audit: {},'))
57
+ assert.deepEqual(meta.find((m) => m.name === 'refund')?.audit, {
58
+ durability: 'best-effort',
59
+ })
60
+ })
61
+
62
+ // Absent rather than `false`: an unmarked function drops `auditLog.write()`
63
+ // with a warning, so "no audit key" has to be the honest reading of it.
64
+ test('a function with no audit config carries no audit key', async () => {
65
+ const meta = await metaFor(func('listInvoices', null))
66
+ const found = meta.find((m) => m.name === 'listInvoices')
67
+ assert.ok(found, 'the function should still be registered')
68
+ assert.equal(found.audit, undefined)
69
+ })
70
+
71
+ test('`audit: false` is the same as not declaring it', async () => {
72
+ const meta = await metaFor(func('healthCheck', 'audit: false,'))
73
+ assert.equal(meta.find((m) => m.name === 'healthCheck')?.audit, undefined)
74
+ })
75
+ })
@@ -36,17 +36,17 @@ const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
36
36
  * downstream, which is exactly how `displayName` went unused.
37
37
  */
38
38
  describe('config value metadata (docsUrl)', () => {
39
- test('carries docsUrl from wireSecret into the definition', async () => {
39
+ test('carries docsUrl from defineSecret into the definition', async () => {
40
40
  const rootDir = await mkdtemp(`${fixtureRoot}-secret-`)
41
41
  const file = join(rootDir, 'secret.ts')
42
42
 
43
43
  await writeFile(
44
44
  file,
45
45
  [
46
- "import { wireSecret } from '@pikku/core'",
46
+ "import { defineSecret } from '@pikku/core'",
47
47
  "import { z } from 'zod'",
48
48
  'export const StripeKeySchema = z.string()',
49
- 'wireSecret({',
49
+ 'defineSecret({',
50
50
  " name: 'stripeKey',",
51
51
  " displayName: 'Stripe Secret Key',",
52
52
  " secretId: 'STRIPE_SECRET_KEY',",
@@ -76,10 +76,10 @@ describe('config value metadata (docsUrl)', () => {
76
76
  await writeFile(
77
77
  file,
78
78
  [
79
- "import { wireSecret } from '@pikku/core'",
79
+ "import { defineSecret } from '@pikku/core'",
80
80
  "import { z } from 'zod'",
81
81
  'export const ApiKeySchema = z.string()',
82
- 'wireSecret({',
82
+ 'defineSecret({',
83
83
  " name: 'apiKey',",
84
84
  " displayName: 'API Key',",
85
85
  " secretId: 'API_KEY',",
@@ -101,17 +101,17 @@ describe('config value metadata (docsUrl)', () => {
101
101
  }
102
102
  })
103
103
 
104
- test('carries docsUrl from wireVariable into the definition', async () => {
104
+ test('carries docsUrl from defineVariable into the definition', async () => {
105
105
  const rootDir = await mkdtemp(`${fixtureRoot}-variable-`)
106
106
  const file = join(rootDir, 'variable.ts')
107
107
 
108
108
  await writeFile(
109
109
  file,
110
110
  [
111
- "import { wireVariable } from '@pikku/core'",
111
+ "import { defineVariable } from '@pikku/core'",
112
112
  "import { z } from 'zod'",
113
113
  'export const ConsoleUrlSchema = z.string()',
114
- 'wireVariable({',
114
+ 'defineVariable({',
115
115
  " name: 'consoleUrl',",
116
116
  " displayName: 'Console URL',",
117
117
  " variableId: 'CONSOLE_URL',",
@@ -134,17 +134,17 @@ describe('config value metadata (docsUrl)', () => {
134
134
  }
135
135
  })
136
136
 
137
- test('carries docsUrl from wireCredential into the definition', async () => {
137
+ test('carries docsUrl from defineCredential into the definition', async () => {
138
138
  const rootDir = await mkdtemp(`${fixtureRoot}-credential-`)
139
139
  const file = join(rootDir, 'credential.ts')
140
140
 
141
141
  await writeFile(
142
142
  file,
143
143
  [
144
- "import { wireCredential } from '@pikku/core'",
144
+ "import { defineCredential } from '@pikku/core'",
145
145
  "import { z } from 'zod'",
146
146
  'export const TokenSchema = z.object({ token: z.string() })',
147
- 'wireCredential({',
147
+ 'defineCredential({',
148
148
  " name: 'githubToken',",
149
149
  " displayName: 'GitHub Token',",
150
150
  " type: 'wire',",
@@ -0,0 +1,211 @@
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 { ErrorCode } from '../error-codes.js'
8
+ import type { InspectorLogger } from '../types.js'
9
+
10
+ function makeLogger() {
11
+ const diagnostics: Array<{ code: ErrorCode; message: string }> = []
12
+ const logger: InspectorLogger = {
13
+ debug: () => {},
14
+ info: () => {},
15
+ warn: () => {},
16
+ error: () => {},
17
+ diagnostic: ({ code, message }) => diagnostics.push({ code, message }),
18
+ critical: (code, message) => diagnostics.push({ code, message }),
19
+ hasCriticalErrors: () => diagnostics.length > 0,
20
+ }
21
+ return { logger, diagnostics }
22
+ }
23
+
24
+ /**
25
+ * Stands in for @pikku/core, which is not importable from a tmp dir. The shapes
26
+ * are what matters: `Secret<T>` is the optional brand `.reveal()` returns, and
27
+ * the service interfaces are matched by name, so these must keep the real names.
28
+ */
29
+ const PRELUDE = `
30
+ type Secret<T> = T & { readonly __classification__?: 'secret' }
31
+
32
+ declare class SecretValue<T = string> {
33
+ private brand: T
34
+ reveal(): Secret<T>
35
+ }
36
+
37
+ interface Logger {
38
+ info(messageOrObj: any, ...meta: any[]): void
39
+ error(messageOrObj: any, ...meta: any[]): void
40
+ }
41
+ interface QueueService {
42
+ add<T>(queueName: string, data: T): Promise<string>
43
+ }
44
+ interface EmailService {
45
+ send(input: any): Promise<void>
46
+ }
47
+ interface WebhookService {
48
+ send(input: any): Promise<void>
49
+ }
50
+ interface AuditService {
51
+ audit(event: { type: string; input?: unknown }): Promise<void>
52
+ }
53
+ interface AuditLog {
54
+ write(event: { type: string; input?: unknown }): Promise<void>
55
+ }
56
+
57
+ type Services = {
58
+ logger: Logger
59
+ queueService: QueueService
60
+ email: EmailService
61
+ webhooks: WebhookService
62
+ audit?: AuditService
63
+ auditLog?: AuditLog
64
+ secrets: { getSecret(key: string): Promise<SecretValue<string>> }
65
+ }
66
+
67
+ declare const pikkuFunc: <In, Out>(cfg: {
68
+ func: (services: Services, data: In) => Promise<Out>
69
+ }) => unknown
70
+ `
71
+
72
+ async function runInspect(source: string) {
73
+ const tmpDir = await mkdtemp(join(tmpdir(), 'pikku-secret-sink-test-'))
74
+ const file = join(tmpDir, 'funcs.ts')
75
+ await writeFile(file, PRELUDE + source)
76
+ const { logger, diagnostics } = makeLogger()
77
+ try {
78
+ await inspect(logger, [file], { rootDir: tmpDir, classificationCheck: true })
79
+ } finally {
80
+ await rm(tmpDir, { recursive: true, force: true })
81
+ }
82
+ return diagnostics
83
+ }
84
+
85
+ const sinkErrors = (diagnostics: Array<{ code: ErrorCode; message: string }>) =>
86
+ diagnostics.filter((d) => d.code === ErrorCode.SECRET_REVEALED_INTO_SINK)
87
+
88
+ describe('PKU953 — a revealed secret reaching a sink', () => {
89
+ test('flags a secret logged inline', async () => {
90
+ const diagnostics = await runInspect(`
91
+ export const leak = pikkuFunc<void, void>({
92
+ func: async ({ logger, secrets }) => {
93
+ const token = await secrets.getSecret('API_KEY')
94
+ logger.info(token.reveal())
95
+ },
96
+ })
97
+ `)
98
+ const errors = sinkErrors(diagnostics)
99
+ assert.equal(errors.length, 1)
100
+ assert.match(errors[0]!.message, /logger\.info/)
101
+ })
102
+
103
+ test('flags a secret that reaches the sink through a local binding', async () => {
104
+ const diagnostics = await runInspect(`
105
+ export const leak = pikkuFunc<void, void>({
106
+ func: async ({ logger, secrets }) => {
107
+ const token = (await secrets.getSecret('API_KEY')).reveal()
108
+ logger.error('auth failed', { token })
109
+ },
110
+ })
111
+ `)
112
+ const errors = sinkErrors(diagnostics)
113
+ assert.equal(errors.length, 1)
114
+ assert.match(errors[0]!.message, /logger\.error/)
115
+ assert.match(errors[0]!.message, /token/)
116
+ })
117
+
118
+ test('flags a secret enqueued, emailed or sent to a webhook', async () => {
119
+ const diagnostics = await runInspect(`
120
+ export const leak = pikkuFunc<void, void>({
121
+ func: async ({ queueService, email, webhooks, secrets }) => {
122
+ const token = (await secrets.getSecret('API_KEY')).reveal()
123
+ await queueService.add('jobs', { token })
124
+ await email.send({ to: 'a@b.c', text: token })
125
+ await webhooks.send({ url: 'https://x', data: { token } })
126
+ },
127
+ })
128
+ `)
129
+ assert.equal(sinkErrors(diagnostics).length, 3)
130
+ })
131
+
132
+ test('flags a secret written to an audit, through the optional chain it is reached by', async () => {
133
+ const diagnostics = await runInspect(`
134
+ export const leak = pikkuFunc<void, void>({
135
+ func: async ({ audit, auditLog, secrets }) => {
136
+ const token = (await secrets.getSecret('API_KEY')).reveal()
137
+ await audit?.audit({ type: 'used', input: { token } })
138
+ await auditLog?.write({ type: 'used', input: token })
139
+ },
140
+ })
141
+ `)
142
+ const errors = sinkErrors(diagnostics)
143
+ assert.equal(errors.length, 2)
144
+ assert.match(errors[0]!.message, /audit\.audit/)
145
+ assert.match(errors[1]!.message, /auditLog\.write/)
146
+ })
147
+
148
+ test('flags console, which no type guard covers', async () => {
149
+ const diagnostics = await runInspect(`
150
+ export const leak = pikkuFunc<void, void>({
151
+ func: async ({ secrets }) => {
152
+ const token = await secrets.getSecret('API_KEY')
153
+ console.log(token.reveal())
154
+ },
155
+ })
156
+ `)
157
+ const errors = sinkErrors(diagnostics)
158
+ assert.equal(errors.length, 1)
159
+ assert.match(errors[0]!.message, /console\.log/)
160
+ })
161
+
162
+ test('stays quiet when the secret never reaches a sink', async () => {
163
+ const diagnostics = await runInspect(`
164
+ declare function authenticate(token: string): Promise<void>
165
+
166
+ export const fine = pikkuFunc<void, void>({
167
+ func: async ({ logger, secrets }) => {
168
+ const token = (await secrets.getSecret('API_KEY')).reveal()
169
+ await authenticate(token)
170
+ logger.info('authenticated')
171
+ },
172
+ })
173
+ `)
174
+ assert.equal(sinkErrors(diagnostics).length, 0)
175
+ })
176
+
177
+ test('stays quiet for ordinary logging', async () => {
178
+ const diagnostics = await runInspect(`
179
+ export const fine = pikkuFunc<{ id: string }, void>({
180
+ func: async ({ logger }, data) => {
181
+ logger.info('processing', { id: data.id, at: new Date() })
182
+ },
183
+ })
184
+ `)
185
+ assert.equal(sinkErrors(diagnostics).length, 0)
186
+ })
187
+
188
+ test('does not run without the classification flag', async () => {
189
+ const tmpDir = await mkdtemp(join(tmpdir(), 'pikku-secret-sink-off-'))
190
+ const file = join(tmpDir, 'funcs.ts')
191
+ await writeFile(
192
+ file,
193
+ PRELUDE +
194
+ `
195
+ export const leak = pikkuFunc<void, void>({
196
+ func: async ({ logger, secrets }) => {
197
+ const token = await secrets.getSecret('API_KEY')
198
+ logger.info(token.reveal())
199
+ },
200
+ })
201
+ `
202
+ )
203
+ const { logger, diagnostics } = makeLogger()
204
+ try {
205
+ await inspect(logger, [file], { rootDir: tmpDir })
206
+ } finally {
207
+ await rm(tmpDir, { recursive: true, force: true })
208
+ }
209
+ assert.equal(sinkErrors(diagnostics).length, 0)
210
+ })
211
+ })
@@ -62,9 +62,9 @@ describe('wiring name must be a string literal', () => {
62
62
  await writeFile(
63
63
  file,
64
64
  [
65
- "import { wireSecret } from '@pikku/core'",
65
+ "import { defineSecret } from '@pikku/core'",
66
66
  'const SECRET_ID = "STRIPE_SECRET_KEY"',
67
- 'wireSecret({',
67
+ 'defineSecret({',
68
68
  ' secretId: SECRET_ID,',
69
69
  " name: 'Stripe secret key',",
70
70
  " displayName: 'Stripe secret key',",
@@ -25,9 +25,11 @@ export enum ErrorCode {
25
25
  SCENARIO_BROWSER_STEP_NEEDS_ACTOR = 'PKU677',
26
26
  SCENARIO_STEP_TARGET_NOT_STATIC = 'PKU678',
27
27
  SCENARIO_NOT_EXTRACTABLE = 'PKU679',
28
+ SCENARIO_HAS_NO_ASSERTION = 'PKU680',
28
29
  WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED = 'PKU600',
29
30
  INVALID_DSL_WORKFLOW = 'PKU641',
30
31
  WORKFLOW_GRAPH_ADDON_NOT_WIRED = 'PKU642',
32
+ COMPLEX_WORKFLOW_NOT_ALLOWED = 'PKU643',
31
33
 
32
34
  // Remote addon (wireRemoteAddon) validation
33
35
  REMOTE_ADDON_NOT_DEV_DEPENDENCY = 'PKU338',
@@ -37,6 +39,9 @@ export enum ErrorCode {
37
39
  DB_COLUMN_NAME_TYPE_CONTRADICTION = 'PKU480',
38
40
  DB_JSON_COLUMN_UNTYPED = 'PKU481',
39
41
  DB_FORMAT_ON_NON_STRING = 'PKU482',
42
+ DB_SECRET_COLUMN_STORED_PLAIN = 'PKU483',
43
+ DB_FORM_ON_NON_STRING = 'PKU484',
44
+ DB_DEFAULT_SCHEMA_NAME_COLLISION = 'PKU485',
40
45
 
41
46
  // Configuration errors
42
47
  SCHEMA_GENERATION_ERROR = 'PKU456',
@@ -49,10 +54,20 @@ export enum ErrorCode {
49
54
  DUPLICATE_AUTH_DEFINITION = 'PKU581',
50
55
  AUTH_NOT_EXPORTED = 'PKU582',
51
56
 
57
+ // Single-declaration constructs — one call site per codebase.
58
+ // PKU583 (defineScope) and PKU584 (defineSystemRole) are retired, not free:
59
+ // scopes and roles held this rule until the generated `admin` tree made it
60
+ // unsatisfiable, and get it back once `admin` is a default scope nobody
61
+ // declares. Do not reuse those two numbers for anything else.
62
+ DUPLICATE_PERSONAS_DEFINITION = 'PKU585',
63
+
52
64
  // HTTP Route errors
53
65
  ROUTE_PARAM_MISMATCH = 'PKU571',
54
66
  ROUTE_QUERY_MISMATCH = 'PKU572',
55
67
  AUTH_DISABLED_REQUIRES_SESSIONLESS = 'PKU573',
68
+ EXPOSED_FUNCTION_HAS_NO_GATE = 'PKU574',
69
+ TAG_RESOLVES_TO_NO_MIDDLEWARE = 'PKU575',
70
+ PERMISSIONS_IN_BODY_NOT_ALLOWED = 'PKU576',
56
71
 
57
72
  // Versioning errors
58
73
  DUPLICATE_FUNCTION_VERSION = 'PKU850',
@@ -70,6 +85,11 @@ export enum ErrorCode {
70
85
  MISSING_MODEL = 'PKU145',
71
86
  INVALID_MODEL = 'PKU146',
72
87
 
88
+ // Agent tool reference errors
89
+ AGENT_TOOL_UNKNOWN_NAMESPACE = 'PKU152',
90
+ AGENT_TOOL_NOT_FOUND = 'PKU153',
91
+ AGENT_TOOL_MISSING_DESCRIPTION = 'PKU154',
92
+
73
93
  // File structure errors
74
94
  SCHEMA_AND_WIRING_COLOCATED = 'PKU490',
75
95
 
@@ -90,6 +110,12 @@ export enum ErrorCode {
90
110
  ADDON_CONTRACT_HANDLERS_NOT_ALLOWED = 'PKU921',
91
111
 
92
112
  RPC_INVOCATION_TYPE_CAST = 'PKU940',
113
+
114
+ // Secret boundary errors
115
+ SECRET_SERVICE_ALIASED = 'PKU950',
116
+ SECRET_NOT_DECLARED = 'PKU951',
117
+ SECRET_KEY_NOT_STATIC = 'PKU952',
118
+ SECRET_REVEALED_INTO_SINK = 'PKU953',
93
119
  }
94
120
 
95
121
  /**
package/src/index.ts CHANGED
@@ -32,7 +32,10 @@ export type {
32
32
  VersionManifest,
33
33
  VersionManifestEntry,
34
34
  } from './utils/contract-hashes.js'
35
- export type { InspectorFeature, InspectorFeatureEntry } from './types.js'
35
+ export type {
36
+ InspectorFeature,
37
+ InspectorFeatureEntry,
38
+ } from './types.js'
36
39
  export { serializeMCPJson } from './utils/serialize-mcp-json.js'
37
40
  export type { OpenAPISpecInfo } from './utils/serialize-openapi-json.js'
38
41
  export {