@pikku/inspector 0.12.52 → 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 (38) hide show
  1. package/CHANGELOG.md +302 -0
  2. package/dist/add/add-functions.js +46 -4
  3. package/dist/add/add-personas.js +7 -1
  4. package/dist/add/add-workflow.js +12 -1
  5. package/dist/error-codes.d.ts +8 -1
  6. package/dist/error-codes.js +12 -0
  7. package/dist/types.d.ts +9 -0
  8. package/dist/utils/check-secret-sinks.d.ts +31 -0
  9. package/dist/utils/check-secret-sinks.js +117 -0
  10. package/dist/utils/post-process.js +4 -1
  11. package/dist/utils/single-declaration.d.ts +32 -0
  12. package/dist/utils/single-declaration.js +43 -0
  13. package/dist/utils/validate-exposed-functions-gated.js +15 -7
  14. package/dist/utils/workflow/dsl/extract-dsl-workflow.d.ts +6 -0
  15. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +48 -1
  16. package/dist/utils/workflow/dsl/patterns.d.ts +9 -4
  17. package/dist/utils/workflow/dsl/patterns.js +26 -4
  18. package/package.json +3 -3
  19. package/src/add/add-functions.ts +57 -5
  20. package/src/add/add-personas.test.ts +115 -1
  21. package/src/add/add-personas.ts +18 -9
  22. package/src/add/add-scenario-step.test.ts +38 -2
  23. package/src/add/add-scope.test.ts +73 -0
  24. package/src/add/add-system-role.test.ts +83 -0
  25. package/src/add/add-workflow.ts +21 -1
  26. package/src/add/allow-escape-hatches.test.ts +144 -0
  27. package/src/add/audit-meta.test.ts +75 -0
  28. package/src/add/secret-sink-check.test.ts +211 -0
  29. package/src/error-codes.ts +13 -0
  30. package/src/types.ts +9 -0
  31. package/src/utils/check-secret-sinks.ts +145 -0
  32. package/src/utils/post-process.ts +4 -1
  33. package/src/utils/single-declaration.ts +57 -0
  34. package/src/utils/validate-exposed-functions-gated.test.ts +2 -2
  35. package/src/utils/validate-exposed-functions-gated.ts +16 -7
  36. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +57 -0
  37. package/src/utils/workflow/dsl/patterns.ts +33 -4
  38. package/tsconfig.tsbuildinfo +1 -1
