@pikku/inspector 0.12.14 → 0.12.16

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 (41) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/add/add-ai-agent.js +1 -1
  3. package/dist/add/add-channel.js +25 -7
  4. package/dist/add/add-functions.js +28 -13
  5. package/dist/add/add-gateway.js +1 -1
  6. package/dist/add/add-http-route.js +23 -1
  7. package/dist/add/add-mcp-prompt.js +1 -1
  8. package/dist/add/add-mcp-resource.js +1 -1
  9. package/dist/add/add-queue-worker.js +1 -1
  10. package/dist/add/add-schedule.js +1 -1
  11. package/dist/add/add-trigger.js +1 -1
  12. package/dist/add/add-workflow.js +1 -1
  13. package/dist/utils/check-pii-output.d.ts +9 -4
  14. package/dist/utils/check-pii-output.js +17 -7
  15. package/dist/utils/ensure-function-metadata.js +1 -1
  16. package/dist/utils/extract-node-value.d.ts +1 -1
  17. package/dist/utils/extract-node-value.js +10 -1
  18. package/dist/utils/get-property-value.d.ts +1 -1
  19. package/dist/utils/get-property-value.js +35 -9
  20. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +20 -9
  21. package/package.json +1 -1
  22. package/src/add/add-ai-agent.ts +1 -1
  23. package/src/add/add-channel.ts +37 -7
  24. package/src/add/add-functions.ts +44 -13
  25. package/src/add/add-gateway.ts +1 -1
  26. package/src/add/add-http-route.ts +26 -1
  27. package/src/add/add-mcp-prompt.ts +1 -1
  28. package/src/add/add-mcp-resource.ts +1 -1
  29. package/src/add/add-queue-worker.ts +1 -1
  30. package/src/add/add-schedule.ts +1 -1
  31. package/src/add/add-trigger.ts +1 -1
  32. package/src/add/add-workflow.test.ts +152 -0
  33. package/src/add/add-workflow.ts +2 -1
  34. package/src/add/pii-check.test.ts +70 -28
  35. package/src/utils/check-pii-output.ts +27 -11
  36. package/src/utils/ensure-function-metadata.ts +3 -1
  37. package/src/utils/extract-node-value.test.ts +12 -10
  38. package/src/utils/extract-node-value.ts +15 -1
  39. package/src/utils/get-property-value.ts +33 -13
  40. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +22 -9
  41. package/tsconfig.tsbuildinfo +1 -1
@@ -238,7 +238,8 @@ export function addMessagesRoutes(
238
238
  init,
239
239
  'Channel message',
240
240
  routeKey,
241
- logger
241
+ logger,
242
+ checker
242
243
  ).tags
243
244
  : undefined
244
245
  const routeMiddleware = ts.isObjectLiteralExpression(init)
@@ -270,7 +271,8 @@ export function addMessagesRoutes(
270
271
  init,
271
272
  'Channel message',
272
273
  routeKey,
273
- logger
274
+ logger,
275
+ checker
274
276
  ).tags
275
277
  : undefined
276
278
  const routeMiddleware = ts.isObjectLiteralExpression(init)
@@ -319,7 +321,8 @@ export function addMessagesRoutes(
319
321
  init,
320
322
  'Channel message',
321
323
  routeKey,
322
- logger
324
+ logger,
325
+ checker
323
326
  ).tags
324
327
  : undefined
