diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7c1fbee..a0f5f83 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,20 +7,13 @@ on:
env:
CI: true
+permissions:
+ contents: read
+
jobs:
lint:
uses: NicTool/.github/.github/workflows/lint.yml@main
- permissions:
- contents: read
-
- coverage:
- uses: NicTool/.github/.github/workflows/coverage.yml@main
- secrets: inherit
- permissions:
- contents: read
test:
uses: NicTool/.github/.github/workflows/test.yml@main
secrets: inherit
- permissions:
- contents: read
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
new file mode 100644
index 0000000..348329d
--- /dev/null
+++ b/.github/workflows/coverage.yml
@@ -0,0 +1,15 @@
+name: Coverage
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ coverage:
+ uses: NicTool/.github/.github/workflows/coverage.yml@main
+ secrets: inherit
diff --git a/.gitmodules b/.gitmodules
index a8e94cb..1647327 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,3 @@
[submodule ".release"]
path = .release
- url = git@github.com:msimerson/.release.git
+ url = https://github.com/msimerson/.release.git
diff --git a/.release b/.release
index a6911a9..9069128 160000
--- a/.release
+++ b/.release
@@ -1 +1 @@
-Subproject commit a6911a90f1b15486fb319d844341421c78035b2a
+Subproject commit 9069128a8cbbab42c2b50989c630700397c4aba1
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e65643f..6e1fda1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
### [0.8.10] - 2026-04-13
- delegation: add validation schemas for GET, POST, PUT, DELETE (#22)
+- feat: add nameserver type native
+- zone_rec: add pagination options
- deps: bump versions
### [0.8.9] - 2026-03-29
@@ -118,7 +120,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
[0.5.0]: https://github.com/NicTool/validate/releases/tag/0.5.0
[0.6.0]: https://github.com/NicTool/validate/releases/tag/0.6.0
[0.6.1]: https://github.com/NicTool/validate/releases/tag/0.6.1
-[0.6.3]: https://github.com/NicTool/validate/releases/tag/0.6.3
+[0.6.3]: https://github.com/NicTool/validate/releases/tag/v0.6.3
[0.7.0]: https://github.com/NicTool/validate/releases/tag/0.7.0
[0.7.1]: https://github.com/NicTool/validate/releases/tag/v0.7.1
[0.7.2]: https://github.com/NicTool/validate/releases/tag/v0.7.2
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index 549c44c..4d55feb 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -2,8 +2,8 @@
This handcrafted artisanal software is brought to you by:
-| 
msimerson (29)|
-| :---: |
+| 
msimerson (30) | 
burning-bush-dev (1) |
+| :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
this file is generated by [.release](https://github.com/msimerson/.release).
Contribute to this project to get your GitHub profile included here.
diff --git a/lib/nameserver.js b/lib/nameserver.js
index dce8a4d..9230fcb 100644
--- a/lib/nameserver.js
+++ b/lib/nameserver.js
@@ -18,10 +18,51 @@ export const type = Joi.string().valid(
'maradns',
'powerdns',
'dynect',
+ 'native',
)
export const remote_login = Joi.string().empty('').max(127)
+// --- Runtime configuration: listen sockets, publisher, transport, dnssec ---
+
+export const listen = Joi.array().items(
+ Joi.object({
+ address: Joi.alternatives(shared.ipv4, shared.ipv6).required(),
+ port: shared.uint16.min(1).required(),
+ proto: Joi.string().valid('udp', 'tcp').default('udp'),
+ }),
+)
+
+export const publisher = Joi.object({
+ type: Joi.string().valid('memory', 'rfc1035', 'tinydns-cdb', 'powerdns-db').required(),
+ path: Joi.string().empty('').max(1024),
+ database: Joi.string().empty('').max(255),
+}).unknown(true)
+
+export const transport = Joi.object({
+ type: Joi.string().valid('noop', 'axfr', 'rsync', 'db-replication').required(),
+ interval: shared.uint32.default(300),
+ cooldown: shared.uint16.default(5),
+ master: Joi.string().empty('').max(255),
+ tsig_key: Joi.string().empty('').max(255),
+ remote: Joi.string().empty('').max(255),
+ ssh_key: Joi.string().empty('').max(1024),
+}).unknown(true)
+
+export const dnssec = Joi.object({
+ enabled: Joi.boolean().default(false),
+ algorithm: Joi.string().valid(
+ 'RSASHA256',
+ 'RSASHA512',
+ 'ECDSAP256SHA256',
+ 'ECDSAP384SHA384',
+ 'ED25519',
+ 'ED448',
+ ),
+ keyset: Joi.string().empty('').max(1024),
+ nsec3: Joi.boolean().default(false),
+})
+
export const v3 = Joi.object({
id: id,
gid: shared.uint32.required(),
@@ -33,6 +74,11 @@ export const v3 = Joi.object({
remote_login: remote_login,
logdir: Joi.string().empty('').max(255),
datadir: Joi.string().empty('').min(2).max(255),
+ engine: type,
+ listen: listen,
+ publisher: publisher,
+ transport: transport,
+ dnssec: dnssec,
export: Joi.object({
interval: shared.uint16,
serials: Joi.boolean(),
@@ -58,6 +104,11 @@ export const PUT = Joi.object({
address: shared.ipv4,
address6: shared.ipv6.empty(''),
remote_login: remote_login,
+ engine: type,
+ listen: listen,
+ publisher: publisher,
+ transport: transport,
+ dnssec: dnssec,
export: Joi.object({
interval: shared.uint16,
serials: Joi.boolean(),
diff --git a/lib/nameserver.test.js b/lib/nameserver.test.js
index e84ac15..0e56376 100644
--- a/lib/nameserver.test.js
+++ b/lib/nameserver.test.js
@@ -78,7 +78,7 @@ describe('nameserver', function () {
assert.deepEqual(value, testCase)
})
- for (const n of ['bind', 'djbdns', 'knot', 'nsd', 'maradns', 'powerdns', 'dynect']) {
+ for (const n of ['bind', 'djbdns', 'knot', 'nsd', 'maradns', 'powerdns', 'dynect', 'native']) {
it(`accepts valid: ${n}`, () => {
const testCase = JSON.parse(JSON.stringify(testNS))
testCase.export.type = n
@@ -99,13 +99,96 @@ describe('nameserver', function () {
assert.strictEqual(
error.message,
- '"export.type" must be one of [bind, djbdns, knot, nsd, maradns, powerdns, dynect]',
+ '"export.type" must be one of [bind, djbdns, knot, nsd, maradns, powerdns, dynect, native]',
)
assert.deepEqual(value, testCase)
})
}
})
+ describe('listen', function () {
+ it('accepts an array of {address, port, proto}', () => {
+ const testCase = JSON.parse(JSON.stringify(testNS))
+ testCase.listen = [
+ { address: '127.0.0.1', port: 53, proto: 'udp' },
+ { address: '127.0.0.1', port: 53, proto: 'tcp' },
+ ]
+ const { error } = schema.validate(testCase)
+ assert.ifError(error)
+ })
+
+ it('rejects an invalid proto', () => {
+ const testCase = JSON.parse(JSON.stringify(testNS))
+ testCase.listen = [{ address: '127.0.0.1', port: 53, proto: 'http' }]
+ const { error } = schema.validate(testCase)
+ assert.match(error.message, /"listen\[0\]\.proto" must be one of/)
+ })
+
+ it('rejects out-of-range port', () => {
+ const testCase = JSON.parse(JSON.stringify(testNS))
+ testCase.listen = [{ address: '127.0.0.1', port: 70000 }]
+ const { error } = schema.validate(testCase)
+ assert.match(error.message, /"listen\[0\]\.port"/)
+ })
+ })
+
+ describe('publisher', function () {
+ for (const t of ['memory', 'rfc1035', 'tinydns-cdb', 'powerdns-db']) {
+ it(`accepts type=${t}`, () => {
+ const testCase = JSON.parse(JSON.stringify(testNS))
+ testCase.publisher = { type: t }
+ const { error } = schema.validate(testCase)
+ assert.ifError(error)
+ })
+ }
+
+ it('rejects unknown publisher type', () => {
+ const testCase = JSON.parse(JSON.stringify(testNS))
+ testCase.publisher = { type: 'floppy' }
+ const { error } = schema.validate(testCase)
+ assert.match(error.message, /"publisher\.type" must be one of/)
+ })
+ })
+
+ describe('transport', function () {
+ for (const t of ['noop', 'axfr', 'rsync', 'db-replication']) {
+ it(`accepts type=${t}`, () => {
+ const testCase = JSON.parse(JSON.stringify(testNS))
+ testCase.transport = { type: t, interval: 0, cooldown: 2 }
+ const { error } = schema.validate(testCase)
+ assert.ifError(error)
+ })
+ }
+
+ it('rejects unknown transport type', () => {
+ const testCase = JSON.parse(JSON.stringify(testNS))
+ testCase.transport = { type: 'sneakernet' }
+ const { error } = schema.validate(testCase)
+ assert.match(error.message, /"transport\.type" must be one of/)
+ })
+ })
+
+ describe('dnssec', function () {
+ it('accepts a valid dnssec block', () => {
+ const testCase = JSON.parse(JSON.stringify(testNS))
+ testCase.dnssec = {
+ enabled: true,
+ algorithm: 'ECDSAP256SHA256',
+ keyset: './keys/ns1',
+ nsec3: true,
+ }
+ const { error } = schema.validate(testCase)
+ assert.ifError(error)
+ })
+
+ it('rejects an unknown algorithm', () => {
+ const testCase = JSON.parse(JSON.stringify(testNS))
+ testCase.dnssec = { enabled: true, algorithm: 'ROT13' }
+ const { error } = schema.validate(testCase)
+ assert.match(error.message, /"dnssec\.algorithm" must be one of/)
+ })
+ })
+
describe('gid', function () {
it(`accepts valid`, () => {
const testCase = JSON.parse(JSON.stringify(testNS))
diff --git a/lib/zone_record.js b/lib/zone_record.js
index d5be903..5fb0699 100644
--- a/lib/zone_record.js
+++ b/lib/zone_record.js
@@ -206,7 +206,7 @@ export const v3 = Joi.object({
'time signed': [Joi.number(), Joi.string().empty('')],
- timestamp: Joi.date(),
+ timestamp: Joi.date().allow(null),
txt: Joi.string().empty(''),
@@ -226,6 +226,11 @@ export const v3 = Joi.object({
export const GET_req = Joi.object({
id: shared.uint32,
zid: shared.uint32,
+ search: Joi.string().max(255).allow(''),
+ limit: shared.uint32,
+ offset: shared.uint32,
+ sort_by: Joi.string().valid('id', 'owner', 'type', 'ttl'),
+ sort_dir: Joi.string().lowercase().valid('asc', 'desc'),
deleted: Joi.boolean(),
}).options({ allowUnknown: true })
diff --git a/lib/zone_record.test.js b/lib/zone_record.test.js
index 858e8da..bd050d8 100644
--- a/lib/zone_record.test.js
+++ b/lib/zone_record.test.js
@@ -181,4 +181,57 @@ describe('zone_record', function () {
assert.deepEqual(missing, [])
})
})
+
+ describe('timestamp', function () {
+ const base = {
+ id: 1,
+ zid: 1,
+ owner: 'www.example.com.',
+ ttl: 300,
+ type: 'A',
+ address: '1.2.3.4',
+ }
+
+ it('accepts a null or omitted timestamp (the store drops invalid ones)', () => {
+ assert.equal(schema.validate({ ...base, timestamp: null }).error, undefined)
+ assert.equal(schema.validate(base).error, undefined)
+ })
+
+ it('accepts a real date', () => {
+ const { error } = schema.validate({ ...base, timestamp: '2026-01-19T00:00:00Z' })
+ assert.equal(error, undefined)
+ })
+ })
+
+ describe('GET_req', function () {
+ const GET_req = zoneRecordSchema.GET_req
+
+ it('accepts pagination, search and sort params', () => {
+ const { error } = GET_req.validate({
+ zid: 5,
+ search: 'www',
+ limit: 50,
+ offset: 100,
+ sort_by: 'owner',
+ sort_dir: 'desc',
+ })
+ assert.equal(error, undefined)
+ })
+
+ it('lowercases sort_dir and accepts an empty search', () => {
+ const { value, error } = GET_req.validate({ zid: 5, sort_dir: 'DESC', search: '' })
+ assert.equal(error, undefined)
+ assert.equal(value.sort_dir, 'desc')
+ })
+
+ it('rejects an unknown sort_by column', () => {
+ const { error } = GET_req.validate({ zid: 5, sort_by: 'address' })
+ assert.ok(error)
+ })
+
+ it('rejects a negative offset', () => {
+ const { error } = GET_req.validate({ zid: 5, offset: -1 })
+ assert.ok(error)
+ })
+ })
})
diff --git a/package.json b/package.json
index c6f0584..527a649 100644
--- a/package.json
+++ b/package.json
@@ -29,7 +29,8 @@
"versions": "npx npm-dep-mgr check",
"versions:fix": "npx npm-dep-mgr update",
"watch": "node --test --watch",
- "test:coverage": "npx c8 --reporter=text --reporter=text-summary npm test"
+ "test:coverage": "node --test --experimental-test-coverage",
+ "test:coverage:lcov": "node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info"
},
"repository": {
"type": "git",
@@ -48,13 +49,13 @@
},
"homepage": "https://github.com/NicTool/validate#readme",
"devDependencies": {
- "eslint": "^10.2.0",
+ "eslint": "^10.7.0",
"@eslint/js": "^10.0.1",
"eslint-config-prettier": "^10.1.8",
- "globals": "^17.5.0"
+ "globals": "^17.7.0"
},
"dependencies": {
- "joi": "^18.1.2",
+ "joi": "^18.2.3",
"joi-password": "^4.3.0"
},
"prettier": {
@@ -63,4 +64,4 @@
"trailingComma": "all",
"printWidth": 100
}
-}
\ No newline at end of file
+}