@pikku/inspector 0.12.66 → 0.12.68
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 +47 -0
- package/dist/add/add-functions.js +18 -9
- package/dist/error-codes.d.ts +3 -1
- package/dist/error-codes.js +3 -0
- package/dist/utils/check-classified-sinks.d.ts +40 -0
- package/dist/utils/check-classified-sinks.js +170 -0
- package/dist/utils/find-zod-transform.d.ts +21 -0
- package/dist/utils/find-zod-transform.js +93 -0
- package/dist/utils/schema-generator.js +11 -0
- package/package.json +3 -3
- package/src/add/add-functions.ts +19 -9
- package/src/add/{secret-sink-check.test.ts → classified-sink-check.test.ts} +135 -2
- package/src/error-codes.ts +4 -0
- package/src/utils/check-classified-sinks.ts +215 -0
- package/src/utils/find-zod-transform.test.ts +70 -0
- package/src/utils/find-zod-transform.ts +103 -0
- package/src/utils/schema-generator.test.ts +47 -0
- package/src/utils/schema-generator.ts +14 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/src/utils/check-secret-sinks.ts +0 -148
|
@@ -23,11 +23,22 @@ function makeLogger() {
|
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
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,
|
|
27
|
-
*
|
|
26
|
+
* are what matters: `Secret<T>` is the optional brand `.reveal()` returns,
|
|
27
|
+
* `Pii<T>` and `Private<T>` are the brands a generated row type carries, and the
|
|
28
|
+
* service interfaces are matched by name, so these must keep the real names.
|
|
28
29
|
*/
|
|
29
30
|
const PRELUDE = `
|
|
30
31
|
type Secret<T> = T & { readonly __classification__?: 'secret' }
|
|
32
|
+
type Pii<T> = T & { readonly __classification__?: 'pii' }
|
|
33
|
+
type Private<T> = T & { readonly __classification__?: 'private' }
|
|
34
|
+
|
|
35
|
+
/** What \`db.selectFrom('user')\` hands back once the schema is generated. */
|
|
36
|
+
interface UserRow {
|
|
37
|
+
userId: string
|
|
38
|
+
email: Pii<string>
|
|
39
|
+
internalNote: Private<string>
|
|
40
|
+
}
|
|
41
|
+
declare function getUser(): Promise<UserRow>
|
|
31
42
|
|
|
32
43
|
declare class SecretValue<T = string> {
|
|
33
44
|
private brand: T
|
|
@@ -88,6 +99,9 @@ async function runInspect(source: string) {
|
|
|
88
99
|
const sinkErrors = (diagnostics: Array<{ code: ErrorCode; message: string }>) =>
|
|
89
100
|
diagnostics.filter((d) => d.code === ErrorCode.SECRET_REVEALED_INTO_SINK)
|
|
90
101
|
|
|
102
|
+
const piiErrors = (diagnostics: Array<{ code: ErrorCode; message: string }>) =>
|
|
103
|
+
diagnostics.filter((d) => d.code === ErrorCode.PII_INTO_SINK)
|
|
104
|
+
|
|
91
105
|
describe('PKU953 — a revealed secret reaching a sink', () => {
|
|
92
106
|
test('flags a secret logged inline', async () => {
|
|
93
107
|
const diagnostics = await runInspect(`
|
|
@@ -212,3 +226,122 @@ describe('PKU953 — a revealed secret reaching a sink', () => {
|
|
|
212
226
|
assert.equal(sinkErrors(diagnostics).length, 0)
|
|
213
227
|
})
|
|
214
228
|
})
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* PII is not a secret, so the policy is not "never write it down" — it is "not
|
|
232
|
+
* where it leaves your control". A log ships to an aggregator and a webhook
|
|
233
|
+
* posts to somebody else's server; an email is addressed to the data subject,
|
|
234
|
+
* and a queue and an audit stay on the operator's own infrastructure.
|
|
235
|
+
*/
|
|
236
|
+
describe('PKU954 — personal data reaching a sink', () => {
|
|
237
|
+
test('flags PII logged inline and through a local binding', async () => {
|
|
238
|
+
const diagnostics = await runInspect(`
|
|
239
|
+
export const leak = pikkuFunc<void, void>({
|
|
240
|
+
func: async ({ logger }) => {
|
|
241
|
+
const user = await getUser()
|
|
242
|
+
logger.info(user.email)
|
|
243
|
+
const email = user.email
|
|
244
|
+
logger.error('send failed', { email })
|
|
245
|
+
},
|
|
246
|
+
})
|
|
247
|
+
`)
|
|
248
|
+
const errors = piiErrors(diagnostics)
|
|
249
|
+
assert.equal(errors.length, 2)
|
|
250
|
+
assert.match(errors[0]!.message, /logger\.info/)
|
|
251
|
+
assert.match(errors[1]!.message, /logger\.error/)
|
|
252
|
+
assert.equal(sinkErrors(diagnostics).length, 0)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('flags a whole row logged, since the PII rides along inside it', async () => {
|
|
256
|
+
const diagnostics = await runInspect(`
|
|
257
|
+
export const leak = pikkuFunc<void, void>({
|
|
258
|
+
func: async ({ logger }) => {
|
|
259
|
+
const user = await getUser()
|
|
260
|
+
logger.info('loaded', user)
|
|
261
|
+
},
|
|
262
|
+
})
|
|
263
|
+
`)
|
|
264
|
+
assert.equal(piiErrors(diagnostics).length, 1)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test('flags PII on console and in a webhook', async () => {
|
|
268
|
+
const diagnostics = await runInspect(`
|
|
269
|
+
export const leak = pikkuFunc<void, void>({
|
|
270
|
+
func: async ({ webhooks }) => {
|
|
271
|
+
const user = await getUser()
|
|
272
|
+
console.log(user.email)
|
|
273
|
+
await webhooks.send({ url: 'https://x', data: { email: user.email } })
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
`)
|
|
277
|
+
const errors = piiErrors(diagnostics)
|
|
278
|
+
assert.equal(errors.length, 2)
|
|
279
|
+
assert.match(errors[0]!.message, /console\.log/)
|
|
280
|
+
assert.match(errors[1]!.message, /webhooks\.send/)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('stays quiet for an email to the data subject, an audit and a queue', async () => {
|
|
284
|
+
const diagnostics = await runInspect(`
|
|
285
|
+
export const fine = pikkuFunc<void, void>({
|
|
286
|
+
func: async ({ email, audit, auditLog, queueService }) => {
|
|
287
|
+
const user = await getUser()
|
|
288
|
+
await email.send({ to: user.email, text: 'Hi ' + user.email })
|
|
289
|
+
await audit?.audit({ type: 'viewed', input: { email: user.email } })
|
|
290
|
+
await auditLog?.write({ type: 'viewed', input: user.email })
|
|
291
|
+
await queueService.add('welcome', { email: user.email })
|
|
292
|
+
},
|
|
293
|
+
})
|
|
294
|
+
`)
|
|
295
|
+
assert.equal(piiErrors(diagnostics).length, 0)
|
|
296
|
+
assert.equal(sinkErrors(diagnostics).length, 0)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test('stays quiet for private, which is about who may read the row', async () => {
|
|
300
|
+
const diagnostics = await runInspect(`
|
|
301
|
+
export const fine = pikkuFunc<void, void>({
|
|
302
|
+
func: async ({ logger }) => {
|
|
303
|
+
const user = await getUser()
|
|
304
|
+
logger.info('note', { note: user.internalNote, id: user.userId })
|
|
305
|
+
},
|
|
306
|
+
})
|
|
307
|
+
`)
|
|
308
|
+
assert.equal(piiErrors(diagnostics).length, 0)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('stays quiet when an explicit annotation erases the brand', async () => {
|
|
312
|
+
const diagnostics = await runInspect(`
|
|
313
|
+
export const documentedLimit = pikkuFunc<void, void>({
|
|
314
|
+
func: async ({ logger }) => {
|
|
315
|
+
const user = await getUser()
|
|
316
|
+
const email: string = user.email
|
|
317
|
+
logger.info(email)
|
|
318
|
+
},
|
|
319
|
+
})
|
|
320
|
+
`)
|
|
321
|
+
assert.equal(piiErrors(diagnostics).length, 0)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
test('does not run without the classification flag', async () => {
|
|
325
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'pikku-pii-sink-off-'))
|
|
326
|
+
const file = join(tmpDir, 'funcs.ts')
|
|
327
|
+
await writeFile(
|
|
328
|
+
file,
|
|
329
|
+
PRELUDE +
|
|
330
|
+
`
|
|
331
|
+
export const leak = pikkuFunc<void, void>({
|
|
332
|
+
func: async ({ logger }) => {
|
|
333
|
+
const user = await getUser()
|
|
334
|
+
logger.info(user.email)
|
|
335
|
+
},
|
|
336
|
+
})
|
|
337
|
+
`
|
|
338
|
+
)
|
|
339
|
+
const { logger, diagnostics } = makeLogger()
|
|
340
|
+
try {
|
|
341
|
+
await inspect(logger, [file], { rootDir: tmpDir })
|
|
342
|
+
} finally {
|
|
343
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
344
|
+
}
|
|
345
|
+
assert.equal(piiErrors(diagnostics).length, 0)
|
|
346
|
+
})
|
|
347
|
+
})
|
package/src/error-codes.ts
CHANGED
|
@@ -98,6 +98,9 @@ export enum ErrorCode {
|
|
|
98
98
|
// File structure errors
|
|
99
99
|
SCHEMA_AND_WIRING_COLOCATED = 'PKU490',
|
|
100
100
|
|
|
101
|
+
// Schema authoring errors
|
|
102
|
+
SCHEMA_TRANSFORM_NOT_SUPPORTED = 'PKU491',
|
|
103
|
+
|
|
101
104
|
// Optimization diagnostics
|
|
102
105
|
SERVICES_NOT_DESTRUCTURED = 'PKU410',
|
|
103
106
|
WIRES_NOT_DESTRUCTURED = 'PKU411',
|
|
@@ -123,6 +126,7 @@ export enum ErrorCode {
|
|
|
123
126
|
SECRET_NOT_DECLARED = 'PKU951',
|
|
124
127
|
SECRET_KEY_NOT_STATIC = 'PKU952',
|
|
125
128
|
SECRET_REVEALED_INTO_SINK = 'PKU953',
|
|
129
|
+
PII_INTO_SINK = 'PKU954',
|
|
126
130
|
}
|
|
127
131
|
|
|
128
132
|
/**
|
|
@@ -0,0 +1,215 @@
|
|
|
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 SinkClassification = 'secret' | 'pii'
|
|
6
|
+
|
|
7
|
+
export type SinkViolation = {
|
|
8
|
+
/** How the value reaches the outside world, e.g. `logger.info`. */
|
|
9
|
+
sink: string
|
|
10
|
+
/** Dotted path to the secret inside the argument, or `<argument>` when bare. */
|
|
11
|
+
path: string
|
|
12
|
+
/** Position of the offending argument, for the diagnostic message. */
|
|
13
|
+
line: number
|
|
14
|
+
/** Which brand was found, so the caller can pick the diagnostic. */
|
|
15
|
+
classification: SinkClassification
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A method that writes its arguments somewhere a classified value must never
|
|
20
|
+
* land, keyed by the core interface that owns it. `'*'` means every method on
|
|
21
|
+
* the type is a sink, which is true of a logger.
|
|
22
|
+
*
|
|
23
|
+
* `rejects` differs per sink because the two brands leak differently. A vault
|
|
24
|
+
* secret is wrong everywhere: no sink has a legitimate reason to receive one.
|
|
25
|
+
* PII is wrong only where it leaves the operator's control, or lands somewhere
|
|
26
|
+
* never designed to hold it:
|
|
27
|
+
*
|
|
28
|
+
* - a log is read far more widely than the record it came from, is usually
|
|
29
|
+
* shipped to a third-party aggregator, and is retained long past the
|
|
30
|
+
* lifetime of any consent — so PII does not belong in one;
|
|
31
|
+
* - a webhook posts to somebody else's server, which is the same disclosure
|
|
32
|
+
* with a different transport;
|
|
33
|
+
* - an email is addressed to the data subject, and their own name in it is
|
|
34
|
+
* the point;
|
|
35
|
+
* - an audit is a record in the operator's own database, and naming who did
|
|
36
|
+
* what is usually the legal requirement rather than a breach of it;
|
|
37
|
+
* - a queue payload stays on the operator's own infrastructure and is
|
|
38
|
+
* consumed by their own worker.
|
|
39
|
+
*
|
|
40
|
+
* `private` is deliberately absent everywhere: it marks a value that is not
|
|
41
|
+
* public, which is a statement about who may read the row — not a claim that
|
|
42
|
+
* writing it down is a disclosure.
|
|
43
|
+
*/
|
|
44
|
+
const SINKS: Array<{
|
|
45
|
+
coreType: string
|
|
46
|
+
methods: '*' | string[]
|
|
47
|
+
rejects: SinkClassification[]
|
|
48
|
+
}> = [
|
|
49
|
+
{ coreType: 'Logger', methods: '*', rejects: ['secret', 'pii'] },
|
|
50
|
+
{ coreType: 'WebhookService', methods: ['send'], rejects: ['secret', 'pii'] },
|
|
51
|
+
{ coreType: 'QueueService', methods: ['add'], rejects: ['secret'] },
|
|
52
|
+
{ coreType: 'EmailService', methods: ['send'], rejects: ['secret'] },
|
|
53
|
+
{
|
|
54
|
+
coreType: 'AuditService',
|
|
55
|
+
methods: ['audit', 'write'],
|
|
56
|
+
rejects: ['secret'],
|
|
57
|
+
},
|
|
58
|
+
{ coreType: 'AuditLog', methods: ['write'], rejects: ['secret'] },
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
/** `console` has no Pikku type, and is read as widely as any logger. */
|
|
62
|
+
const CONSOLE_REJECTS: SinkClassification[] = ['secret', 'pii']
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Finds revealed vault secrets — and, for the sinks that reject them, PII
|
|
66
|
+
* columns — that flow into a sink.
|
|
67
|
+
*
|
|
68
|
+
* `SecretValue` is nominally typed, so the type system alone stops it reaching
|
|
69
|
+
* any of these. `.reveal()` is the deliberate escape hatch, and it hands back
|
|
70
|
+
* `Secret<T>` — the erasable classification brand. That brand is what this scan
|
|
71
|
+
* follows, which means it catches the revealed value both inline
|
|
72
|
+
* (`logger.info(s.reveal())`) and through a local binding, because the local's
|
|
73
|
+
* inferred type carries the brand too.
|
|
74
|
+
*
|
|
75
|
+
* PII arrives by the same route without a `.reveal()`: `Pii<T>` is the brand the
|
|
76
|
+
* generated row types carry, so a column read straight out of the database keeps
|
|
77
|
+
* it through a local binding and into the call.
|
|
78
|
+
*
|
|
79
|
+
* The brand is optional by design, so an explicit annotation (`const t: string =
|
|
80
|
+
* s.reveal()`, or `const email: string = user.email`) erases it and this scan
|
|
81
|
+
* goes quiet. That is the documented limit
|
|
82
|
+
* of a structural marker: it catches the mistake, not someone determined to
|
|
83
|
+
* silence it.
|
|
84
|
+
*
|
|
85
|
+
* Sinks are matched on the receiver's resolved core interface, which means the
|
|
86
|
+
* scan needs the services parameter to be typed. Real projects get that from
|
|
87
|
+
* the generated `#pikku` factory; a hand-rolled file that calls the raw core
|
|
88
|
+
* factory without annotating its services leaves the parameter `any`, and an
|
|
89
|
+
* `any` receiver matches nothing.
|
|
90
|
+
*/
|
|
91
|
+
export function findClassifiedSinks(
|
|
92
|
+
checker: ts.TypeChecker,
|
|
93
|
+
handler: ts.Node
|
|
94
|
+
): SinkViolation[] {
|
|
95
|
+
const violations: SinkViolation[] = []
|
|
96
|
+
|
|
97
|
+
const visit = (node: ts.Node): void => {
|
|
98
|
+
if (
|
|
99
|
+
ts.isCallExpression(node) &&
|
|
100
|
+
ts.isPropertyAccessExpression(node.expression)
|
|
101
|
+
) {
|
|
102
|
+
const sink = describeSink(checker, node.expression)
|
|
103
|
+
if (sink) {
|
|
104
|
+
for (const arg of node.arguments) {
|
|
105
|
+
const found = findClassifiedExpression(checker, arg, sink.rejects)
|
|
106
|
+
if (found) {
|
|
107
|
+
violations.push({
|
|
108
|
+
sink: sink.name,
|
|
109
|
+
path: found.node.getText(),
|
|
110
|
+
line:
|
|
111
|
+
found.node
|
|
112
|
+
.getSourceFile()
|
|
113
|
+
.getLineAndCharacterOfPosition(found.node.getStart()).line +
|
|
114
|
+
1,
|
|
115
|
+
classification: found.classification,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
ts.forEachChild(node, visit)
|
|
122
|
+
}
|
|
123
|
+
visit(handler)
|
|
124
|
+
|
|
125
|
+
return violations
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Finds the first expression inside a sink argument whose own type carries one
|
|
130
|
+
* of the brands this sink rejects, and returns it so the diagnostic can quote
|
|
131
|
+
* it.
|
|
132
|
+
*
|
|
133
|
+
* This walks the argument rather than typing it whole, because an object
|
|
134
|
+
* literal in an argument position is contextually typed by the parameter — for
|
|
135
|
+
* a guarded sink that parameter is `Safe<M>`, and resolving the literal against
|
|
136
|
+
* it drops the optional brand before it can be read. Each inner expression
|
|
137
|
+
* (`token.reveal()`, or a reference to a local holding it) carries its own type
|
|
138
|
+
* regardless of the surrounding context, so reading those is what actually
|
|
139
|
+
* sees the secret.
|
|
140
|
+
*/
|
|
141
|
+
function findClassifiedExpression(
|
|
142
|
+
checker: ts.TypeChecker,
|
|
143
|
+
node: ts.Node,
|
|
144
|
+
rejects: SinkClassification[],
|
|
145
|
+
/**
|
|
146
|
+
* The node is only being read to reach a property of it, so its own type says
|
|
147
|
+
* nothing about what the call receives. Without this, `user.userId` reports
|
|
148
|
+
* the row's `email` column: the walk types the `user` receiver on its way
|
|
149
|
+
* down, and a row type is branded wherever any of its columns are.
|
|
150
|
+
*/
|
|
151
|
+
isReceiver = false
|
|
152
|
+
): { node: ts.Expression; classification: SinkClassification } | undefined {
|
|
153
|
+
// Descend first, so an object literal wrapping the secret reports the
|
|
154
|
+
// expression that actually holds it (`token.reveal()`) rather than the whole
|
|
155
|
+
// literal. Nothing inside a `SecretValue` receiver is itself branded, so a
|
|
156
|
+
// bare `token.reveal()` still falls through to the self-check below.
|
|
157
|
+
for (const child of node.getChildren()) {
|
|
158
|
+
// A property name is not a value, and typing it re-resolves the property.
|
|
159
|
+
if (ts.isPropertyAssignment(node) && child === node.name) continue
|
|
160
|
+
const childIsReceiver =
|
|
161
|
+
(ts.isPropertyAccessExpression(node) ||
|
|
162
|
+
ts.isElementAccessExpression(node)) &&
|
|
163
|
+
child === node.expression
|
|
164
|
+
const found = findClassifiedExpression(
|
|
165
|
+
checker,
|
|
166
|
+
child,
|
|
167
|
+
rejects,
|
|
168
|
+
childIsReceiver
|
|
169
|
+
)
|
|
170
|
+
if (found) return found
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!isReceiver && ts.isExpression(node) && !ts.isStringLiteralLike(node)) {
|
|
174
|
+
const type = checker.getTypeAtLocation(node)
|
|
175
|
+
/* `secret` outranks `pii` when a value somehow carries both, so the caller
|
|
176
|
+
reports the more serious of the two rather than whichever came first. */
|
|
177
|
+
for (const classification of ['secret', 'pii'] as const) {
|
|
178
|
+
if (!rejects.includes(classification)) continue
|
|
179
|
+
const hit = findPiiPaths(checker, type).some(
|
|
180
|
+
(f) => f.classification === classification
|
|
181
|
+
)
|
|
182
|
+
if (hit) return { node, classification }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return undefined
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Names the sink a call writes to, or undefined when the call is not one.
|
|
191
|
+
* `console` is matched by identifier because it is a global with no Pikku type;
|
|
192
|
+
* everything else is matched on its resolved core interface so a renamed
|
|
193
|
+
* service or a decorator still counts.
|
|
194
|
+
*/
|
|
195
|
+
function describeSink(
|
|
196
|
+
checker: ts.TypeChecker,
|
|
197
|
+
callee: ts.PropertyAccessExpression
|
|
198
|
+
): { name: string; rejects: SinkClassification[] } | undefined {
|
|
199
|
+
const method = callee.name.text
|
|
200
|
+
const receiver = callee.expression
|
|
201
|
+
|
|
202
|
+
if (ts.isIdentifier(receiver) && receiver.text === 'console') {
|
|
203
|
+
return { name: `console.${method}`, rejects: CONSOLE_REJECTS }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const receiverType = checker.getTypeAtLocation(receiver)
|
|
207
|
+
for (const { coreType, methods, rejects } of SINKS) {
|
|
208
|
+
if (methods !== '*' && !methods.includes(method)) continue
|
|
209
|
+
if (doesTypeExtendsCore(receiverType, checker, new Set(), coreType)) {
|
|
210
|
+
return { name: `${receiver.getText()}.${method}`, rejects }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return undefined
|
|
215
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import * as z from 'zod'
|
|
4
|
+
import { findZodTransform } from './find-zod-transform.js'
|
|
5
|
+
|
|
6
|
+
test('a schema with no transform is reported as clean', () => {
|
|
7
|
+
assert.equal(findZodTransform(z.object({ id: z.string() })), undefined)
|
|
8
|
+
assert.equal(findZodTransform(z.string().min(1).trim()), undefined)
|
|
9
|
+
// a refinement narrows the value, it does not change its shape
|
|
10
|
+
assert.equal(
|
|
11
|
+
findZodTransform(z.object({ id: z.string() }).refine(() => true)),
|
|
12
|
+
undefined
|
|
13
|
+
)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('a transform on the schema itself is found at the root', () => {
|
|
17
|
+
assert.equal(
|
|
18
|
+
findZodTransform(z.object({ id: z.string() }).transform((v) => v)),
|
|
19
|
+
''
|
|
20
|
+
)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('a transform on a field is found by path', () => {
|
|
24
|
+
assert.equal(
|
|
25
|
+
findZodTransform(
|
|
26
|
+
z.object({
|
|
27
|
+
id: z.string(),
|
|
28
|
+
when: z.string().transform((s) => new Date(s)),
|
|
29
|
+
})
|
|
30
|
+
),
|
|
31
|
+
'.when'
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('a transform is found through wrappers, arrays and unions', () => {
|
|
36
|
+
assert.equal(
|
|
37
|
+
findZodTransform(
|
|
38
|
+
z.object({ tags: z.array(z.string().transform((s) => s.trim())) })
|
|
39
|
+
),
|
|
40
|
+
'.tags[]'
|
|
41
|
+
)
|
|
42
|
+
assert.equal(
|
|
43
|
+
findZodTransform(z.object({ n: z.string().transform(Number).optional() })),
|
|
44
|
+
'.n'
|
|
45
|
+
)
|
|
46
|
+
assert.equal(
|
|
47
|
+
findZodTransform(
|
|
48
|
+
z.object({ v: z.union([z.string(), z.number().transform(String)]) })
|
|
49
|
+
),
|
|
50
|
+
'.v|1'
|
|
51
|
+
)
|
|
52
|
+
assert.equal(
|
|
53
|
+
findZodTransform(
|
|
54
|
+
z.object({
|
|
55
|
+
m: z.record(
|
|
56
|
+
z.string(),
|
|
57
|
+
z.string().transform((s) => s)
|
|
58
|
+
),
|
|
59
|
+
})
|
|
60
|
+
),
|
|
61
|
+
'.m[]'
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('a self-referencing schema terminates instead of recursing forever', () => {
|
|
66
|
+
const node: z.ZodType = z.lazy(() =>
|
|
67
|
+
z.object({ name: z.string(), children: z.array(node) })
|
|
68
|
+
)
|
|
69
|
+
assert.equal(findZodTransform(node), undefined)
|
|
70
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locate a `.transform()` anywhere inside a Zod schema.
|
|
3
|
+
*
|
|
4
|
+
* A transform is why a schema cannot be a function's `input`: the value the
|
|
5
|
+
* caller sends and the value the function receives are different, and a wire
|
|
6
|
+
* contract has to describe one shape. Zod says so twice, in two different
|
|
7
|
+
* places, and neither says it usefully on its own — `toJSONSchema` with
|
|
8
|
+
* `unrepresentable: 'any'` quietly yields `{}`, a schema that accepts
|
|
9
|
+
* everything, and `zodToTs` throws about TypeScript rather than about the
|
|
10
|
+
* wiring. Finding it up front lets the diagnostic name the schema, the path,
|
|
11
|
+
* and the call.
|
|
12
|
+
*
|
|
13
|
+
* Zod 4 models `x.transform(fn)` as a `pipe` whose `out` is a `transform`, so
|
|
14
|
+
* the walk looks for that node rather than for the method.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Max nodes visited, so a pathological or cyclic schema cannot hang the walk. */
|
|
18
|
+
const MAX_NODES = 10_000
|
|
19
|
+
|
|
20
|
+
interface ZodNode {
|
|
21
|
+
_zod?: { def?: Record<string, any> }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const isNode = (value: unknown): value is ZodNode =>
|
|
25
|
+
typeof value === 'object' &&
|
|
26
|
+
value !== null &&
|
|
27
|
+
typeof (value as ZodNode)._zod?.def?.type === 'string'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The path to the first `.transform()` found — `''` when the transform is the
|
|
31
|
+
* schema itself, `'.user.email'` when it is nested — or `undefined` when there
|
|
32
|
+
* is none.
|
|
33
|
+
*/
|
|
34
|
+
export function findZodTransform(schema: unknown): string | undefined {
|
|
35
|
+
const seen = new Set<unknown>()
|
|
36
|
+
let budget = MAX_NODES
|
|
37
|
+
|
|
38
|
+
const walk = (node: unknown, path: string): string | undefined => {
|
|
39
|
+
if (budget-- <= 0 || !isNode(node) || seen.has(node)) return undefined
|
|
40
|
+
seen.add(node)
|
|
41
|
+
|
|
42
|
+
const def = node._zod!.def!
|
|
43
|
+
if (def.type === 'transform') return path
|
|
44
|
+
|
|
45
|
+
const children: Array<[unknown, string]> = []
|
|
46
|
+
switch (def.type) {
|
|
47
|
+
case 'object':
|
|
48
|
+
case 'interface':
|
|
49
|
+
for (const [key, child] of Object.entries(def.shape ?? {})) {
|
|
50
|
+
children.push([child, `${path}.${key}`])
|
|
51
|
+
}
|
|
52
|
+
break
|
|
53
|
+
case 'array':
|
|
54
|
+
case 'set':
|
|
55
|
+
children.push([def.element, `${path}[]`])
|
|
56
|
+
break
|
|
57
|
+
case 'tuple':
|
|
58
|
+
for (const [i, child] of (def.items ?? []).entries()) {
|
|
59
|
+
children.push([child, `${path}[${i}]`])
|
|
60
|
+
}
|
|
61
|
+
if (def.rest) children.push([def.rest, `${path}[]`])
|
|
62
|
+
break
|
|
63
|
+
case 'union':
|
|
64
|
+
for (const [i, child] of (def.options ?? []).entries()) {
|
|
65
|
+
children.push([child, `${path}|${i}`])
|
|
66
|
+
}
|
|
67
|
+
break
|
|
68
|
+
case 'intersection':
|
|
69
|
+
children.push([def.left, path], [def.right, path])
|
|
70
|
+
break
|
|
71
|
+
case 'record':
|
|
72
|
+
case 'map':
|
|
73
|
+
children.push(
|
|
74
|
+
[def.keyType, `${path}[key]`],
|
|
75
|
+
[def.valueType, `${path}[]`]
|
|
76
|
+
)
|
|
77
|
+
break
|
|
78
|
+
case 'pipe':
|
|
79
|
+
// `.transform()` itself: report the pipe's own path, not `out`'s.
|
|
80
|
+
children.push([def.in, path], [def.out, path])
|
|
81
|
+
break
|
|
82
|
+
default:
|
|
83
|
+
// optional, nullable, default, prefault, nonoptional, readonly, catch,
|
|
84
|
+
// promise, lazy, success — every wrapper Zod spells `innerType`.
|
|
85
|
+
if (def.innerType) children.push([def.innerType, path])
|
|
86
|
+
if (def.getter) {
|
|
87
|
+
try {
|
|
88
|
+
children.push([def.getter(), path])
|
|
89
|
+
} catch {
|
|
90
|
+
// A lazy schema that cannot be forced tells us nothing; skip it.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const [child, childPath] of children) {
|
|
96
|
+
const found = walk(child, childPath)
|
|
97
|
+
if (found !== undefined) return found
|
|
98
|
+
}
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return walk(schema, '')
|
|
103
|
+
}
|
|
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
|
|
|
3
3
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
|
|
4
4
|
import { tmpdir } from 'os'
|
|
5
5
|
import { join } from 'path'
|
|
6
|
+
import { fileURLToPath } from 'url'
|
|
6
7
|
|
|
7
8
|
import { getInitialInspectorState } from '../inspector.js'
|
|
8
9
|
import { generateAllSchemas, schemaRuntimeFile } from './schema-generator.js'
|
|
@@ -223,3 +224,49 @@ describe('schemaRuntimeFile', () => {
|
|
|
223
224
|
assert.equal(schemaRuntimeFile(orphan), orphan)
|
|
224
225
|
})
|
|
225
226
|
})
|
|
227
|
+
|
|
228
|
+
describe('a Zod schema that cannot be a wire contract', () => {
|
|
229
|
+
test('names .transform() and its own error code, not a downstream one', async () => {
|
|
230
|
+
// A transform used to surface as PKU724 — the *bad `#pikku/*` import* error
|
|
231
|
+
// — because the schema failed to convert, its generated leaf never got
|
|
232
|
+
// written, and the type that leaf feeds resolved to an error type. The
|
|
233
|
+
// message sent you to an import block that was fine.
|
|
234
|
+
// Inside the package, so the fixture's `import 'zod'` resolves the same
|
|
235
|
+
// way a project's would; a tmpdir has no node_modules above it.
|
|
236
|
+
const dir = mkdtempSync(
|
|
237
|
+
join(fileURLToPath(new URL('../../', import.meta.url)), '.zod-transform-')
|
|
238
|
+
)
|
|
239
|
+
try {
|
|
240
|
+
const file = join(dir, 'schema.ts')
|
|
241
|
+
writeFileSync(
|
|
242
|
+
file,
|
|
243
|
+
`import * as z from 'zod'\n` +
|
|
244
|
+
`export const CreateAssessment = z.object({ patientId: z.string(), when: z.string().transform((s) => new Date(s)) })\n`
|
|
245
|
+
)
|
|
246
|
+
const state = getInitialInspectorState(dir) as any
|
|
247
|
+
state.schemaLookup.set('CreateAssessmentInput', {
|
|
248
|
+
variableName: 'CreateAssessment',
|
|
249
|
+
sourceFile: file,
|
|
250
|
+
vendor: 'zod',
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
const errors: string[] = []
|
|
254
|
+
await assert.rejects(() =>
|
|
255
|
+
generateAllSchemas(
|
|
256
|
+
{ ...logger, error: (m: string) => errors.push(m) },
|
|
257
|
+
{ tsconfig: join(projectDir, 'tsconfig.json') },
|
|
258
|
+
state
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
const reported = errors.join('\n')
|
|
263
|
+
assert.match(reported, /PKU491/)
|
|
264
|
+
assert.match(reported, /CreateAssessmentInput/)
|
|
265
|
+
assert.match(reported, /\.transform\(\)/)
|
|
266
|
+
assert.match(reported, /\.when/)
|
|
267
|
+
assert.doesNotMatch(reported, /PKU724/)
|
|
268
|
+
} finally {
|
|
269
|
+
rmSync(dir, { recursive: true, force: true })
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
})
|
|
@@ -12,6 +12,7 @@ import { createGenerator, RootlessError } from 'ts-json-schema-generator'
|
|
|
12
12
|
import { register } from 'tsx/esm/api'
|
|
13
13
|
import * as z from 'zod'
|
|
14
14
|
import { zodToTs, createAuxiliaryTypeStore } from 'zod-to-ts'
|
|
15
|
+
import { findZodTransform } from './find-zod-transform.js'
|
|
15
16
|
import type { FunctionsMeta } from '@pikku/core/services'
|
|
16
17
|
import type { JSONValue } from '@pikku/core/utils'
|
|
17
18
|
import type { HTTPWiringsMeta } from '@pikku/core/http'
|
|
@@ -508,6 +509,19 @@ function processZodSchema(
|
|
|
508
509
|
fakeSourceFile: ts.SourceFile,
|
|
509
510
|
logger: InspectorLogger
|
|
510
511
|
): void {
|
|
512
|
+
const transformPath = findZodTransform(zodSchema)
|
|
513
|
+
if (transformPath !== undefined) {
|
|
514
|
+
const where = transformPath === '' ? 'the schema itself' : transformPath
|
|
515
|
+
throw new Error(
|
|
516
|
+
`[${ErrorCode.SCHEMA_TRANSFORM_NOT_SUPPORTED}] '${schemaName}' contains a .transform() at ${where}.\n` +
|
|
517
|
+
'A transform makes the value sent and the value received two different ' +
|
|
518
|
+
'shapes, and a wire contract describes one: it cannot be expressed in ' +
|
|
519
|
+
'JSON Schema or in the generated TypeScript.\n' +
|
|
520
|
+
'Validate with the plain schema and apply the transform inside the ' +
|
|
521
|
+
'function, or move it to the output type.'
|
|
522
|
+
)
|
|
523
|
+
}
|
|
524
|
+
|
|
511
525
|
const schema = z.toJSONSchema(zodSchema, {
|
|
512
526
|
unrepresentable: 'any',
|
|
513
527
|
override: ({ zodSchema, jsonSchema }) => {
|