Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
- Run formatting check: `npm run prettier`
- Run coverage: `npm run test:coverage`

Local tests expect MySQL plus the NicTool schema. CI initializes it with `sh sql/init-mysql.sh`, and `test.sh` recreates fixtures before each run.
Local tests expect MySQL plus the NicTool schema. CI initializes it with `sh sql/init-mysql.sh`, and `test/run.sh` recreates fixtures before each run.

## Architecture

Expand All @@ -38,5 +38,5 @@ Local tests expect MySQL plus the NicTool schema. CI initializes it with `sh sql
- Reuse the legacy-schema mapping helpers instead of hand-rolling field conversions. Most repos convert booleans, nested permission/export objects, and short API names into the older DB layout before writing and normalize them again on read.
- When changing group or user behavior, check permission side effects too. Group creation/update touches permission rows, and user reads/write paths may change `inherit_group_permissions` handling.
- Route tests use `init()` plus `server.inject()` instead of booting a live server. They usually establish auth by calling `POST /session` and then pass `Authorization: Bearer <token>` to protected routes.
- The test entrypoint is `test.sh`, not raw `node --test`, when you need DB-backed behavior. It tears fixtures down, recreates them, and then runs the requested test target.
- The test entrypoint is `test/run.sh`, not raw `node --test`, when you need DB-backed behavior. It tears fixtures down, recreates them, and then runs the requested test target.
- Zone-record changes must preserve the existing record-field translation logic. Special cases like zero `weight`/`priority` retention for `SRV`, `URI`, `HTTPS`, and `SVCB` are intentional.
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ jobs:

test-docker:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- name: Generate .env
Expand Down Expand Up @@ -126,4 +128,4 @@ jobs:
node-version: ${{ matrix.node-version }}
- run: sh sql/init-mysql.sh
- run: npm install
- run: sh test.sh
- run: sh test/run.sh
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,4 @@ dist
package-lock.json
.release/
conf.d/*.pem
CLAUDE.md
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

### Unreleased

- move mysql teardown/disconnect into mysql classes
- fix: don't log sensitive information
- add: TOML stores for group, nameserver, permission, session

### [3.0.0-alpha.11] - 2026-04-07

- decorate user & group with permissions
Expand Down
8 changes: 5 additions & 3 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class Config {
const str = await fs.readFile(`./conf.d/${name}.toml`, 'utf8')
const cfg = parse(str)
applyEnvOverrides(name, cfg)
if (this.debug) console.debug(cfg)
// if (this.debug) console.debug(cfg)

if (name === 'http') {
const tls = await loadPEM('./conf.d')
Expand All @@ -37,7 +37,7 @@ class Config {
const str = fsSync.readFileSync(`./conf.d/${name}.toml`, 'utf8')
const cfg = parse(str)
applyEnvOverrides(name, cfg)
if (this.debug) console.debug(cfg)
// if (this.debug) console.debug(cfg)

if (name === 'http') {
const tls = loadPEMSync('./conf.d')
Expand Down Expand Up @@ -102,7 +102,9 @@ function loadPEMSync(dir) {
}

function parsePEMBlocks(content) {
const keyMatch = content.match(/-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z]+ )?PRIVATE KEY-----/)
const keyMatch = content.match(
/-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z]+ )?PRIVATE KEY-----/,
)
const certMatches = [...content.matchAll(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g)]

if (!keyMatch && !certMatches.length) return null
Expand Down
19 changes: 15 additions & 4 deletions lib/config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@

import Config from './config.js'

const envOverrideKeys = ['NICTOOL_DB_HOST', 'NICTOOL_DB_PORT', 'NICTOOL_DB_USER', 'NICTOOL_DB_USER_PASSWORD', 'NICTOOL_DB_NAME', 'NICTOOL_HTTP_HOST', 'NICTOOL_HTTP_PORT']
const envOverrideKeys = [
'NICTOOL_DB_HOST',
'NICTOOL_DB_PORT',
'NICTOOL_DB_USER',
'NICTOOL_DB_USER_PASSWORD',
'NICTOOL_DB_NAME',
'NICTOOL_HTTP_HOST',
'NICTOOL_HTTP_PORT',
]

describe('config', () => {
const savedEnv = {}
Expand All @@ -27,33 +35,36 @@
describe('get', () => {
it(`loads mysql config`, async () => {
const cfg = await Config.get('mysql')
delete cfg.password; delete cfg.user
delete cfg.password
delete cfg.user
assert.deepEqual(cfg, mysqlCfg)
})

it(`loads mysql config synchronously`, () => {
const cfg = Config.getSync('mysql')
delete cfg.password; delete cfg.user
delete cfg.password
delete cfg.user
})

it(`loads mysql config (from cache)`, async () => {
process.env.NODE_DEBUG = 1
const cfg = await Config.get('mysql')
delete cfg.password; delete cfg.user
delete cfg.password
delete cfg.user
assert.deepEqual(cfg, mysqlCfg)
process.env.NODE_DEBUG = ''
})

it(`loads http config`, async () => {
const cfg = await Config.get('http')
const { tls, ...rest } = cfg

Check warning on line 60 in lib/config.test.js

View workflow job for this annotation

GitHub Actions / lint / lint

'tls' is assigned a value but never used
delete rest.password
assert.deepEqual(rest, httpCfg)
})

it(`loads http config synchronously`, () => {
const cfg = Config.getSync('http')
const { tls, ...rest } = cfg

Check warning on line 67 in lib/config.test.js

View workflow job for this annotation

GitHub Actions / lint / lint

'tls' is assigned a value but never used
delete rest.password
assert.deepEqual(rest, httpCfg)
})
Expand Down
4 changes: 4 additions & 0 deletions lib/group/store/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ class GroupBase {
this.debug = args?.debug ?? false
}

disconnect() {
// noop, for repos that need to clean up resources
}

// -------------------------------------------------------------------------
// Repo contract – subclasses must implement these
// -------------------------------------------------------------------------
Expand Down
22 changes: 14 additions & 8 deletions lib/group/store/mysql.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,13 @@ class Group extends GroupBase {
async addToSubgroups(gid, parent_gid, rank = 1000) {
if (!parent_gid || parent_gid === 0) return

await Mysql.execute(...Mysql.insert('nt_group_subgroups', {
nt_group_id: parent_gid,
nt_subgroup_id: gid,
rank,
}))
await Mysql.execute(
...Mysql.insert('nt_group_subgroups', {
nt_group_id: parent_gid,
nt_subgroup_id: gid,
rank,
}),
)

const parent = await this.get({ id: parent_gid })
if (parent.length === 1 && parent[0].parent_gid !== 0) {
Expand Down Expand Up @@ -73,9 +75,9 @@ class Group extends GroupBase {
if (include_subgroups) {
const subgroupRows = await Mysql.execute(
'SELECT nt_subgroup_id FROM nt_group_subgroups WHERE nt_group_id = ?',
[args.id]
[args.id],
)
const gids = [args.id, ...subgroupRows.map(r => r.nt_subgroup_id)]
const gids = [args.id, ...subgroupRows.map((r) => r.nt_subgroup_id)]
where.push(`g.nt_group_id IN (${gids.join(',')})`)
} else {
where.push('g.nt_group_id = ?')
Expand Down Expand Up @@ -135,7 +137,7 @@ class Group extends GroupBase {
if (perm) {
await Permission.put({
id: perm.id,
nameserver: { usable: Array.isArray(usable_ns) ? usable_ns : [] }
nameserver: { usable: Array.isArray(usable_ns) ? usable_ns : [] },
})
}
}
Expand Down Expand Up @@ -163,6 +165,10 @@ class Group extends GroupBase {
const r = await Mysql.execute(...Mysql.delete(`nt_group`, { nt_group_id: args.id }))
return r.affectedRows === 1
}

disconnect() {
return this.mysql?.disconnect()
}
}

export default Group
2 changes: 1 addition & 1 deletion lib/group/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import Group from '../index.js'
import testCase from '../test/group.json' with { type: 'json' }

after(async () => {
Group.mysql.disconnect()
Group.disconnect()
})

describe('group', function () {
Expand Down
5 changes: 1 addition & 4 deletions lib/mysql.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,8 @@ class Mysql {
}

async connect() {
// if (this.dbh && this.dbh?.connection?.connectionId) return this.dbh;

const cfg = await Config.get('mysql')
if (_debug) console.log(cfg)

// if (_debug) console.log(cfg)
this.dbh = await mysql.createConnection(cfg)
if (_debug) console.log(`MySQL connection id ${this.dbh.connection.connectionId}`)
return this.dbh
Expand Down
4 changes: 4 additions & 0 deletions lib/nameserver/store/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ class NameserverBase {
async destroy(_args) {
throw new Error('destroy() not implemented by this repo')
}

disconnect() {
// noop, for repos that need to clean up resources
}
}

export default NameserverBase
4 changes: 4 additions & 0 deletions lib/nameserver/store/mysql.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ class Nameserver extends NameserverBase {
const r = await Mysql.execute(...Mysql.delete(`nt_nameserver`, { nt_nameserver_id: args.id }))
return r.affectedRows === 1
}

disconnect() {
return this.mysql?.disconnect()
}
}

export default Nameserver
Expand Down
17 changes: 1 addition & 16 deletions lib/nameserver/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ before(async () => {

after(async () => {
await Nameserver.destroy({ id: testCase.id })
Nameserver.mysql.disconnect()
await Nameserver.disconnect()
})

describe('nameserver', function () {
Expand All @@ -33,21 +33,6 @@ describe('nameserver', function () {
assert.ok(await Nameserver.put({ id: testCase.id, name: testCase.name }))
})

it('handles null export interval gracefully', async () => {
await Nameserver.mysql.execute(
'UPDATE nt_nameserver SET export_interval = NULL WHERE nt_nameserver_id = ?',
[testCase.id],
)

const ns = await Nameserver.get({ id: testCase.id })
assert.equal(ns[0].export.interval, undefined)

await Nameserver.mysql.execute(
'UPDATE nt_nameserver SET export_interval = ? WHERE nt_nameserver_id = ?',
[0, testCase.id],
)
})

it('deletes a nameserver', async () => {
assert.ok(await Nameserver.delete({ id: testCase.id }))
let g = await Nameserver.get({ id: testCase.id, deleted: 1 })
Expand Down
36 changes: 36 additions & 0 deletions lib/nameserver/test/mysql.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict'
import { describe, it, before, after } from 'node:test'

import Nameserver from '../index.js'

import baseCase from './nameserver.json' with { type: 'json' }

// Use a distinct id so this test never races with index.js (same fixture id = concurrent NULL mutation)
const testCase = { ...baseCase, id: 9001 }

before(async () => {
await Nameserver.destroy({ id: testCase.id })
await Nameserver.create(testCase)
})

after(async () => {
await Nameserver.destroy({ id: testCase.id })
await Nameserver.disconnect()
})

describe('nameserver (mysql)', function () {
it('handles null export interval gracefully', async () => {
await Nameserver.mysql.execute(
'UPDATE nt_nameserver SET export_interval = NULL WHERE nt_nameserver_id = ?',
[testCase.id],
)

const ns = await Nameserver.get({ id: testCase.id })
assert.equal(ns[0].export.interval, undefined)

await Nameserver.mysql.execute(
'UPDATE nt_nameserver SET export_interval = ? WHERE nt_nameserver_id = ?',
[testCase.export.interval, testCase.id],
)
})
})
8 changes: 5 additions & 3 deletions lib/permission/store/mysql.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,7 @@ class PermissionRepoMySQL extends PermissionBase {
if (!('nt_user_id' in dbArgs) && !('nt_perm_id' in dbArgs)) {
conditions.push('p.nt_user_id IS NULL')
}
const query = conditions.length
? `${baseQuery} WHERE ${conditions.join(' AND ')}`
: baseQuery
const query = conditions.length ? `${baseQuery} WHERE ${conditions.join(' AND ')}` : baseQuery

const rows = await Mysql.execute(query, params)
if (rows.length === 0) return
Expand Down Expand Up @@ -119,6 +117,10 @@ class PermissionRepoMySQL extends PermissionBase {
const r = await Mysql.execute(...Mysql.delete(`nt_perm`, mapToDbColumn(args, permDbMap)))
return r.affectedRows === 1
}

disconnect() {
return this.mysql?.disconnect()
}
}

export default PermissionRepoMySQL
Expand Down
Loading
Loading