@pikku/inspector 0.12.12 → 0.12.14

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,48 @@
1
+ ## 0.12.14
2
+
3
+ ### Patch Changes
4
+
5
+ - 4b5c75b: feat(auth-js): wire OIDC config (issuer/tenantId) as variables, expand provider registry
6
+ - Move `issuer` and `tenantId` out of the secret blob for OIDC providers (auth0, okta, azure-ad, keycloak, cognito, microsoft-entra-id) — they are public config URLs, not secrets. Now registered via `wireVariable` and loaded at runtime via `services.variables.get()`.
7
+ - Expand provider registry from 13 to 31 providers: reddit, notion, instagram, zoom, figma, tiktok, threads, patreon, dropbox, bitbucket, hubspot, salesforce, atlassian, strava, keycloak, cognito, microsoft-entra-id added.
8
+ - `serialize-auth-gen` emits `wireVariable({...})` declarations and `services.variables.get()` calls in the generated factory for OIDC providers.
9
+ - Integration verifier exercises real `/auth/providers` endpoint with `LocalSecretService` + `LocalVariablesService`, including a spy test proving `services.variables.get('AUTH0_ISSUER')` is called at request time.
10
+
11
+ - 4b5c75b: Add end-to-end data classification for SQLite and Postgres projects.
12
+
13
+ **Core (`@pikku/core`):** New `Private<T>` and `Secret<T>` intersection brands, `ClassificationManifest`, `ColumnClassification`, and `AnonymizeStrategy` types exported from `data-classification.ts`.
14
+
15
+ **CLI (`@pikku/cli`):**
16
+ - SQL comment annotations: `-- @public`, `-- @private[:strategy]`, `-- @secret[:strategy]` on `CREATE TABLE` columns and `ALTER TABLE ... ADD COLUMN` statements. Unannotated columns default to `private`.
17
+ - `pikku db migrate` now emits a `classification.gen.ts` manifest alongside `schema.d.ts`.
18
+ - New `pikku db audit` command — prints a per-column classification summary and warns on `private`/`secret` columns with no anonymize strategy.
19
+ - Postgres dialect support in `resolveDb`, `PostgresMigrationExecutor`, and `PostgresIntrospector`.
20
+
21
+ **Inspector (`@pikku/inspector`):** New PKU910 check — `findPiiPaths()` walks inferred function return types looking for `__pii__` brands (including inside `Array<T>`, `Record<K,V>`, and index signatures) and fails the build if a function exposes branded fields in its output.
22
+
23
+ - Updated dependencies [4b5c75b]
24
+ - Updated dependencies [4b5c75b]
25
+ - @pikku/core@0.12.27
26
+
27
+ ## 0.12.13
28
+
29
+ ### Patch Changes
30
+
31
+ - 665bdb0: Add end-to-end data classification for SQLite and Postgres projects.
32
+
33
+ **Core (`@pikku/core`):** New `Private<T>` and `Secret<T>` intersection brands, `ClassificationManifest`, `ColumnClassification`, and `AnonymizeStrategy` types exported from `data-classification.ts`.
34
+
35
+ **CLI (`@pikku/cli`):**
36
+ - SQL comment annotations: `-- @public`, `-- @private[:strategy]`, `-- @secret[:strategy]` on `CREATE TABLE` columns and `ALTER TABLE ... ADD COLUMN` statements. Unannotated columns default to `private`.
37
+ - `pikku db migrate` now emits a `classification.gen.ts` manifest alongside `schema.d.ts`.
38
+ - New `pikku db audit` command — prints a per-column classification summary and warns on `private`/`secret` columns with no anonymize strategy.
39
+ - Postgres dialect support in `resolveDb`, `PostgresMigrationExecutor`, and `PostgresIntrospector`.
40
+
41
+ **Inspector (`@pikku/inspector`):** New PKU910 check — `findPiiPaths()` walks inferred function return types looking for `__pii__` brands (including inside `Array<T>`, `Record<K,V>`, and index signatures) and fails the build if a function exposes branded fields in its output.
42
+
43
+ - Updated dependencies [665bdb0]
44
+ - @pikku/core@0.12.25
45
+
1
46
  ## 0.12.12
2
47
 
3
48
  ### Patch Changes