@@ -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
+ })
@@ -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
+ })
@@ -29,6 +29,7 @@ export enum ErrorCode {
29
29
  WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED = 'PKU600',
30
30
  INVALID_DSL_WORKFLOW = 'PKU641',
31
31
  WORKFLOW_GRAPH_ADDON_NOT_WIRED = 'PKU642',
32
+ COMPLEX_WORKFLOW_NOT_ALLOWED = 'PKU643',
32
33
 
33
34
  // Remote addon (wireRemoteAddon) validation
34
35
  REMOTE_ADDON_NOT_DEV_DEPENDENCY = 'PKU338',
@@ -38,6 +39,9 @@ export enum ErrorCode {
38
39
  DB_COLUMN_NAME_TYPE_CONTRADICTION = 'PKU480',
39
40
  DB_JSON_COLUMN_UNTYPED = 'PKU481',
40
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',
41
45
 
42
46
  // Configuration errors
43
47
  SCHEMA_GENERATION_ERROR = 'PKU456',
@@ -50,12 +54,20 @@ export enum ErrorCode {
50
54
  DUPLICATE_AUTH_DEFINITION = 'PKU581',
51
55
  AUTH_NOT_EXPORTED = 'PKU582',
52
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
+
53
64
  // HTTP Route errors
54
65
  ROUTE_PARAM_MISMATCH = 'PKU571',
55
66
  ROUTE_QUERY_MISMATCH = 'PKU572',
56
67
  AUTH_DISABLED_REQUIRES_SESSIONLESS = 'PKU573',
57
68
  EXPOSED_FUNCTION_HAS_NO_GATE = 'PKU574',
58
69
  TAG_RESOLVES_TO_NO_MIDDLEWARE = 'PKU575',
70
+ PERMISSIONS_IN_BODY_NOT_ALLOWED = 'PKU576',
59
71
 
60
72
  // Versioning errors
61
73
  DUPLICATE_FUNCTION_VERSION = 'PKU850',
@@ -103,6 +115,7 @@ export enum ErrorCode {
103
115
  SECRET_SERVICE_ALIASED = 'PKU950',
104
116
  SECRET_NOT_DECLARED = 'PKU951',
105
117
  SECRET_KEY_NOT_STATIC = 'PKU952',
118
+ SECRET_REVEALED_INTO_SINK = 'PKU953',
106
119
  }
107
120
 
108
121
  /**
package/src/types.ts CHANGED
@@ -335,6 +335,15 @@ export type InspectorOptions = Partial<{
335
335
  * function, which is expensive. Enabled via `pikku all --security`.
336
336
  */
337
337
  classificationCheck: boolean
338
+ /**
339
+ * Escape hatches the project has opted into, from `allow` in
340
+ * `pikku.config.json`. Absent or false means the feature is refused with a
341
+ * critical naming the flag that would permit it.
342
+ */
343
+ allow: Partial<{
344
+ permissionsInBody: boolean
345
+ complexWorkflows: boolean
346
+ }>
338
347
  }>
339
348
 
340
349
  export interface InspectorLogger {
@@ -0,0 +1,145 @@
1
+ import * as ts from 'typescript'
2
+ import { findPiiPaths } from './check-pii-output.js'
3
+ import { doesTypeExtendsCore } from './does-type-extend-core-type.js'
4
+
5
+ export type SecretSinkViolation = {
6
+ /** How the value reaches the outside world, e.g. `logger.info`. */
7
+ sink: string
8
+ /** Dotted path to the secret inside the argument, or `<argument>` when bare. */
9
+ path: string
10
+ /** Position of the offending argument, for the diagnostic message. */
11
+ line: number
12
+ }
13
+
14
+ /**
15
+ * A method that writes its arguments somewhere a secret must never land, keyed
16
+ * by the core interface that owns it. `'*'` means every method on the type is a
17
+ * sink, which is true of a logger.
18
+ */
19
+ const SINKS: Array<{ coreType: string; methods: '*' | string[] }> = [
20
+ { coreType: 'Logger', methods: '*' },
21
+ { coreType: 'QueueService', methods: ['add'] },
22
+ { coreType: 'EmailService', methods: ['send'] },
23
+ { coreType: 'WebhookService', methods: ['send'] },
24
+ { coreType: 'AuditService', methods: ['audit', 'write'] },
25
+ { coreType: 'AuditLog', methods: ['write'] },
26
+ ]
27
+
28
+ /**
29
+ * Finds revealed vault secrets that flow into a sink.
30
+ *
31
+ * `SecretValue` is nominally typed, so the type system alone stops it reaching
32
+ * any of these. `.reveal()` is the deliberate escape hatch, and it hands back
33
+ * `Secret<T>` — the erasable classification brand. That brand is what this scan
34
+ * follows, which means it catches the revealed value both inline
35
+ * (`logger.info(s.reveal())`) and through a local binding, because the local's
36
+ * inferred type carries the brand too.
37
+ *
38
+ * The brand is optional by design, so an explicit annotation (`const t: string =
39
+ * s.reveal()`) erases it and this scan goes quiet. That is the documented limit
40
+ * of a structural marker: it catches the mistake, not someone determined to
41
+ * silence it.
42
+ *
43
+ * Sinks are matched on the receiver's resolved core interface, which means the
44
+ * scan needs the services parameter to be typed. Real projects get that from
45
+ * the generated `#pikku` factory; a hand-rolled file that calls the raw core
46
+ * factory without annotating its services leaves the parameter `any`, and an
47
+ * `any` receiver matches nothing.
48
+ */
49
+ export function findRevealedSecretSinks(
50
+ checker: ts.TypeChecker,
51
+ handler: ts.Node
52
+ ): SecretSinkViolation[] {
53
+ const violations: SecretSinkViolation[] = []
54
+
55
+ const visit = (node: ts.Node): void => {
56
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
57
+ const sink = describeSink(checker, node.expression)
58
+ if (sink) {
59
+ for (const arg of node.arguments) {
60
+ const found = findSecretExpression(checker, arg)
61
+ if (found) {
62
+ violations.push({
63
+ sink,
64
+ path: found.getText(),
65
+ line:
66
+ found
67
+ .getSourceFile()
68
+ .getLineAndCharacterOfPosition(found.getStart()).line + 1,
69
+ })
70
+ }
71
+ }
72
+ }
73
+ }
74
+ ts.forEachChild(node, visit)
75
+ }
76
+ visit(handler)
77
+
78
+ return violations
79
+ }
80
+
81
+ /**
82
+ * Finds the first expression inside a sink argument whose own type carries the
83
+ * secret brand, and returns it so the diagnostic can quote it.
84
+ *
85
+ * This walks the argument rather than typing it whole, because an object
86
+ * literal in an argument position is contextually typed by the parameter — for
87
+ * a guarded sink that parameter is `Safe<M>`, and resolving the literal against
88
+ * it drops the optional brand before it can be read. Each inner expression
89
+ * (`token.reveal()`, or a reference to a local holding it) carries its own type
90
+ * regardless of the surrounding context, so reading those is what actually
91
+ * sees the secret.
92
+ */
93
+ function findSecretExpression(
94
+ checker: ts.TypeChecker,
95
+ node: ts.Node
96
+ ): ts.Expression | undefined {
97
+ // Descend first, so an object literal wrapping the secret reports the
98
+ // expression that actually holds it (`token.reveal()`) rather than the whole
99
+ // literal. Nothing inside a `SecretValue` receiver is itself branded, so a
100
+ // bare `token.reveal()` still falls through to the self-check below.
101
+ for (const child of node.getChildren()) {
102
+ // A property name is not a value, and typing it re-resolves the property.
103
+ if (ts.isPropertyAssignment(node) && child === node.name) continue
104
+ const found = findSecretExpression(checker, child)
105
+ if (found) return found
106
+ }
107
+
108
+ if (ts.isExpression(node) && !ts.isStringLiteralLike(node)) {
109
+ const type = checker.getTypeAtLocation(node)
110
+ const isSecret = findPiiPaths(checker, type).some(
111
+ (f) => f.classification === 'secret'
112
+ )
113
+ if (isSecret) return node
114
+ }
115
+
116
+ return undefined
117
+ }
118
+
119
+ /**
120
+ * Names the sink a call writes to, or undefined when the call is not one.
121
+ * `console` is matched by identifier because it is a global with no Pikku type;
122
+ * everything else is matched on its resolved core interface so a renamed
123
+ * service or a decorator still counts.
124
+ */
125
+ function describeSink(
126
+ checker: ts.TypeChecker,
127
+ callee: ts.PropertyAccessExpression
128
+ ): string | undefined {
129
+ const method = callee.name.text
130
+ const receiver = callee.expression
131
+
132
+ if (ts.isIdentifier(receiver) && receiver.text === 'console') {
133
+ return `console.${method}`
134
+ }
135
+
136
+ const receiverType = checker.getTypeAtLocation(receiver)
137
+ for (const { coreType, methods } of SINKS) {
138
+ if (methods !== '*' && !methods.includes(method)) continue
139
+ if (doesTypeExtendsCore(receiverType, checker, new Set(), coreType)) {
140
+ return `${receiver.getText()}.${method}`
141
+ }
142
+ }
143
+
144
+ return undefined
145
+ }
@@ -817,7 +817,10 @@ export function validateScenarioSteps(
817
817
  // nothing threw. It is also invisible to witness coverage — it contributes
818
818
  // 0/0, so it can never lower the number — which makes an assertion-free
819
819
  // flow the cheapest way to make a suite look better covered than it is.
820
- if (phasesSeen.size > 0 && !phasesSeen.has('then')) {
820
+ // `asserts` is the escape for a flow whose witness is an expectation helper
821
+ // instead: those are inline steps and leave no phase behind, but a recorded
822
+ // service call or a refusal is an assertion all the same.
823
+ if (phasesSeen.size > 0 && !phasesSeen.has('then') && !meta.asserts) {
821
824
  logger.critical(
822
825
  ErrorCode.SCENARIO_HAS_NO_ASSERTION,
823
826
  `Scenario '${workflowName}' has ${[...phasesSeen].join('/')} steps but never asserts. ` +
@@ -0,0 +1,57 @@
1
+ import type { ErrorCode } from '../error-codes.js'
2
+ import type { InspectorLogger } from '../types.js'
3
+
4
+ /**
5
+ * Claims the one call site a single-declaration construct is allowed.
6
+ *
7
+ * `definePersonas` takes a keyed object, so one call already declares as many
8
+ * entries as you like. Letting the call itself repeat buys nothing and costs
9
+ * the thing that matters: a single place to read the declared set from, and a
10
+ * single place to add to it. The same rule `pikkuBetterAuth` has had all along.
11
+ *
12
+ * A second call in the SAME file is refused too — "the file" is not an answer
13
+ * to "where do I add a persona?" when the file holds two calls, so allowing it
14
+ * would keep the ambiguity the rule exists to remove.
15
+ *
16
+ * A generated file is exempt, and never claims the slot either. The rule exists
17
+ * to name the one place a person adds to, and nobody adds to a file the CLI
18
+ * rewrites on the next run — `pikku-personas.gen.ts` would otherwise make every
19
+ * project that scaffolds one unbuildable, and take the app's own declaration
20
+ * down with it.
21
+ *
22
+ * `defineScope` and `defineSystemRole` held this rule too, briefly, and do not
23
+ * until the `admin` tree stops being app-declared. The exemption above is not
24
+ * enough for them: the CLI generates a `defineScope` in `user-admin.gen.ts` and
25
+ * `@pikku/addon-console` spells the same tree out again in a hand-written file,
26
+ * so two non-generated declarations remain and the build still fails. The fix
27
+ * is for `admin` to be a default scope nobody declares, and the rule comes back
28
+ * for scopes and roles once that lands.
29
+ *
30
+ * @param files - The construct's `files` set, which doubles as the claim.
31
+ * @returns true when the caller holds the claim and should carry on.
32
+ */
33
+ export const claimSingleDeclaration = (
34
+ logger: InspectorLogger,
35
+ files: Set<string>,
36
+ code: ErrorCode,
37
+ helper: string,
38
+ sourceFile: string
39
+ ): boolean => {
40
+ if (sourceFile.endsWith('.gen.ts')) {
41
+ return true
42
+ }
43
+
44
+ const [first] = files
45
+ if (first === undefined) {
46
+ files.add(sourceFile)
47
+ return true
48
+ }
49
+
50
+ logger.critical(
51
+ code,
52
+ first === sourceFile
53
+ ? `Only one ${helper}(...) is allowed per codebase, so there is one place to read the declarations from and one place to add to. Found a second call in ${sourceFile}. Declare them all in one call.`
54
+ : `Only one ${helper}(...) is allowed per codebase, so there is one place to read the declarations from and one place to add to. Found a second in ${sourceFile} (first: ${first}). Declare them all in one call.`
55
+ )
56
+ return false
57
+ }
@@ -108,7 +108,7 @@ describe('validateExposedFunctionsGated', () => {
108
108
  // nobody reads.
109
109
  run(
110
110
  stateWith({
111
- stripeWebhook: ungatedFunction({ selfAuthenticated: true }),
111
+ stripeWebhook: ungatedFunction({ permissionsInBody: true }),
112
112
  })
113
113
  )
114
114
 
@@ -118,7 +118,7 @@ describe('validateExposedFunctionsGated', () => {
118
118
  test('still warns when the declaration is explicitly false', () => {
119
119
  run(
120
120
  stateWith({
121
- credentialGet: ungatedFunction({ selfAuthenticated: false }),
121
+ credentialGet: ungatedFunction({ permissionsInBody: false }),
122
122
  })
123
123
  )
124
124
 
@@ -32,7 +32,7 @@ export function validateExposedFunctionsGated(
32
32
  // this check has no way to see. Taking the claim at face value is the
33
33
  // point: without it the warning fires forever on functions that are fine,
34
34
  // and a warning that is usually wrong stops being read.
35
- if (meta.selfAuthenticated === true) continue
35
+ if (meta.permissionsInBody === true) continue
36
36
 
37
37
  // The function gates itself.
38
38
  if (meta.auth === true) continue
@@ -49,18 +49,27 @@ export function validateExposedFunctionsGated(
49
49
  const shown = ungated.slice(0, 10)
50
50
  const rest = ungated.length - shown.length
51
51
 
52
+ const one = ungated.length === 1
53
+
54
+ // Every function here is sessionless — that is the population, not the
55
+ // finding, so the message names it as given rather than reporting it as a
56
+ // defect. The remedies are the ones that can actually apply to an anonymous
57
+ // caller: `scopes` cannot, because `verifyScopes` fails closed on a session
58
+ // that does not exist, and telling someone to add them sends them to a
59
+ // MissingScopeError on every public request.
52
60
  logger.diagnostic({
53
61
  severity: 'warn',
54
62
  code: ErrorCode.EXPOSED_FUNCTION_HAS_NO_GATE,
55
63
  message:
56
- `${ungated.length} exposed function${ungated.length === 1 ? '' : 's'} ` +
57
- `require neither a session nor any permission or scope, and ` +
58
- `${ungated.length === 1 ? 'is' : 'are'} reachable by anyone through ` +
64
+ `${ungated.length} exposed sessionless function${one ? '' : 's'} ` +
65
+ `carr${one ? 'ies' : 'y'} no permission and no addon gate, so ` +
66
+ `${one ? 'it is' : 'they are'} reachable by anyone through ` +
59
67
  `POST /rpc/:rpcName: ${shown.join(', ')}` +
60
68
  (rest > 0 ? `, and ${rest} more` : '') +
61
- `. Add scopes or permissions to the function, gate the whole addon with ` +
62
- `wireAddon({ scopes: [...] }), or drop \`expose: true\` if it was not ` +
63
- `meant to be callable from outside.`,
69
+ `. Add permissions to the function, set \`auth: true\` if a session is ` +
70
+ `required after all, gate the whole addon with ` +
71
+ `wireAddon({ auth: true }), or drop \`expose: true\` if ` +
72
+ `${one ? 'it was' : 'they were'} not meant to be callable from outside.`,
64
73
  })
65
74
  }
66
75