325
328
  const routeMiddleware = ts.isObjectLiteralExpression(
@@ -350,7 +353,8 @@ export function addMessagesRoutes(
350
353
  init,
351
354
  'Channel message',
352
355
  routeKey,
353
- logger
356
+ logger,
357
+ checker
354
358
  ).tags
355
359
  : undefined
356
360
  const routeMiddleware = ts.isObjectLiteralExpression(
@@ -438,7 +442,8 @@ export function addMessagesRoutes(
438
442
  init,
439
443
  'Channel message',
440
444
  routeKey,
441
- logger
445
+ logger,
446
+ checker
442
447
  ).tags
443
448
  : undefined
444
449
  const routeMiddleware = ts.isObjectLiteralExpression(init)
@@ -481,7 +486,13 @@ export function addMessagesRoutes(
481
486
  // Resolve middleware and permissions for this route
482
487
  // Check if the route config is an object literal with middleware/permissions
483
488
  const routeTags = ts.isObjectLiteralExpression(init)
484
- ? getCommonWireMetaData(init, 'Channel message', routeKey, logger).tags
489
+ ? getCommonWireMetaData(
490
+ init,
491
+ 'Channel message',
492
+ routeKey,
493
+ logger,
494
+ checker
495
+ ).tags
485
496
  : undefined
486
497
  const routeMiddleware = ts.isObjectLiteralExpression(init)
487
498
  ? resolveMiddleware(state, init, routeTags, checker)
@@ -540,7 +551,7 @@ export const addChannel: AddWiring = (
540
551
  : []
541
552
 
542
553
  const { disabled, tags, summary, description, errors } =
543
- getCommonWireMetaData(obj, 'Channel', name, logger)
554
+ getCommonWireMetaData(obj, 'Channel', name, logger, checker)
544
555
 
545
556
  if (disabled) return
546
557
 
@@ -630,6 +641,25 @@ export const addChannel: AddWiring = (
630
641
  state.serviceAggregation.usedFunctions.add(disconnectFuncId)
631
642
  }
632
643
 
644
+ // Synthesize function meta for connect/disconnect handlers that are defined
645
+ // with pikkuChannelConnectionFunc/pikkuChannelDisconnectionFunc (not pikkuFunc/
646
+ // pikkuSessionlessFunc), so the runtime has correct sessionless info without
647
+ // needing to inject it at runtime.
648
+ {
649
+ const routeAuth = getPropertyValue(obj, 'auth')
650
+ const sessionless = routeAuth === false ? true : undefined
651
+ for (const funcId of [connectFuncId, disconnectFuncId]) {
652
+ if (funcId && !state.functions.meta[funcId]) {
653
+ state.functions.meta[funcId] = {
654
+ pikkuFuncId: funcId,
655
+ sessionless,
656
+ inputSchemaName: null,
657
+ outputSchemaName: null,
658
+ }
659
+ }
660
+ }
661
+ }
662
+
633
663
  if (message) {
634
664
  state.serviceAggregation.usedFunctions.add(message.pikkuFuncId)
635
665
  }
@@ -462,7 +462,13 @@ export const addFunctions: AddWiring = (
462
462
  // Extract config properties if using object form
463
463
  if (ts.isObjectLiteralExpression(firstArg)) {
464
464
  objectNode = firstArg
465
- const metadata = getCommonWireMetaData(firstArg, 'Function', name, logger)
465
+ const metadata = getCommonWireMetaData(
466
+ firstArg,
467
+ 'Function',
468
+ name,
469
+ logger,
470
+ checker
471
+ )
466
472
  if (metadata.disabled) return
467
473
  title = metadata.title
468
474
  tags = metadata.tags
@@ -883,24 +889,50 @@ export const addFunctions: AddWiring = (
883
889
  }
884
890
  }
885
891
 
886
- // ── PII brand check ───────────────────────────────────────────────────────
887
- // Walk the function body's ACTUAL inferred return type looking for Private<T>
888
- // / Pii<T> / Secret<T> brands (__classification__ property). This runs for every function,
889
- // including those with a Zod output schema, because the TS return type
890
- // reflects what the body actually returns before any Zod coercion.
892
+ const sessionless = expression.text !== 'pikkuFunc'
893
+
894
+ // ── Classification brand check ─────────────────────────────────────────────
895
+ // Walk the function body's ACTUAL inferred return type looking for classification
896
+ // brands (__classification__ property on Private<T>, Pii<T>, Secret<T>).
897
+ //
898
+ // Semantics:
899
+ // secret → never returned by any exposed function (sessioned or not)
900
+ // private → only visible to authenticated (sessioned) users; ok for pikkuFunc
901
+ // public → safe for sessionless functions
891
902
  {
892
903
  const sig = checker.getSignatureFromDeclaration(handler)
893
904
  if (sig) {
894
905
  const rawRet = checker.getReturnTypeOfSignature(sig)
895
906
  const unwrapped = unwrapPromise(checker, rawRet)
896
- const piiPaths = findPiiPaths(checker, unwrapped)
897
- if (piiPaths.length > 0) {
907
+ const classifiedFields = findPiiPaths(checker, unwrapped)
908
+
909
+ const secretPaths = classifiedFields
910
+ .filter((f) => f.classification === 'secret')
911
+ .map((f) => f.path)
912
+
913
+ const privatePaths = classifiedFields
914
+ .filter(
915
+ (f) => f.classification === 'private' || f.classification === 'pii'
916
+ )
917
+ .map((f) => f.path)
918
+
919
+ if (secretPaths.length > 0) {
920
+ logger.critical(
921
+ ErrorCode.PII_IN_OUTPUT,
922
+ `Function '${name}' exposes secret-classified field(s) in its return type: ` +
923
+ secretPaths.map((p) => `'${p}'`).join(', ') +
924
+ `.\n Secret fields must never appear in function output. ` +
925
+ `Strip these fields before returning or change the column classification.`
926
+ )
927
+ }
928
+
929
+ if (sessionless && privatePaths.length > 0) {
898
930
  logger.critical(
899
931
  ErrorCode.PII_IN_OUTPUT,
900
- `Function '${name}' exposes PII-classified field(s) in its return type: ` +
901
- piiPaths.map((p) => `'${p}'`).join(', ') +
902
- `.\n Either strip these fields before returning or mark the column ` +
903
- `@public in the migration if it is safe to expose.`
932
+ `Sessionless function '${name}' exposes private-classified field(s) in its return type: ` +
933
+ privatePaths.map((p) => `'${p}'`).join(', ') +
934
+ `.\n Private fields are only safe to return from authenticated (sessioned) functions. ` +
935
+ `Either require a session (use pikkuFunc) or mark the column @public if it is safe to expose publicly.`
904
936
  )
905
937
  }
906
938
  }
@@ -946,7 +978,6 @@ export const addFunctions: AddWiring = (
946
978
  }
947
979
  }
948
980
 
949
- const sessionless = expression.text !== 'pikkuFunc'
950
981
  const implementationHash = computeImplementationHash({
951
982
  wrapper: expression.text,
952
983
  handler,
@@ -45,7 +45,7 @@ export const addGateway: AddWiring = (
45
45
  const typeValue = getPropertyValue(obj, 'type') as GatewayTransportType | null
46
46
  const routeValue = getPropertyValue(obj, 'route') as string | undefined
47
47
  const { disabled, tags, summary, description, errors } =
48
- getCommonWireMetaData(obj, 'Gateway', nameValue, logger)
48
+ getCommonWireMetaData(obj, 'Gateway', nameValue, logger, checker)
49
49
 
50
50
  if (disabled) return
51
51
 
@@ -175,7 +175,7 @@ export function registerHTTPRoute({
175
175
  summary,
176
176
  description,
177
177
  errors,
178
- } = getCommonWireMetaData(obj, 'HTTP route', fullRoute, logger)
178
+ } = getCommonWireMetaData(obj, 'HTTP route', fullRoute, logger, checker)
179
179
 
180
180
  if (disabled) return
181
181
 
@@ -248,6 +248,31 @@ export function registerHTTPRoute({
248
248
  extracted.isHelper
249
249
  )
250
250
 
251
+ // Propagate sessionless from the addon target so the runtime can correctly
252
+ // determine whether a session is required for ref()-based routes.
253
+ if (refAddonTarget) {
254
+ const targetMeta = resolveFunctionMeta(state, refAddonTarget)
255
+ const inlineMeta = state.functions.meta[funcName]
256
+ if (targetMeta && inlineMeta && targetMeta.sessionless !== undefined) {
257
+ inlineMeta.sessionless = targetMeta.sessionless
258
+ }
259
+ }
260
+
261
+ // For inline functions (e.g. oauth2 handlers), propagate sessionless: true
262
+ // when auth is explicitly disabled at the route or group level — the
263
+ // developer opted out of auth so no session is required.
264
+ {
265
+ const inlineMeta = state.functions.meta[funcName]
266
+ if (inlineMeta && inlineMeta.sessionless === undefined) {
267
+ const routeAuth = getPropertyValue(obj, 'auth')
268
+ const resolvedAuth =
269
+ routeAuth === true || routeAuth === false ? routeAuth : inheritedAuth
270
+ if (resolvedAuth === false) {
271
+ inlineMeta.sessionless = true
272
+ }
273
+ }
274
+ }
275
+
251
276
  // Lookup existing function metadata
252
277
  const fnMeta = resolveFunctionMeta(state, funcName)
253
278
  if (!fnMeta) {
@@ -45,7 +45,7 @@ export const addMCPPrompt: AddWiring = (
45
45
 
46
46
  const nameValue = getPropertyValue(obj, 'name') as string | null
47
47
  const { disabled, tags, summary, description, errors } =
48
- getCommonWireMetaData(obj, 'MCP prompt', nameValue, logger)
48
+ getCommonWireMetaData(obj, 'MCP prompt', nameValue, logger, checker)
49
49
 
50
50
  if (disabled) return
51
51
 
@@ -46,7 +46,7 @@ export const addMCPResource: AddWiring = (
46
46
  const uriValue = getPropertyValue(obj, 'uri') as string | null
47
47
  const titleValue = getPropertyValue(obj, 'title') as string | null
48
48
  const { disabled, tags, summary, description, errors } =
49
- getCommonWireMetaData(obj, 'MCP resource', uriValue, logger)
49
+ getCommonWireMetaData(obj, 'MCP resource', uriValue, logger, checker)
50
50
 
51
51
  if (disabled) return
52
52
 
@@ -37,7 +37,7 @@ export const addQueueWorker: AddWiring = (logger, node, checker, state) => {
37
37
 
38
38
  const name = getPropertyValue(obj, 'name') as string | null
39
39
  const { disabled, tags, summary, description, errors } =
40
- getCommonWireMetaData(obj, 'Queue worker', name, logger)
40
+ getCommonWireMetaData(obj, 'Queue worker', name, logger, checker)
41
41
 
42
42
  if (disabled) return
43
43
 
@@ -44,7 +44,7 @@ export const addSchedule: AddWiring = (
44
44
  const nameValue = getPropertyValue(obj, 'name') as string | null
45
45
  const scheduleValue = getPropertyValue(obj, 'schedule') as string | null
46
46
  const { disabled, tags, summary, description, errors } =
47
- getCommonWireMetaData(obj, 'Scheduler', nameValue, logger)
47
+ getCommonWireMetaData(obj, 'Scheduler', nameValue, logger, checker)
48
48
 
49
49
  if (disabled) return
50
50
 
@@ -55,7 +55,7 @@ const addWireTrigger: (
55
55
 
56
56
  const nameValue = getPropertyValue(obj, 'name') as string | null
57
57
  const { disabled, tags, summary, description, errors } =
58
- getCommonWireMetaData(obj, 'Trigger', nameValue, logger)
58
+ getCommonWireMetaData(obj, 'Trigger', nameValue, logger, checker)
59
59
 
60
60
  if (disabled) return
61
61
 
@@ -0,0 +1,152 @@
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
+ function makeLogger(
10
+ criticals: Array<{ code: string; message: string }>
11
+ ): InspectorLogger {
12
+ return {
13
+ debug: () => {},
14
+ info: () => {},
15
+ warn: () => {},
16
+ error: () => {},
17
+ critical: (code: any, message: string) => {
18
+ criticals.push({ code, message })
19
+ },
20
+ hasCriticalErrors: () => criticals.length > 0,
21
+ }
22
+ }
23
+
24
+ describe('addWorkflow — workflow.do RPC detection', () => {
25
+ test('detects RPC step when result is assigned to a const', async () => {
26
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-workflow-'))
27
+ const wfFile = join(rootDir, 'my.workflow.ts')
28
+ const stepFile = join(rootDir, 'my.steps.ts')
29
+
30
+ await writeFile(
31
+ stepFile,
32
+ [
33
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
34
+ 'export const doThing = pikkuSessionlessFunc({',
35
+ ' func: async ({ logger }) => ({ ok: true }),',
36
+ '})',
37
+ ].join('\n')
38
+ )
39
+
40
+ await writeFile(
41
+ wfFile,
42
+ [
43
+ "import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
44
+ 'export const myWorkflow = pikkuWorkflowFunc(async (_, _input, { workflow }) => {',
45
+ " const result = await workflow.do('Do thing', 'doThing', {})",
46
+ ' return { id: result.ok }',
47
+ '})',
48
+ ].join('\n')
49
+ )
50
+
51
+ const criticals: Array<{ code: string; message: string }> = []
52
+ try {
53
+ const state = await inspect(makeLogger(criticals), [stepFile, wfFile], {
54
+ rootDir,
55
+ })
56
+ assert.ok(
57
+ state.rpc.invokedFunctions.has('doThing'),
58
+ 'doThing should be in invokedFunctions'
59
+ )
60
+ assert.ok(
61
+ state.rpc.internalFiles.has('doThing'),
62
+ 'doThing should be in internalFiles'
63
+ )
64
+ } finally {
65
+ await rm(rootDir, { recursive: true, force: true })
66
+ }
67
+ })
68
+
69
+ test('detects RPC step when result is reassigned to a pre-declared null variable', async () => {
70
+ // Regression: `let x = null; x = await workflow.do(...)` was treated as a
71
+ // set-step instead of an RPC step, so the referenced function was never registered.
72
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-workflow-reassign-'))
73
+ const wfFile = join(rootDir, 'project.workflow.ts')
74
+ const stepFile = join(rootDir, 'project.steps.ts')
75
+
76
+ await writeFile(
77
+ stepFile,
78
+ [
79
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
80
+ 'export const launchSandbox = pikkuSessionlessFunc({',
81
+ ' func: async ({ logger }) => ({ sandboxId: "abc" }),',
82
+ '})',
83
+ ].join('\n')
84
+ )
85
+
86
+ await writeFile(
87
+ wfFile,
88
+ [
89
+ "import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
90
+ 'export const createProjectWorkflow = pikkuWorkflowFunc(async (_, input, { workflow }) => {',
91
+ ' let launched: { sandboxId: string } | null = null',
92
+ ' if (input.createSandbox) {',
93
+ " launched = await workflow.do('Launch sandbox', 'launchSandbox', { projectId: input.projectId })",
94
+ ' }',
95
+ ' return { sandboxId: launched?.sandboxId ?? null }',
96
+ '})',
97
+ ].join('\n')
98
+ )
99
+
100
+ const criticals: Array<{ code: string; message: string }> = []
101
+ try {
102
+ const state = await inspect(makeLogger(criticals), [stepFile, wfFile], {
103
+ rootDir,
104
+ })
105
+ assert.ok(
106
+ state.rpc.invokedFunctions.has('launchSandbox'),
107
+ 'launchSandbox should be in invokedFunctions even when assigned to a pre-declared null variable'
108
+ )
109
+ assert.ok(
110
+ state.rpc.internalFiles.has('launchSandbox'),
111
+ 'launchSandbox should be in internalFiles so it gets registered in pikku-functions.gen.ts'
112
+ )
113
+ } finally {
114
+ await rm(rootDir, { recursive: true, force: true })
115
+ }
116
+ })
117
+
118
+ test('still treats plain reassignment to context var as a set step', async () => {
119
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-workflow-set-'))
120
+ const wfFile = join(rootDir, 'set.workflow.ts')
121
+
122
+ await writeFile(
123
+ wfFile,
124
+ [
125
+ "import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
126
+ 'export const setWorkflow = pikkuWorkflowFunc(async (_, input, { workflow }) => {',
127
+ ' let status = "pending"',
128
+ ' status = "done"',
129
+ " await workflow.do('No-op', 'noopStep', {})",
130
+ ' return { status }',
131
+ '})',
132
+ ].join('\n')
133
+ )
134
+
135
+ const criticals: Array<{ code: string; message: string }> = []
136
+ try {
137
+ const state = await inspect(makeLogger(criticals), [wfFile], { rootDir })
138
+ const meta = state.workflows.meta['setWorkflow']
139
+ assert.ok(meta, 'workflow should be registered')
140
+ const steps = meta.steps ?? []
141
+ const setStep = steps.find(
142
+ (s: any) => s.type === 'set' && s.variable === 'status'
143
+ )
144
+ assert.ok(
145
+ setStep,
146
+ 'plain string reassignment should still produce a set step'
147
+ )
148
+ } finally {
149
+ await rm(rootDir, { recursive: true, force: true })
150
+ }
151
+ })
152
+ })
@@ -217,7 +217,8 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
217
217
  firstArg,
218
218
  'Workflow',
219
219
  workflowName,
220
- logger
220
+ logger,
221
+ checker
221
222
  )
222
223
  if (metadata.disabled) return
223
224
  tags = metadata.tags
@@ -46,30 +46,35 @@ async function runInspect(sourceCode: string) {
46
46
  return criticals
47
47
  }
48
48
 
49
- // ── findPiiPaths unit tests via full inspect() round-trip ────────────────────
49
+ // ── classification semantics:
50
+ // secret → never returned by any function (sessioned or not)
51
+ // private → only blocked in sessionless functions (pikkuSessionlessFunc)
52
+ // public → safe for sessionless functions
50
53
 
51
54
  describe('PII output check — PKU910', () => {
52
- test('flags a top-level Private<string> field', async () => {
55
+ // ── Secret<T>: always blocked ──────────────────────────────────────────────
56
+
57
+ test('flags a top-level Secret<string> field in a sessioned function', async () => {
53
58
  const criticals = await runInspect(`
54
59
  ${BRAND_TYPES}
55
60
  import { pikkuFunc } from '@pikku/core'
56
- export const getUser = pikkuFunc({
61
+ export const getToken = pikkuFunc({
57
62
  func: async () => {
58
- const email = 'test@example.com' as Private<string>
59
- return { id: 1, email }
63
+ const token = 'abc' as Secret<string>
64
+ return { token }
60
65
  }
61
66
  })
62
67
  `)
63
68
  const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
64
- assert.ok(hit, `Expected PKU910 but got: ${JSON.stringify(criticals)}`)
65
- assert.match(hit.message, /email/)
69
+ assert.ok(hit)
70
+ assert.match(hit.message, /token/)
66
71
  })
67
72
 
68
- test('flags a top-level Secret<string> field', async () => {
73
+ test('flags a top-level Secret<string> field in a sessionless function', async () => {
69
74
  const criticals = await runInspect(`
70
75
  ${BRAND_TYPES}
71
- import { pikkuFunc } from '@pikku/core'
72
- export const getToken = pikkuFunc({
76
+ import { pikkuSessionlessFunc } from '@pikku/core'
77
+ export const getToken = pikkuSessionlessFunc({
73
78
  func: async () => {
74
79
  const token = 'abc' as Secret<string>
75
80
  return { token }
@@ -81,11 +86,48 @@ export const getToken = pikkuFunc({
81
86
  assert.match(hit.message, /token/)
82
87
  })
83
88
 
84
- test('flags a nested Private field', async () => {
89
+ // ── Private<T>: only blocked in sessionless functions ─────────────────────
90
+
91
+ test('flags a top-level Private<string> field in a sessionless function', async () => {
92
+ const criticals = await runInspect(`
93
+ ${BRAND_TYPES}
94
+ import { pikkuSessionlessFunc } from '@pikku/core'
95
+ export const getUser = pikkuSessionlessFunc({
96
+ func: async () => {
97
+ const email = 'test@example.com' as Private<string>
98
+ return { id: 1, email }
99
+ }
100
+ })
101
+ `)
102
+ const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
103
+ assert.ok(hit, `Expected PKU910 but got: ${JSON.stringify(criticals)}`)
104
+ assert.match(hit.message, /email/)
105
+ })
106
+
107
+ test('does not flag a Private<string> field in a sessioned function', async () => {
85
108
  const criticals = await runInspect(`
86
109
  ${BRAND_TYPES}
87
110
  import { pikkuFunc } from '@pikku/core'
88
- export const getProfile = pikkuFunc({
111
+ export const getUser = pikkuFunc({
112
+ func: async () => {
113
+ const email = 'test@example.com' as Private<string>
114
+ return { id: 1, email }
115
+ }
116
+ })
117
+ `)
118
+ const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
119
+ assert.equal(
120
+ hit,
121
+ undefined,
122
+ `Expected no PKU910 (sessioned function may return Private fields) but got: ${JSON.stringify(hit)}`
123
+ )
124
+ })
125
+
126
+ test('flags a nested Private field in a sessionless function', async () => {
127
+ const criticals = await runInspect(`
128
+ ${BRAND_TYPES}
129
+ import { pikkuSessionlessFunc } from '@pikku/core'
130
+ export const getProfile = pikkuSessionlessFunc({
89
131
  func: async () => {
90
132
  const email = 'x@y.com' as Private<string>
91
133
  return { user: { id: 1, email } }
@@ -123,12 +165,12 @@ export const doWork = pikkuFunc({
123
165
  assert.equal(hit, undefined)
124
166
  })
125
167
 
126
- test('flags a function that returns a typed alias with Private field', async () => {
168
+ test('flags a sessionless function that returns a typed alias with Private field', async () => {
127
169
  const criticals = await runInspect(`
128
170
  ${BRAND_TYPES}
129
- import { pikkuFunc } from '@pikku/core'
171
+ import { pikkuSessionlessFunc } from '@pikku/core'
130
172
  type UserRow = { id: number; email: Private<string> }
131
- export const getUser = pikkuFunc({
173
+ export const getUser = pikkuSessionlessFunc({
132
174
  func: async (): Promise<UserRow> => {
133
175
  return { id: 1, email: 'x' as Private<string> }
134
176
  }
@@ -139,17 +181,17 @@ export const getUser = pikkuFunc({
139
181
  assert.match(hit.message, /email/)
140
182
  })
141
183
 
142
- test('flags across multiple functions in the same file', async () => {
184
+ test('flags across multiple sessionless functions in the same file', async () => {
143
185
  const criticals = await runInspect(`
144
186
  ${BRAND_TYPES}
145
- import { pikkuFunc } from '@pikku/core'
146
- export const getEmail = pikkuFunc({
187
+ import { pikkuSessionlessFunc } from '@pikku/core'
188
+ export const getEmail = pikkuSessionlessFunc({
147
189
  func: async () => ({ email: 'x' as Private<string> })
148
190
  })
149
- export const getPhone = pikkuFunc({
191
+ export const getPhone = pikkuSessionlessFunc({
150
192
  func: async () => ({ phone: '555' as Private<string> })
151
193
  })
152
- export const getSafe = pikkuFunc({
194
+ export const getSafe = pikkuSessionlessFunc({
153
195
  func: async () => ({ name: 'Alice' })
154
196
  })
155
197
  `)
@@ -157,11 +199,11 @@ export const getSafe = pikkuFunc({
157
199
  assert.equal(hits.length, 2, `Expected 2 PKU910 but got ${hits.length}`)
158
200
  })
159
201
 
160
- test('flags branded values inside arrays', async () => {
202
+ test('flags branded values inside arrays (sessionless)', async () => {
161
203
  const criticals = await runInspect(`
162
204
  ${BRAND_TYPES}
163
- import { pikkuFunc } from '@pikku/core'
164
- export const getEmails = pikkuFunc({
205
+ import { pikkuSessionlessFunc } from '@pikku/core'
206
+ export const getEmails = pikkuSessionlessFunc({
165
207
  func: async () => ({ emails: ['x@y.com' as Private<string>] })
166
208
  })
167
209
  `)
@@ -170,11 +212,11 @@ export const getEmails = pikkuFunc({
170
212
  assert.match(hit.message, /emails/)
171
213
  })
172
214
 
173
- test('flags branded values inside string-indexed records', async () => {
215
+ test('flags branded values inside string-indexed records (sessionless)', async () => {
174
216
  const criticals = await runInspect(`
175
217
  ${BRAND_TYPES}
176
- import { pikkuFunc } from '@pikku/core'
177
- export const getMap = pikkuFunc({
218
+ import { pikkuSessionlessFunc } from '@pikku/core'
219
+ export const getMap = pikkuSessionlessFunc({
178
220
  func: async () => ({ byId: { a: 'x@y.com' as Private<string> } as Record<string, Private<string>> })
179
221
  })
180
222
  `)
@@ -186,8 +228,8 @@ export const getMap = pikkuFunc({
186
228
  test('does not flag when branded field is stripped before return', async () => {
187
229
  const criticals = await runInspect(`
188
230
  ${BRAND_TYPES}
189
- import { pikkuFunc } from '@pikku/core'
190
- export const getUser = pikkuFunc({
231
+ import { pikkuSessionlessFunc } from '@pikku/core'
232
+ export const getUser = pikkuSessionlessFunc({
191
233
  func: async () => {
192
234
  const raw: { email: Private<string> } = { email: 'x' as Private<string> }
193
235
  const safe: { email: string } = { email: raw.email as string }