@@ -0,0 +1,2 @@
1
+ import type { AddWiring } from '../types.js';
2
+ export declare const addAuth: AddWiring;
@@ -0,0 +1,34 @@
1
+ import * as ts from 'typescript';
2
+ import { ErrorCode } from '../error-codes.js';
3
+ export const addAuth = (logger, node, _checker, state) => {
4
+ if (!ts.isCallExpression(node))
5
+ return;
6
+ const expression = node.expression;
7
+ if (!ts.isIdentifier(expression) || expression.text !== 'wireAuth')
8
+ return;
9
+ const firstArg = node.arguments[0];
10
+ if (!firstArg || !ts.isObjectLiteralExpression(firstArg))
11
+ return;
12
+ const providersProp = firstArg.properties.find((p) => ts.isPropertyAssignment(p) &&
13
+ (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) &&
14
+ p.name.text === 'providers');
15
+ const sourceFile = node.getSourceFile().fileName;
16
+ state.auth.files.add(sourceFile);
17
+ if (!providersProp) {
18
+ return;
19
+ }
20
+ if (!ts.isArrayLiteralExpression(providersProp.initializer)) {
21
+ logger.critical(ErrorCode.MISSING_NAME, 'wireAuth: providers must be an array literal of string literals.');
22
+ return;
23
+ }
24
+ for (const element of providersProp.initializer
25
+ .elements) {
26
+ if (!ts.isStringLiteral(element)) {
27
+ logger.critical(ErrorCode.NON_LITERAL_WIRE_NAME, `wireAuth: each provider must be a string literal. Found: ${element.getText()}`);
28
+ return;
29
+ }
30
+ if (!state.auth.providers.includes(element.text)) {
31
+ state.auth.providers.push(element.text);
32
+ }
33
+ }
34
+ };
@@ -10,6 +10,7 @@ import { resolveMiddleware } from '../utils/middleware.js';
10
10
  import { resolvePermissions } from '../utils/permissions.js';
11
11
  import { extractWireNames } from '../utils/post-process.js';
12
12
  import { ErrorCode } from '../error-codes.js';
13
+ import { findPiiPaths } from '../utils/check-pii-output.js';
13
14
  const isValidVariableName = (name) => {
14
15
  const regex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
15
16
  return regex.test(name);
@@ -626,6 +627,25 @@ export const addFunctions = (logger, node, checker, state, options) => {
626
627
  }
627
628
  }
628
629
  }
630
+ // ── PII brand check ───────────────────────────────────────────────────────
631
+ // Walk the function body's ACTUAL inferred return type looking for Private<T>
632
+ // / Pii<T> / Secret<T> brands (__classification__ property). This runs for every function,
633
+ // including those with a Zod output schema, because the TS return type
634
+ // reflects what the body actually returns before any Zod coercion.
635
+ {
636
+ const sig = checker.getSignatureFromDeclaration(handler);
637
+ if (sig) {
638
+ const rawRet = checker.getReturnTypeOfSignature(sig);
639
+ const unwrapped = unwrapPromise(checker, rawRet);
640
+ const piiPaths = findPiiPaths(checker, unwrapped);
641
+ if (piiPaths.length > 0) {
642
+ logger.critical(ErrorCode.PII_IN_OUTPUT, `Function '${name}' exposes PII-classified field(s) in its return type: ` +
643
+ piiPaths.map((p) => `'${p}'`).join(', ') +
644
+ `.\n Either strip these fields before returning or mark the column ` +
645
+ `@public in the migration if it is safe to expose.`);
646
+ }
647
+ }
648
+ }
629
649
  // --- resolve middleware ---
630
650
  let middleware = objectNode
631
651
  ? resolveMiddleware(state, objectNode, tags, checker)
@@ -54,5 +54,6 @@ export declare enum ErrorCode {
54
54
  SCHEMA_AND_WIRING_COLOCATED = "PKU490",
55
55
  SERVICES_NOT_DESTRUCTURED = "PKU410",
56
56
  WIRES_NOT_DESTRUCTURED = "PKU411",
57
- WORKFLOW_MULTI_QUEUE_NOT_SUPPORTED = "PKU901"
57
+ WORKFLOW_MULTI_QUEUE_NOT_SUPPORTED = "PKU901",
58
+ PII_IN_OUTPUT = "PKU910"
58
59
  }
@@ -67,4 +67,6 @@ export var ErrorCode;
67
67
  ErrorCode["WIRES_NOT_DESTRUCTURED"] = "PKU411";
