fastify 5.12.1 → 5.12.2
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/docs/Reference/Validation-and-Serialization.md +11 -0
- package/docs/Reference/Warnings.md +11 -0
- package/lib/four-oh-four.js +10 -22
- package/lib/route.js +7 -4
- package/lib/schemas.js +5 -2
- package/lib/validation.js +196 -23
- package/lib/warnings.js +10 -1
- package/package.json +1 -1
- package/test/404s.test.js +105 -7
- package/test/router-options.test.js +24 -0
- package/test/schema-feature.test.js +158 -0
- package/test/schema-special-usage.test.js +81 -0
- package/test/schema-validation.test.js +378 -0
- package/.pi/self-learning-memory/README.md +0 -8
- package/.pi/self-learning-memory/core/CORE.md +0 -33
- package/.pi/self-learning-memory/core/index.json +0 -735
- package/.pi/self-learning-memory/daily/2026-07-06.md +0 -22
- package/.pi/self-learning-memory/daily/2026-07-29.md +0 -50
- package/.pi/self-learning-memory/daily/2026-08-04.md +0 -41
- package/.pi/self-learning-memory/daily/2026-08-18.md +0 -40
- package/.pi/self-learning-memory/long-term-memory.md +0 -89
- package/AGENTS.md +0 -290
|
@@ -471,6 +471,164 @@ test('Should emit a warning for every route with undefined schema', (t, testDone
|
|
|
471
471
|
})
|
|
472
472
|
})
|
|
473
473
|
|
|
474
|
+
test('Default validator compiler is initialized for a route whose only schema is false', async (t) => {
|
|
475
|
+
t.plan(2)
|
|
476
|
+
const fastify = Fastify({ exposeHeadRoutes: false })
|
|
477
|
+
|
|
478
|
+
fastify.post('/body', {
|
|
479
|
+
handler: echoBody,
|
|
480
|
+
schema: {
|
|
481
|
+
body: false
|
|
482
|
+
}
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
const res = await fastify.inject({
|
|
486
|
+
method: 'POST',
|
|
487
|
+
url: '/body',
|
|
488
|
+
payload: {}
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
t.assert.strictEqual(res.statusCode, 400)
|
|
492
|
+
t.assert.ok(fastify.validatorCompiler, 'validator compiler is initialized')
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
test('Boolean false request schemas reject every request and no handler executes', async (t) => {
|
|
496
|
+
t.plan(5)
|
|
497
|
+
const fastify = Fastify({ exposeHeadRoutes: false })
|
|
498
|
+
|
|
499
|
+
let bodyExecuted = false
|
|
500
|
+
let qsExecuted = false
|
|
501
|
+
let paramsExecuted = false
|
|
502
|
+
let headersExecuted = false
|
|
503
|
+
|
|
504
|
+
fastify.post('/body', {
|
|
505
|
+
handler: async () => { bodyExecuted = true; return { protectedActionExecuted: true } },
|
|
506
|
+
schema: { body: false }
|
|
507
|
+
})
|
|
508
|
+
fastify.get('/querystring', {
|
|
509
|
+
handler: async () => { qsExecuted = true; return { protectedActionExecuted: true } },
|
|
510
|
+
schema: { querystring: false }
|
|
511
|
+
})
|
|
512
|
+
fastify.post('/params/:id', {
|
|
513
|
+
handler: async () => { paramsExecuted = true; return { protectedActionExecuted: true } },
|
|
514
|
+
schema: { params: false }
|
|
515
|
+
})
|
|
516
|
+
fastify.post('/headers', {
|
|
517
|
+
handler: async () => { headersExecuted = true; return { protectedActionExecuted: true } },
|
|
518
|
+
schema: { headers: false }
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
const r1 = await fastify.inject({ method: 'POST', url: '/body', payload: {} })
|
|
522
|
+
t.assert.strictEqual(r1.statusCode, 400)
|
|
523
|
+
const r2 = await fastify.inject({ method: 'GET', url: '/querystring' })
|
|
524
|
+
t.assert.strictEqual(r2.statusCode, 400)
|
|
525
|
+
const r3 = await fastify.inject({ method: 'POST', url: '/params/1' })
|
|
526
|
+
t.assert.strictEqual(r3.statusCode, 400)
|
|
527
|
+
const r4 = await fastify.inject({ method: 'POST', url: '/headers' })
|
|
528
|
+
t.assert.strictEqual(r4.statusCode, 400)
|
|
529
|
+
|
|
530
|
+
t.assert.deepStrictEqual({
|
|
531
|
+
bodyExecuted,
|
|
532
|
+
qsExecuted,
|
|
533
|
+
paramsExecuted,
|
|
534
|
+
headersExecuted
|
|
535
|
+
}, {
|
|
536
|
+
bodyExecuted: false,
|
|
537
|
+
qsExecuted: false,
|
|
538
|
+
paramsExecuted: false,
|
|
539
|
+
headersExecuted: false
|
|
540
|
+
})
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
test('Custom validator compiler receives the literal boolean false schema', async (t) => {
|
|
544
|
+
t.plan(3)
|
|
545
|
+
const received = []
|
|
546
|
+
const fastify = Fastify({ exposeHeadRoutes: false })
|
|
547
|
+
|
|
548
|
+
fastify.post('/body', {
|
|
549
|
+
handler: async () => ({ ok: true }),
|
|
550
|
+
schema: { body: false },
|
|
551
|
+
validatorCompiler: ({ schema, httpPart }) => {
|
|
552
|
+
received.push({ schema, httpPart })
|
|
553
|
+
return () => { throw new Error('should not validate') }
|
|
554
|
+
}
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
await fastify.ready()
|
|
558
|
+
t.assert.strictEqual(received.length, 1)
|
|
559
|
+
t.assert.strictEqual(received[0].schema, false)
|
|
560
|
+
t.assert.strictEqual(received[0].httpPart, 'body')
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
test('FSTWRN001 is emitted only for explicitly undefined schemas, not boolean false', async (t) => {
|
|
564
|
+
t.plan(4)
|
|
565
|
+
const spyData = spyWarning(FSTWRN001)
|
|
566
|
+
t.after(spyData.restore)
|
|
567
|
+
|
|
568
|
+
const fastify = Fastify({ exposeHeadRoutes: false })
|
|
569
|
+
|
|
570
|
+
fastify.post('/false-body', {
|
|
571
|
+
handler: async () => ({ ok: true }),
|
|
572
|
+
schema: { body: false }
|
|
573
|
+
})
|
|
574
|
+
fastify.post('/undefined-body', {
|
|
575
|
+
handler: async () => ({ ok: true }),
|
|
576
|
+
schema: { body: undefined }
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
await fastify.ready()
|
|
580
|
+
t.assert.strictEqual(spyData.callCount(), 1)
|
|
581
|
+
t.assert.deepStrictEqual(spyData.calls[0], { arguments: ['body', 'POST', '/undefined-body'], result: true })
|
|
582
|
+
|
|
583
|
+
const r1 = await fastify.inject({ method: 'POST', url: '/false-body', payload: {} })
|
|
584
|
+
t.assert.strictEqual(r1.statusCode, 400)
|
|
585
|
+
const r2 = await fastify.inject({ method: 'POST', url: '/undefined-body', payload: {} })
|
|
586
|
+
t.assert.strictEqual(r2.statusCode, 200)
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
test('Invalid null schema fails closed during schema compilation', async (t) => {
|
|
590
|
+
t.plan(3)
|
|
591
|
+
const fastify = Fastify({ exposeHeadRoutes: false })
|
|
592
|
+
|
|
593
|
+
let handlerExecuted = false
|
|
594
|
+
fastify.post('/null-body', {
|
|
595
|
+
handler: async () => { handlerExecuted = true; return { ok: true } },
|
|
596
|
+
schema: { body: null }
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
await t.assert.rejects(fastify.ready(), (err) => {
|
|
600
|
+
t.assert.strictEqual(err.code, 'FST_ERR_SCH_VALIDATION_BUILD')
|
|
601
|
+
return true
|
|
602
|
+
})
|
|
603
|
+
t.assert.strictEqual(handlerExecuted, false)
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
test('Boolean false request schema via the query alias rejects every request', async (t) => {
|
|
607
|
+
const fastify = Fastify()
|
|
608
|
+
let handlerExecuted = false
|
|
609
|
+
|
|
610
|
+
// `query` is a documented alias for `querystring`; a deny-all `false` must be
|
|
611
|
+
// aliased and enforced just like the canonical key.
|
|
612
|
+
fastify.get('/', {
|
|
613
|
+
handler: async () => { handlerExecuted = true; return { protectedActionExecuted: true } },
|
|
614
|
+
schema: { query: false }
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
const res = await fastify.inject({ method: 'GET', url: '/?x=1' })
|
|
618
|
+
|
|
619
|
+
t.assert.strictEqual(res.statusCode, 400)
|
|
620
|
+
t.assert.strictEqual(handlerExecuted, false)
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
test('Setting both query and querystring still throws (boolean false included)', async (t) => {
|
|
624
|
+
const fastify = Fastify()
|
|
625
|
+
fastify.get('/', {
|
|
626
|
+
handler: async () => ({ ok: true }),
|
|
627
|
+
schema: { query: false, querystring: false }
|
|
628
|
+
})
|
|
629
|
+
await t.assert.rejects(fastify.ready())
|
|
630
|
+
})
|
|
631
|
+
|
|
474
632
|
test('First level $ref', (t, testDone) => {
|
|
475
633
|
t.plan(2)
|
|
476
634
|
const fastify = Fastify()
|
|
@@ -939,6 +939,87 @@ test('Supports async AJV validation', (t, testDone) => {
|
|
|
939
939
|
completion.patience.then(testDone)
|
|
940
940
|
})
|
|
941
941
|
|
|
942
|
+
// @see https://github.com/fastify/fastify/security/advisories/GHSA-667r-xxjv-c9mm
|
|
943
|
+
test('async validation result must not replace the request body (value collision)', async (t) => {
|
|
944
|
+
const fastify = Fastify()
|
|
945
|
+
fastify.post('/', {
|
|
946
|
+
schema: {
|
|
947
|
+
body: {
|
|
948
|
+
$async: true,
|
|
949
|
+
type: 'object',
|
|
950
|
+
additionalProperties: true,
|
|
951
|
+
properties: { hello: { type: 'string' } }
|
|
952
|
+
}
|
|
953
|
+
},
|
|
954
|
+
handler: async (request) => request.body
|
|
955
|
+
})
|
|
956
|
+
|
|
957
|
+
const res = await fastify.inject({
|
|
958
|
+
method: 'POST',
|
|
959
|
+
url: '/',
|
|
960
|
+
headers: { 'content-type': 'application/json' },
|
|
961
|
+
body: JSON.stringify({ hello: 'world', value: { hello: 'evil' } })
|
|
962
|
+
})
|
|
963
|
+
|
|
964
|
+
t.assert.strictEqual(res.statusCode, 200)
|
|
965
|
+
// The validated body must be preserved; a `value` key must NOT replace it.
|
|
966
|
+
t.assert.deepStrictEqual(res.json(), { hello: 'world', value: { hello: 'evil' } })
|
|
967
|
+
})
|
|
968
|
+
|
|
969
|
+
// @see https://github.com/fastify/fastify/security/advisories/GHSA-667r-xxjv-c9mm
|
|
970
|
+
test('async validation result must not be treated as an error (error collision)', async (t) => {
|
|
971
|
+
const fastify = Fastify()
|
|
972
|
+
fastify.post('/', {
|
|
973
|
+
schema: {
|
|
974
|
+
body: {
|
|
975
|
+
$async: true,
|
|
976
|
+
type: 'object',
|
|
977
|
+
additionalProperties: true,
|
|
978
|
+
properties: { hello: { type: 'string' } }
|
|
979
|
+
}
|
|
980
|
+
},
|
|
981
|
+
handler: async (request) => request.body
|
|
982
|
+
})
|
|
983
|
+
|
|
984
|
+
const res = await fastify.inject({
|
|
985
|
+
method: 'POST',
|
|
986
|
+
url: '/',
|
|
987
|
+
headers: { 'content-type': 'application/json' },
|
|
988
|
+
body: JSON.stringify({ hello: 'world', error: 'boom' })
|
|
989
|
+
})
|
|
990
|
+
|
|
991
|
+
// An `error` key in the payload must NOT be treated as a validation failure.
|
|
992
|
+
t.assert.strictEqual(res.statusCode, 200)
|
|
993
|
+
t.assert.deepStrictEqual(res.json(), { hello: 'world', error: 'boom' })
|
|
994
|
+
})
|
|
995
|
+
|
|
996
|
+
test('async custom validator resolving false uses its validation errors', async t => {
|
|
997
|
+
const fastify = Fastify()
|
|
998
|
+
fastify.setValidatorCompiler(() => {
|
|
999
|
+
const validate = () => Promise.resolve(false)
|
|
1000
|
+
validate.errors = [{
|
|
1001
|
+
instancePath: '',
|
|
1002
|
+
schemaPath: '#/custom',
|
|
1003
|
+
keyword: 'custom',
|
|
1004
|
+
params: {},
|
|
1005
|
+
message: 'must pass custom validation'
|
|
1006
|
+
}]
|
|
1007
|
+
return validate
|
|
1008
|
+
})
|
|
1009
|
+
fastify.post('/', {
|
|
1010
|
+
schema: { body: { type: 'object' } }
|
|
1011
|
+
}, async () => ({ ok: true }))
|
|
1012
|
+
|
|
1013
|
+
const res = await fastify.inject({
|
|
1014
|
+
method: 'POST',
|
|
1015
|
+
url: '/',
|
|
1016
|
+
payload: {}
|
|
1017
|
+
})
|
|
1018
|
+
|
|
1019
|
+
t.assert.strictEqual(res.statusCode, 400)
|
|
1020
|
+
t.assert.strictEqual(res.json().code, 'FST_ERR_VALIDATION')
|
|
1021
|
+
})
|
|
1022
|
+
|
|
942
1023
|
test('Check all the async AJV validation paths', async (t) => {
|
|
943
1024
|
const fastify = Fastify({
|
|
944
1025
|
exposeHeadRoutes: false,
|
|
@@ -7,6 +7,8 @@ const AJV = require('ajv')
|
|
|
7
7
|
const Schema = require('fluent-json-schema')
|
|
8
8
|
const { waitForCb } = require('./helper')
|
|
9
9
|
const { kRequestContentType } = require('../lib/symbols')
|
|
10
|
+
const { spyWarning } = require('process-warning')
|
|
11
|
+
const { FSTSEC002 } = require('../lib/warnings')
|
|
10
12
|
|
|
11
13
|
const customSchemaCompilers = {
|
|
12
14
|
body: new AJV({
|
|
@@ -1615,3 +1617,379 @@ test('coercion of empty string to null with nullable types', async t => {
|
|
|
1615
1617
|
})
|
|
1616
1618
|
assert.strictEqual(JSON.parse(res.payload).param, null)
|
|
1617
1619
|
})
|
|
1620
|
+
|
|
1621
|
+
test('header schema dependencies with canonical-case names are enforced', async t => {
|
|
1622
|
+
const fastify = Fastify()
|
|
1623
|
+
|
|
1624
|
+
fastify.get('/', {
|
|
1625
|
+
schema: {
|
|
1626
|
+
headers: {
|
|
1627
|
+
type: 'object',
|
|
1628
|
+
properties: {
|
|
1629
|
+
'X-Admin': { type: 'string', const: 'true' },
|
|
1630
|
+
'X-Admin-Token': { type: 'string', const: 'server-secret' }
|
|
1631
|
+
},
|
|
1632
|
+
dependencies: {
|
|
1633
|
+
'X-Admin': ['X-Admin-Token']
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
}, async () => {
|
|
1638
|
+
return { adminAction: true }
|
|
1639
|
+
})
|
|
1640
|
+
|
|
1641
|
+
await fastify.ready()
|
|
1642
|
+
|
|
1643
|
+
// Missing the token required by the dependency must be rejected even though
|
|
1644
|
+
// the dependency trigger is written in canonical case. Node.js stores the
|
|
1645
|
+
// received header as `x-admin`, and the normalized schema must lowercase the
|
|
1646
|
+
// dependency trigger so Ajv sees it and enforces the token assertion.
|
|
1647
|
+
const missingToken = await fastify.inject({
|
|
1648
|
+
method: 'GET',
|
|
1649
|
+
url: '/',
|
|
1650
|
+
headers: { 'X-Admin': 'true' }
|
|
1651
|
+
})
|
|
1652
|
+
t.assert.strictEqual(missingToken.statusCode, 400)
|
|
1653
|
+
t.assert.strictEqual(missingToken.json().code, 'FST_ERR_VALIDATION')
|
|
1654
|
+
|
|
1655
|
+
// Direct property constraints remain active: a wrong token is rejected too.
|
|
1656
|
+
const wrongToken = await fastify.inject({
|
|
1657
|
+
method: 'GET',
|
|
1658
|
+
url: '/',
|
|
1659
|
+
headers: { 'X-Admin': 'true', 'X-Admin-Token': 'wrong' }
|
|
1660
|
+
})
|
|
1661
|
+
t.assert.strictEqual(wrongToken.statusCode, 400)
|
|
1662
|
+
t.assert.strictEqual(wrongToken.json().code, 'FST_ERR_VALIDATION')
|
|
1663
|
+
|
|
1664
|
+
const valid = await fastify.inject({
|
|
1665
|
+
method: 'GET',
|
|
1666
|
+
url: '/',
|
|
1667
|
+
headers: { 'X-Admin': 'true', 'X-Admin-Token': 'server-secret' }
|
|
1668
|
+
})
|
|
1669
|
+
t.assert.strictEqual(valid.statusCode, 200)
|
|
1670
|
+
t.assert.deepStrictEqual(valid.json(), { adminAction: true })
|
|
1671
|
+
})
|
|
1672
|
+
|
|
1673
|
+
test('header schema dependencies: lowercase-equivalent schema behaves identically', async t => {
|
|
1674
|
+
const fastify = Fastify()
|
|
1675
|
+
|
|
1676
|
+
fastify.get('/', {
|
|
1677
|
+
schema: {
|
|
1678
|
+
headers: {
|
|
1679
|
+
type: 'object',
|
|
1680
|
+
properties: {
|
|
1681
|
+
'x-admin': { type: 'string', const: 'true' },
|
|
1682
|
+
'x-admin-token': { type: 'string', const: 'server-secret' }
|
|
1683
|
+
},
|
|
1684
|
+
dependencies: {
|
|
1685
|
+
'x-admin': ['x-admin-token']
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
}, async () => {
|
|
1690
|
+
return { adminAction: true }
|
|
1691
|
+
})
|
|
1692
|
+
|
|
1693
|
+
await fastify.ready()
|
|
1694
|
+
|
|
1695
|
+
const missingToken = await fastify.inject({
|
|
1696
|
+
method: 'GET',
|
|
1697
|
+
url: '/',
|
|
1698
|
+
headers: { 'X-Admin': 'true' }
|
|
1699
|
+
})
|
|
1700
|
+
t.assert.strictEqual(missingToken.statusCode, 400)
|
|
1701
|
+
t.assert.strictEqual(missingToken.json().code, 'FST_ERR_VALIDATION')
|
|
1702
|
+
|
|
1703
|
+
const wrongToken = await fastify.inject({
|
|
1704
|
+
method: 'GET',
|
|
1705
|
+
url: '/',
|
|
1706
|
+
headers: { 'X-Admin': 'true', 'X-Admin-Token': 'wrong' }
|
|
1707
|
+
})
|
|
1708
|
+
t.assert.strictEqual(wrongToken.statusCode, 400)
|
|
1709
|
+
t.assert.strictEqual(wrongToken.json().code, 'FST_ERR_VALIDATION')
|
|
1710
|
+
|
|
1711
|
+
const valid = await fastify.inject({
|
|
1712
|
+
method: 'GET',
|
|
1713
|
+
url: '/',
|
|
1714
|
+
headers: { 'X-Admin': 'true', 'X-Admin-Token': 'server-secret' }
|
|
1715
|
+
})
|
|
1716
|
+
t.assert.strictEqual(valid.statusCode, 200)
|
|
1717
|
+
t.assert.deepStrictEqual(valid.json(), { adminAction: true })
|
|
1718
|
+
})
|
|
1719
|
+
|
|
1720
|
+
test('header schema dependencies are normalized in nested subschemas', async t => {
|
|
1721
|
+
const fastify = Fastify()
|
|
1722
|
+
|
|
1723
|
+
// The dependency lives inside an `allOf` subschema; its trigger and dependent
|
|
1724
|
+
// names must be lowercased the same way as root-level `dependencies`.
|
|
1725
|
+
fastify.get('/', {
|
|
1726
|
+
schema: {
|
|
1727
|
+
headers: {
|
|
1728
|
+
type: 'object',
|
|
1729
|
+
allOf: [{
|
|
1730
|
+
properties: { 'X-Admin': { type: 'string', const: 'true' } },
|
|
1731
|
+
dependencies: { 'X-Admin': ['X-Admin-Token'] }
|
|
1732
|
+
}]
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
}, async () => {
|
|
1736
|
+
return { ok: true }
|
|
1737
|
+
})
|
|
1738
|
+
|
|
1739
|
+
await fastify.ready()
|
|
1740
|
+
|
|
1741
|
+
const missingToken = await fastify.inject({
|
|
1742
|
+
method: 'GET',
|
|
1743
|
+
url: '/',
|
|
1744
|
+
headers: { 'X-Admin': 'true' }
|
|
1745
|
+
})
|
|
1746
|
+
t.assert.strictEqual(missingToken.statusCode, 400)
|
|
1747
|
+
t.assert.strictEqual(missingToken.json().code, 'FST_ERR_VALIDATION')
|
|
1748
|
+
|
|
1749
|
+
const valid = await fastify.inject({
|
|
1750
|
+
method: 'GET',
|
|
1751
|
+
url: '/',
|
|
1752
|
+
headers: { 'X-Admin': 'true', 'X-Admin-Token': 'server-secret' }
|
|
1753
|
+
})
|
|
1754
|
+
t.assert.strictEqual(valid.statusCode, 200)
|
|
1755
|
+
})
|
|
1756
|
+
|
|
1757
|
+
test('header schema dependencies are normalized in local $ref definitions', async t => {
|
|
1758
|
+
const fastify = Fastify()
|
|
1759
|
+
|
|
1760
|
+
fastify.get('/', {
|
|
1761
|
+
schema: {
|
|
1762
|
+
headers: {
|
|
1763
|
+
$ref: '#/definitions/Headers',
|
|
1764
|
+
definitions: {
|
|
1765
|
+
Headers: {
|
|
1766
|
+
type: 'object',
|
|
1767
|
+
properties: {
|
|
1768
|
+
'X-Admin': { type: 'string', const: 'true' },
|
|
1769
|
+
'X-Admin-Token': { type: 'string', const: 'server-secret' }
|
|
1770
|
+
},
|
|
1771
|
+
dependencies: {
|
|
1772
|
+
'X-Admin': ['X-Admin-Token']
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
}, async () => {
|
|
1779
|
+
return { ok: true }
|
|
1780
|
+
})
|
|
1781
|
+
|
|
1782
|
+
await fastify.ready()
|
|
1783
|
+
|
|
1784
|
+
const missingToken = await fastify.inject({
|
|
1785
|
+
method: 'GET',
|
|
1786
|
+
url: '/',
|
|
1787
|
+
headers: { 'X-Admin': 'true' }
|
|
1788
|
+
})
|
|
1789
|
+
t.assert.strictEqual(missingToken.statusCode, 400)
|
|
1790
|
+
t.assert.strictEqual(missingToken.json().code, 'FST_ERR_VALIDATION')
|
|
1791
|
+
|
|
1792
|
+
const valid = await fastify.inject({
|
|
1793
|
+
method: 'GET',
|
|
1794
|
+
url: '/',
|
|
1795
|
+
headers: { 'X-Admin': 'true', 'X-Admin-Token': 'server-secret' }
|
|
1796
|
+
})
|
|
1797
|
+
t.assert.strictEqual(valid.statusCode, 200)
|
|
1798
|
+
})
|
|
1799
|
+
|
|
1800
|
+
test('header schema dependencies: subschema-form dependency values are normalized', async t => {
|
|
1801
|
+
const fastify = Fastify()
|
|
1802
|
+
|
|
1803
|
+
fastify.get('/', {
|
|
1804
|
+
schema: {
|
|
1805
|
+
headers: {
|
|
1806
|
+
type: 'object',
|
|
1807
|
+
properties: { 'X-Admin': { type: 'string', const: 'true' } },
|
|
1808
|
+
dependencies: {
|
|
1809
|
+
'X-Admin': {
|
|
1810
|
+
properties: { 'X-Admin-Token': { type: 'string', const: 'server-secret' } },
|
|
1811
|
+
required: ['X-Admin-Token']
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
}, async () => {
|
|
1817
|
+
return { ok: true }
|
|
1818
|
+
})
|
|
1819
|
+
|
|
1820
|
+
await fastify.ready()
|
|
1821
|
+
|
|
1822
|
+
const missingToken = await fastify.inject({
|
|
1823
|
+
method: 'GET',
|
|
1824
|
+
url: '/',
|
|
1825
|
+
headers: { 'X-Admin': 'true' }
|
|
1826
|
+
})
|
|
1827
|
+
t.assert.strictEqual(missingToken.statusCode, 400)
|
|
1828
|
+
t.assert.strictEqual(missingToken.json().code, 'FST_ERR_VALIDATION')
|
|
1829
|
+
|
|
1830
|
+
const valid = await fastify.inject({
|
|
1831
|
+
method: 'GET',
|
|
1832
|
+
url: '/',
|
|
1833
|
+
headers: { 'X-Admin': 'true', 'X-Admin-Token': 'server-secret' }
|
|
1834
|
+
})
|
|
1835
|
+
t.assert.strictEqual(valid.statusCode, 200)
|
|
1836
|
+
})
|
|
1837
|
+
|
|
1838
|
+
test('header schema lowercasing does not mutate the input schema', async t => {
|
|
1839
|
+
const headers = {
|
|
1840
|
+
type: 'object',
|
|
1841
|
+
properties: {
|
|
1842
|
+
'X-Admin': { type: 'string', const: 'true' },
|
|
1843
|
+
'X-Admin-Token': { type: 'string', const: 'server-secret' }
|
|
1844
|
+
},
|
|
1845
|
+
dependencies: {
|
|
1846
|
+
'X-Admin': ['X-Admin-Token']
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
const schema = { headers }
|
|
1850
|
+
const schemaBefore = JSON.stringify(schema)
|
|
1851
|
+
const headersBefore = JSON.stringify(headers)
|
|
1852
|
+
|
|
1853
|
+
const fastify = Fastify()
|
|
1854
|
+
fastify.get('/', { schema }, async () => {
|
|
1855
|
+
return { ok: true }
|
|
1856
|
+
})
|
|
1857
|
+
await fastify.ready()
|
|
1858
|
+
|
|
1859
|
+
t.assert.strictEqual(JSON.stringify(schema), schemaBefore)
|
|
1860
|
+
t.assert.strictEqual(JSON.stringify(headers), headersBefore)
|
|
1861
|
+
})
|
|
1862
|
+
|
|
1863
|
+
test('header schema with an external $ref emits FSTSEC002 (case-normalization does not reach it)', async t => {
|
|
1864
|
+
const spyData = spyWarning(FSTSEC002)
|
|
1865
|
+
t.after(spyData.restore)
|
|
1866
|
+
|
|
1867
|
+
const fastify = Fastify()
|
|
1868
|
+
fastify.addSchema({
|
|
1869
|
+
$id: 'http://example.com/admin-headers',
|
|
1870
|
+
type: 'object',
|
|
1871
|
+
properties: {
|
|
1872
|
+
'X-Admin': { type: 'string', const: 'true' },
|
|
1873
|
+
'X-Admin-Token': { type: 'string', const: 'server-secret' }
|
|
1874
|
+
},
|
|
1875
|
+
dependencies: { 'X-Admin': ['X-Admin-Token'] }
|
|
1876
|
+
})
|
|
1877
|
+
fastify.post('/', {
|
|
1878
|
+
schema: { headers: { $ref: 'http://example.com/admin-headers#' } }
|
|
1879
|
+
}, async () => ({ ok: true }))
|
|
1880
|
+
|
|
1881
|
+
await fastify.ready()
|
|
1882
|
+
|
|
1883
|
+
t.assert.strictEqual(spyData.callCount(), 1)
|
|
1884
|
+
t.assert.deepStrictEqual(spyData.calls[0].arguments, ['POST', '/', 'http://example.com/admin-headers#'])
|
|
1885
|
+
})
|
|
1886
|
+
|
|
1887
|
+
test('inline and local $ref header schemas do not emit FSTSEC002', async t => {
|
|
1888
|
+
const spyData = spyWarning(FSTSEC002)
|
|
1889
|
+
t.after(spyData.restore)
|
|
1890
|
+
|
|
1891
|
+
const fastify = Fastify()
|
|
1892
|
+
// inline header schema
|
|
1893
|
+
fastify.get('/inline', {
|
|
1894
|
+
schema: {
|
|
1895
|
+
headers: {
|
|
1896
|
+
type: 'object',
|
|
1897
|
+
properties: { 'X-Admin': { type: 'string' } },
|
|
1898
|
+
dependencies: { 'X-Admin': ['X-Admin-Token'] }
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
}, async () => ({ ok: true }))
|
|
1902
|
+
// local same-document $ref header schema
|
|
1903
|
+
fastify.get('/local', {
|
|
1904
|
+
schema: {
|
|
1905
|
+
headers: {
|
|
1906
|
+
type: 'object',
|
|
1907
|
+
$ref: '#/definitions/h',
|
|
1908
|
+
definitions: {
|
|
1909
|
+
h: {
|
|
1910
|
+
type: 'object',
|
|
1911
|
+
properties: { 'X-Admin': { type: 'string' } },
|
|
1912
|
+
dependencies: { 'X-Admin': ['X-Admin-Token'] }
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
}, async () => ({ ok: true }))
|
|
1918
|
+
|
|
1919
|
+
await fastify.ready()
|
|
1920
|
+
|
|
1921
|
+
t.assert.strictEqual(spyData.callCount(), 0)
|
|
1922
|
+
})
|
|
1923
|
+
|
|
1924
|
+
test('header schema normalization traverses dependent schema keywords', async t => {
|
|
1925
|
+
const fastify = Fastify({
|
|
1926
|
+
ajv: { customOptions: { strictSchema: false } }
|
|
1927
|
+
})
|
|
1928
|
+
|
|
1929
|
+
fastify.get('/', {
|
|
1930
|
+
schema: {
|
|
1931
|
+
headers: {
|
|
1932
|
+
type: 'object',
|
|
1933
|
+
dependentSchemas: {
|
|
1934
|
+
'X-Admin': {
|
|
1935
|
+
properties: { 'X-Admin-Token': { type: 'string' } }
|
|
1936
|
+
}
|
|
1937
|
+
},
|
|
1938
|
+
dependentRequired: {
|
|
1939
|
+
'X-Admin': ['X-Admin-Token']
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
}, async () => ({ ok: true }))
|
|
1944
|
+
|
|
1945
|
+
await fastify.ready()
|
|
1946
|
+
t.assert.ok(true)
|
|
1947
|
+
})
|
|
1948
|
+
|
|
1949
|
+
test('header schema normalization preserves malformed keyword values for validation errors', async t => {
|
|
1950
|
+
const fastify = Fastify()
|
|
1951
|
+
|
|
1952
|
+
fastify.get('/', {
|
|
1953
|
+
schema: {
|
|
1954
|
+
headers: {
|
|
1955
|
+
type: 'object',
|
|
1956
|
+
properties: null,
|
|
1957
|
+
required: null,
|
|
1958
|
+
dependencies: null,
|
|
1959
|
+
dependentSchemas: null,
|
|
1960
|
+
dependentRequired: null,
|
|
1961
|
+
definitions: null
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
}, async () => ({ ok: true }))
|
|
1965
|
+
|
|
1966
|
+
await t.assert.rejects(fastify.ready())
|
|
1967
|
+
})
|
|
1968
|
+
|
|
1969
|
+
test('header schema detects an external $ref nested in an array', async t => {
|
|
1970
|
+
const spyData = spyWarning(FSTSEC002)
|
|
1971
|
+
t.after(spyData.restore)
|
|
1972
|
+
|
|
1973
|
+
const fastify = Fastify()
|
|
1974
|
+
fastify.addSchema({
|
|
1975
|
+
$id: 'http://example.com/nested-admin-headers',
|
|
1976
|
+
type: 'object',
|
|
1977
|
+
properties: { 'X-Admin': { type: 'string' } }
|
|
1978
|
+
})
|
|
1979
|
+
fastify.post('/', {
|
|
1980
|
+
schema: {
|
|
1981
|
+
headers: {
|
|
1982
|
+
allOf: [{ $ref: 'http://example.com/nested-admin-headers#' }]
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
}, async () => ({ ok: true }))
|
|
1986
|
+
|
|
1987
|
+
await fastify.ready()
|
|
1988
|
+
|
|
1989
|
+
t.assert.strictEqual(spyData.callCount(), 1)
|
|
1990
|
+
t.assert.deepStrictEqual(spyData.calls[0].arguments, [
|
|
1991
|
+
'POST',
|
|
1992
|
+
'/',
|
|
1993
|
+
'http://example.com/nested-admin-headers#'
|
|
1994
|
+
])
|
|
1995
|
+
})
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
# Core Learnings
|
|
2
|
-
|
|
3
|
-
Most important durable learnings collected over time.
|
|
4
|
-
Last updated: 2026-08-18T19:46:43.092Z
|
|
5
|
-
|
|
6
|
-
This file keeps only top-ranked, most repeated items.
|
|
7
|
-
For the complete history, see long-term-memory.md.
|
|
8
|
-
|
|
9
|
-
Ranked by frequency + recency (with light decay over time).
|
|
10
|
-
|
|
11
|
-
## High-value learnings
|
|
12
|
-
- Closed PR #1 without merging after confirming its head SHA was already the intended private `main` commit.
|
|
13
|
-
- Fetched current `origin/main` and `origin/5.x`, verified each security commit’s parent exactly matched the corresponding remote tip, then used an atomic push.
|
|
14
|
-
- Verified public and private `main`/`5.x` refs matched, fast-forwarded local `main`, deleted temporary security branches, and confirmed a clean synchronized checkout.
|
|
15
|
-
- Obtained explicit approval before public disclosure, then atomically fast-forwarded `origin/main` to `af079bd4` and `origin/5.x` to `8acfea7e`, verifying both refs with `git ls-remote`.
|
|
16
|
-
- Fast-forwarded local `main` to `origin/main` and confirmed a clean synchronized status.
|
|
17
|
-
- Do not retry forbidden workspace GraphQL actions; use permitted repository operations or ask the user for an authorized alternative before attempting comments or metadata changes.
|
|
18
|
-
- Before any security-fix push, explicitly ask whether targets are the private advisory fork, public `origin`, or both; treat a draft advisory as a warning, not implicit authorization policy.
|
|
19
|
-
- Always state push destinations and exact resulting refs, e.g. `advisory-ghsa-3m5p/main` changed while `origin/main` remained at `6e95cb9f`.
|
|
20
|
-
- Run workspace-sensitive PR operations separately: attempt the comment, record an expected permission denial, then independently close only if authorized.
|
|
21
|
-
- Describe PR state from verified fields (`CLOSED`, `mergedAt: null`, head/base OIDs) rather than inferring that it is empty.
|
|
22
|
-
|
|
23
|
-
## Watch-outs
|
|
24
|
-
- Private PR #1 remained open after its head commit had already been placed on private `main`; its mergeability was `UNKNOWN` and it was no longer the correct landing path.
|
|
25
|
-
- Local `main` and temporary security branches were left out of sync after updating the remote branches.
|
|
26
|
-
- Direct public branch updates bypassed the repository’s pull-request-only rules, so ancestry and target refs required explicit validation to avoid unsafe rewrites.
|
|
27
|
-
- Initially pushed the security fix only to the private advisory fork, leaving `origin/main` and `origin/5.x` unchanged without first confirming the intended publication target.
|
|
28
|
-
- After pushing remote refs, local `main` remained one commit behind, creating temporary local/remote inconsistency.
|
|
29
|
-
- A GraphQL `addComment` action was attempted despite workspace-repository restrictions; the denial indicates an intentional permission boundary that should not be bypassed.
|
|
30
|
-
- Assumed the draft advisory meant `origin/main` and `origin/5.x` must remain untouched without first confirming whether the user expected public pushes.
|
|
31
|
-
- Reported that the private branches were complete without clearly distinguishing advisory-fork refs from public `origin` refs, prompting the user to stop and verify deployment status.
|
|
32
|
-
- Combined PR closing with an unsupported workspace-repository comment under `set -e`, causing the entire command to fail before the close could occur.
|
|
33
|
-
- Called the PR “now-empty” even though GitHub still reported it as mergeable with different head and base OIDs.
|