68
68
  // Feature Flag
69
69
  ErrorCode["WORKFLOW_MULTI_QUEUE_NOT_SUPPORTED"] = "PKU901";
70
+ // Data classification errors
71
+ ErrorCode["PII_IN_OUTPUT"] = "PKU910";
70
72
  })(ErrorCode || (ErrorCode = {}));
package/dist/inspector.js CHANGED
@@ -115,6 +115,10 @@ export function getInitialInspectorState(rootDir) {
115
115
  meta: {},
116
116
  files: new Set(),
117
117
  },
118
+ auth: {
119
+ providers: [],
120
+ files: new Set(),
121
+ },
118
122
  secrets: {
119
123
  definitions: [],
120
124
  files: new Set(),
package/dist/types.d.ts CHANGED
@@ -339,6 +339,10 @@ export interface InspectorState {
339
339
  meta: NodesMeta;
340
340
  files: Set<string>;
341
341
  };
342
+ auth: {
343
+ providers: string[];
344
+ files: Set<string>;
345
+ };
342
346
  secrets: {
343
347
  definitions: SecretDefinitions;
344
348
  files: Set<string>;
@@ -0,0 +1,14 @@
1
+ import * as ts from 'typescript';
2
+ /**
3
+ * Recursively walks a resolved TypeScript type looking for `__classification__` brands —
4
+ * the structural marker emitted by `Private<T>` and `Secret<T>`.
5
+ *
6
+ * `Private<T> = T & { readonly __classification__: 'private' }` shows up in the TS type
7
+ * system as an intersection whose constituents include a type with a `__classification__`
8
+ * property. We detect that by checking whether any constituent of an
9
+ * intersection exposes a property named `__classification__`.
10
+ *
11
+ * Returns the list of dotted field paths where a brand was found
12
+ * (e.g. `['email', 'address.phone']`). An empty array means clean.
13
+ */
14
+ export declare function findPiiPaths(checker: ts.TypeChecker, type: ts.Type, path?: string, depth?: number, seen?: Set<ts.Type>): string[];
@@ -0,0 +1,63 @@
1
+ import * as ts from 'typescript';
2
+ /**
3
+ * Recursively walks a resolved TypeScript type looking for `__classification__` brands —
4
+ * the structural marker emitted by `Private<T>` and `Secret<T>`.
5
+ *
6
+ * `Private<T> = T & { readonly __classification__: 'private' }` shows up in the TS type
7
+ * system as an intersection whose constituents include a type with a `__classification__`
8
+ * property. We detect that by checking whether any constituent of an
9
+ * intersection exposes a property named `__classification__`.
10
+ *
11
+ * Returns the list of dotted field paths where a brand was found
12
+ * (e.g. `['email', 'address.phone']`). An empty array means clean.
13
+ */
14
+ export function findPiiPaths(checker, type, path = '', depth = 0, seen = new Set()) {
15
+ if (depth > 8 || seen.has(type))
16
+ return [];
17
+ seen.add(type);
18
+ // ── Is this type itself branded? ─────────────────────────────────────────
19
+ // Private<T> = T & { readonly __classification__: 'private' } → isIntersection()
20
+ // where one constituent has a `__classification__` property.
21
+ if (type.isIntersection()) {
22
+ const branded = type.types.some((t) => t.getProperties().some((p) => p.name === '__classification__'));
23
+ if (branded) {
24
+ return [path || '<return value>'];
25
+ }
26
+ }
27
+ const violations = [];
28
+ // ── Union: check every branch ─────────────────────────────────────────────
29
+ if (type.isUnion()) {
30
+ for (const branch of type.types) {
31
+ violations.push(...findPiiPaths(checker, branch, path, depth, seen));
32
+ }
33
+ return violations;
34
+ }
35
+ // ── Object: recurse into named properties ─────────────────────────────────
36
+ if (type.flags & ts.TypeFlags.Object) {
37
+ const ref = type;
38
+ for (const arg of ref.typeArguments ?? []) {
39
+ violations.push(...findPiiPaths(checker, arg, path, depth + 1, seen));
40
+ }
41
+ const numberIndex = checker.getIndexTypeOfType(type, ts.IndexKind.Number);
42
+ if (numberIndex) {
43
+ const idxPath = path ? `${path}[]` : '[]';
44
+ violations.push(...findPiiPaths(checker, numberIndex, idxPath, depth + 1, seen));
45
+ }
46
+ const stringIndex = checker.getIndexTypeOfType(type, ts.IndexKind.String);
47
+ if (stringIndex) {
48
+ const idxPath = path ? `${path}[*]` : '[*]';
49
+ violations.push(...findPiiPaths(checker, stringIndex, idxPath, depth + 1, seen));
50
+ }
51
+ for (const prop of type.getProperties()) {
52
+ if (prop.name.startsWith('__'))
53
+ continue;
54
+ const decl = prop.valueDeclaration ?? prop.declarations?.[0];
55
+ if (!decl)
56
+ continue;
57
+ const propType = checker.getTypeOfSymbolAtLocation(prop, decl);
58
+ const subPath = path ? `${path}.${prop.name}` : prop.name;
59
+ violations.push(...findPiiPaths(checker, propType, subPath, depth + 1, seen));
60
+ }
61
+ }
62
+ return violations;
63
+ }
@@ -203,6 +203,10 @@ export interface SerializableInspectorState {
203
203
  meta: InspectorState['nodes']['meta'];
204
204
  files: string[];
205
205
  };
206
+ auth: {
207
+ providers: string[];
208
+ files: string[];
209
+ };
206
210
  secrets: {
207
211
  definitions: InspectorState['secrets']['definitions'];
208
212
  files: string[];
@@ -98,6 +98,10 @@ export function serializeInspectorState(state) {
98
98
  meta: state.nodes.meta,
99
99
  files: Array.from(state.nodes.files),
100
100
  },
101
+ auth: {
102
+ providers: state.auth.providers,
103
+ files: Array.from(state.auth.files),
104
+ },
101
105
  secrets: {
102
106
  definitions: state.secrets.definitions,
103
107
  files: Array.from(state.secrets.files),
@@ -246,6 +250,10 @@ export function deserializeInspectorState(data) {
246
250
  meta: data.nodes?.meta || {},
247
251
  files: new Set(data.nodes?.files || []),
248
252
  },
253
+ auth: {
254
+ providers: data.auth?.providers || [],
255
+ files: new Set(data.auth?.files || []),
256
+ },
249
257
  secrets: {
250
258
  definitions: data.secrets?.definitions || [],
251
259
  files: new Set(data.secrets?.files || []),
package/dist/visit.js CHANGED
@@ -17,6 +17,7 @@ import { addWireAddon } from './add/add-wire-addon.js';
17
17
  import { addMiddleware } from './add/add-middleware.js';
18
18
  import { addPermission } from './add/add-permission.js';
19
19
  import { addCLI, addCLIRenderers } from './add/add-cli.js';
20
+ import { addAuth } from './add/add-auth.js';
20
21
  import { addSecret } from './add/add-secret.js';
21
22
  import { addCredential } from './add/add-credential.js';
22
23
  import { addVariable } from './add/add-variable.js';
@@ -41,6 +42,7 @@ export const visitSetup = (logger, checker, node, state, options) => {
41
42
  };
42
43
  export const visitRoutes = (logger, checker, node, state, options) => {
43
44
  addFunctions(logger, node, checker, state, options);
45
+ addAuth(logger, node, checker, state, options);
44
46
  addSecret(logger, node, checker, state, options);
45
47
  addCredential(logger, node, checker, state, options);
46
48
  addVariable(logger, node, checker, state, options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.12",
3
+ "version": "0.12.14",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "BUSL-1.1",
6
6
  "type": "module",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@openapi-contrib/json-schema-to-openapi-schema": "^4.3.1",
38
- "@pikku/core": "^0.12.21",
38
+ "@pikku/core": "^0.12.27",
39
39
  "path-to-regexp": "^8.3.0",
40
40
  "ts-json-schema-generator": "^2.5.0",
41
41
  "tsx": "^4.21.0",
@@ -0,0 +1,175 @@
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
+ const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
11
+ ({
12
+ debug: () => {},
13
+ info: () => {},
14
+ warn: () => {},
15
+ error: () => {},
16
+ critical: (code: ErrorCode, message: string) => {
17
+ criticals.push({ code, message })
18
+ },
19
+ hasCriticalErrors: () => criticals.length > 0,
20
+ }) satisfies InspectorLogger
21
+
22
+ describe('addAuth inspector', () => {
23
+ test('extracts provider string literals from wireAuth call', async () => {
24
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-'))
25
+ const file = join(rootDir, 'auth.ts')
26
+
27
+ await writeFile(
28
+ file,
29
+ [
30
+ "import { wireAuth } from '@pikku/auth-js'",
31
+ "wireAuth({ providers: ['github', 'google'] })",
32
+ ].join('\n')
33
+ )
34
+
35
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
36
+ try {
37
+ const state = await inspect(makeLogger(criticals), [file], { rootDir })
38
+ assert.equal(criticals.length, 0)
39
+ assert.deepEqual(state.auth.providers, ['github', 'google'])
40
+ } finally {
41
+ await rm(rootDir, { recursive: true, force: true })
42
+ }
43
+ })
44
+
45
+ test('deduplicates providers across multiple wireAuth calls', async () => {
46
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-dedup-'))
47
+ const file = join(rootDir, 'auth.ts')
48
+
49
+ await writeFile(
50
+ file,
51
+ [
52
+ "import { wireAuth } from '@pikku/auth-js'",
53
+ "wireAuth({ providers: ['github'] })",
54
+ "wireAuth({ providers: ['github', 'google'] })",
55
+ ].join('\n')
56
+ )
57
+
58
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
59
+ try {
60
+ const state = await inspect(makeLogger(criticals), [file], { rootDir })
61
+ assert.equal(criticals.length, 0)
62
+ assert.deepEqual(state.auth.providers, ['github', 'google'])
63
+ } finally {
64
+ await rm(rootDir, { recursive: true, force: true })
65
+ }
66
+ })
67
+
68
+ test('logs critical error when a provider is a non-literal reference', async () => {
69
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-nonlit-'))
70
+ const file = join(rootDir, 'auth.ts')
71
+
72
+ await writeFile(
73
+ file,
74
+ [
75
+ "import { wireAuth } from '@pikku/auth-js'",
76
+ "const PROVIDER = 'github'",
77
+ 'wireAuth({ providers: [PROVIDER] })',
78
+ ].join('\n')
79
+ )
80
+
81
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
82
+ try {
83
+ await inspect(makeLogger(criticals), [file], { rootDir })
84
+ const hit = criticals.find(
85
+ (e) => e.code === ErrorCode.NON_LITERAL_WIRE_NAME
86
+ )
87
+ assert.ok(hit, 'expected NON_LITERAL_WIRE_NAME critical')
88
+ assert.match(hit!.message, /PROVIDER/)
89
+ } finally {
90
+ await rm(rootDir, { recursive: true, force: true })
91
+ }
92
+ })
93
+
94
+ test('does not error when providers is absent (credentials-only wireAuth)', async () => {
95
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-creds-only-'))
96
+ const file = join(rootDir, 'auth.ts')
97
+
98
+ await writeFile(
99
+ file,
100
+ [
101
+ "import { wireAuth } from '@pikku/auth-js'",
102
+ 'wireAuth({ credentials: { authorize: async () => null } })',
103
+ ].join('\n')
104
+ )
105
+
106
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
107
+ try {
108
+ const state = await inspect(makeLogger(criticals), [file], { rootDir })
109
+ assert.equal(
110
+ criticals.length,
111
+ 0,
112
+ 'credentials-only wireAuth must not produce errors'
113
+ )
114
+ assert.deepEqual(
115
+ state.auth.providers,
116
+ [],
117
+ 'no providers should be extracted'
118
+ )
119
+ assert.ok(state.auth.files.has(file), 'source file still tracked')
120
+ } finally {
121
+ await rm(rootDir, { recursive: true, force: true })
122
+ }
123
+ })
124
+
125
+ test('logs critical error when providers is not an array literal', async () => {
126
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-nonarray-'))
127
+ const file = join(rootDir, 'auth.ts')
128
+
129
+ await writeFile(
130
+ file,
131
+ [
132
+ "import { wireAuth } from '@pikku/auth-js'",
133
+ "const PROVIDERS = ['github']",
134
+ 'wireAuth({ providers: PROVIDERS })',
135
+ ].join('\n')
136
+ )
137
+
138
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
139
+ try {
140
+ await inspect(makeLogger(criticals), [file], { rootDir })
141
+ const hit = criticals.find((e) => e.code === ErrorCode.MISSING_NAME)
142
+ assert.ok(
143
+ hit,
144
+ 'expected MISSING_NAME critical for non-array-literal providers'
145
+ )
146
+ } finally {
147
+ await rm(rootDir, { recursive: true, force: true })
148
+ }
149
+ })
150
+
151
+ test('tracks source file in state.auth.files', async () => {
152
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-files-'))
153
+ const file = join(rootDir, 'auth.wiring.ts')
154
+
155
+ await writeFile(
156
+ file,
157
+ [
158
+ "import { wireAuth } from '@pikku/auth-js'",
159
+ "wireAuth({ providers: ['discord'] })",
160
+ ].join('\n')
161
+ )
162
+
163
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
164
+ try {
165
+ const state = await inspect(makeLogger(criticals), [file], { rootDir })
166
+ assert.equal(criticals.length, 0)
167
+ assert.ok(
168
+ state.auth.files.has(file),
169
+ 'source file should be tracked in state.auth.files'
170
+ )
171
+ } finally {
172
+ await rm(rootDir, { recursive: true, force: true })
173
+ }
174
+ })
175
+ })
@@ -0,0 +1,49 @@
1
+ import * as ts from 'typescript'
2
+ import type { AddWiring } from '../types.js'
3
+ import { ErrorCode } from '../error-codes.js'
4
+
5
+ export const addAuth: AddWiring = (logger, node, _checker, state) => {
6
+ if (!ts.isCallExpression(node)) return
7
+
8
+ const expression = node.expression
9
+ if (!ts.isIdentifier(expression) || expression.text !== 'wireAuth') return
10
+
11
+ const firstArg = node.arguments[0]
12
+ if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) return
13
+
14
+ const providersProp = firstArg.properties.find(
15
+ (p) =>
16
+ ts.isPropertyAssignment(p) &&
17
+ (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) &&
18
+ p.name.text === 'providers'
19
+ ) as ts.PropertyAssignment | undefined
20
+
21
+ const sourceFile = node.getSourceFile().fileName
22
+ state.auth.files.add(sourceFile)
23
+
24
+ if (!providersProp) {
25
+ return
26
+ }
27
+
28
+ if (!ts.isArrayLiteralExpression(providersProp.initializer)) {
29
+ logger.critical(
30
+ ErrorCode.MISSING_NAME,
31
+ 'wireAuth: providers must be an array literal of string literals.'
32
+ )
33
+ return
34
+ }
35
+
36
+ for (const element of (providersProp.initializer as ts.ArrayLiteralExpression)
37
+ .elements) {
38
+ if (!ts.isStringLiteral(element)) {
39
+ logger.critical(
40
+ ErrorCode.NON_LITERAL_WIRE_NAME,
41
+ `wireAuth: each provider must be a string literal. Found: ${element.getText()}`
42
+ )
43
+ return
44
+ }
45
+ if (!state.auth.providers.includes(element.text)) {
46
+ state.auth.providers.push(element.text)
47
+ }
48
+ }
49
+ }
@@ -19,6 +19,7 @@ import { resolveMiddleware } from '../utils/middleware.js'
19
19
  import { resolvePermissions } from '../utils/permissions.js'
20
20
  import { extractWireNames } from '../utils/post-process.js'
21
21
  import { ErrorCode } from '../error-codes.js'
22
+ import { findPiiPaths } from '../utils/check-pii-output.js'
22
23
  import type { NodeType } from '@pikku/core/node'
23
24
 
24
25
  const isValidVariableName = (name: string) => {
@@ -882,6 +883,29 @@ export const addFunctions: AddWiring = (
882
883
  }
883
884
  }
884
885
 
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.
891
+ {
892
+ const sig = checker.getSignatureFromDeclaration(handler)
893
+ if (sig) {
894
+ const rawRet = checker.getReturnTypeOfSignature(sig)
895
+ const unwrapped = unwrapPromise(checker, rawRet)
896
+ const piiPaths = findPiiPaths(checker, unwrapped)
897
+ if (piiPaths.length > 0) {
898
+ logger.critical(
899
+ 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.`
904
+ )
905
+ }
906
+ }
907
+ }
908
+
885
909
  // --- resolve middleware ---
886
910
  let middleware = objectNode
887
911
  ? resolveMiddleware(state, objectNode, tags, checker)