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
13 changes: 3 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ on:
push:
branches: [main]

permissions:
contents: read

jobs:
lint:
permissions:
contents: read
uses: NicTool/.github/.github/workflows/lint.yml@main

get-lts:
Expand All @@ -27,8 +28,6 @@ jobs:
min: ${{ steps.get.outputs.min }}

test:
permissions:
contents: read
needs: [ get-lts ]
runs-on: ${{ matrix.os }}
strategy:
Expand All @@ -47,8 +46,6 @@ jobs:
- run: npm test

test-mac:
permissions:
contents: read
needs: [ get-lts ]
runs-on: macos-latest
strategy:
Expand All @@ -70,8 +67,6 @@ jobs:

test-docker:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- name: Generate .env
Expand All @@ -88,8 +83,6 @@ jobs:
run: docker compose -f docker/docker-compose.yml down -v

test-win:
permissions:
contents: read
needs: [ get-lts ]
runs-on: windows-latest
strategy:
Expand Down
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

### Unreleased

### [3.0.0-alpha.12] - 2026-04-13
### [3.0.0-alpha.13] - 2026-07-24

- fix(sql): quote mysql 8 keyword rank
- fix: subgroup searching
- feat: paginate zone records (#53)

### [3.0.0-alpha.12] - 2026-04-14

- toml backend (#49)
- add: TOML stores for group, nameserver, permission, session (#47)
Expand Down
5 changes: 5 additions & 0 deletions lib/group/store/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* put(args) → boolean
* delete(args) → boolean
* destroy(args) → boolean
* subgroupGids(rootGid) → number[] (rootGid plus every descendant)
*/
class GroupBase {
constructor(args = {}) {
Expand Down Expand Up @@ -43,6 +44,10 @@ class GroupBase {
async destroy(_args) {
throw new Error('destroy() not implemented by this repo')
}

async subgroupGids(_rootGid) {
throw new Error('subgroupGids() not implemented by this repo')
}
}

export default GroupBase
31 changes: 24 additions & 7 deletions lib/group/store/mysql.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ class Group extends GroupBase {

const parent_gid = args.parent_gid ?? 0

const gid = await Mysql.execute(...Mysql.insert(`nt_group`, mapToDbColumn(args, groupDbMap)))
const insertId = await Mysql.execute(...Mysql.insert(`nt_group`, mapToDbColumn(args, groupDbMap)))
// MySQL 8.0 returns insertId 0 for an explicit-id insert into an
// AUTO_INCREMENT column, so fall back to the supplied id; otherwise the
// subgroup + permission wiring below runs against the wrong gid (or is skipped).
const gid = args.id ?? insertId

if (gid && parent_gid !== 0) {
await this.addToSubgroups(gid, parent_gid)
Expand All @@ -41,12 +45,11 @@ class Group extends GroupBase {
async addToSubgroups(gid, parent_gid, rank = 1000) {
if (!parent_gid || parent_gid === 0) return

// `rank` is a reserved word in MySQL 8.0+, so it must be backticked; the
// generic Mysql.insert helper doesn't quote column names.
await Mysql.execute(
...Mysql.insert('nt_group_subgroups', {
nt_group_id: parent_gid,
nt_subgroup_id: gid,
rank,
}),
'INSERT INTO nt_group_subgroups (nt_group_id,nt_subgroup_id,`rank`) VALUES(?,?,?)',
[parent_gid, gid, rank],
)

const parent = await this.get({ id: parent_gid })
Expand Down Expand Up @@ -124,6 +127,16 @@ class Group extends GroupBase {
return groups
}

// rootGid plus every descendant subgroup id, from the nt_group_subgroups
// closure table (the same source the include_subgroups get() uses).
async subgroupGids(rootGid) {
const rows = await Mysql.execute(
'SELECT nt_subgroup_id FROM nt_group_subgroups WHERE nt_group_id = ?',
[rootGid],
)
return [rootGid, ...rows.map((r) => r.nt_subgroup_id)]
}

async put(args) {
if (!args.id) return false
const id = args.id
Expand Down Expand Up @@ -160,8 +173,12 @@ class Group extends GroupBase {
}

async destroy(args) {
// Clean up associated permission rows before removing the group
// Clean up associated permission and subgroup-closure rows before removing the group
await Mysql.execute(`DELETE FROM nt_perm WHERE nt_group_id = ? AND nt_user_id IS NULL`, [args.id])
await Mysql.execute(`DELETE FROM nt_group_subgroups WHERE nt_group_id = ? OR nt_subgroup_id = ?`, [
args.id,
args.id,
])
const r = await Mysql.execute(...Mysql.delete(`nt_group`, { nt_group_id: args.id }))
return r.affectedRows === 1
}
Expand Down
5 changes: 5 additions & 0 deletions lib/group/store/toml.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ class GroupRepoTOML extends GroupBase {
return ids
}

async subgroupGids(rootGid) {
const groups = await this._load()
return [rootGid, ...this._collectSubgroupIds(groups, rootGid)]
}

async create(args) {
args = JSON.parse(JSON.stringify(args))

Expand Down
8 changes: 7 additions & 1 deletion lib/group/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@ import { describe, it, after, before } from 'node:test'

import Group from '../index.js'

import testCase from '../test/group.json' with { type: 'json' }
import groupJson from '../test/group.json' with { type: 'json' }

// This suite renames and soft-deletes its group, so it uses a private group
// rather than the shared fixture that the concurrently-run user and permission
// suites depend on staying live and named.
const testCase = { ...groupJson, id: 4088, name: 'grouptest.example.com' }

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

Expand Down
9 changes: 7 additions & 2 deletions lib/mysql.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,13 @@ class Mysql {

async disconnect(dbh) {
const d = dbh || this.dbh
if (_debug) console.log(`MySQL connection id ${d.connection.connectionId}`)
if (d) await d.end()
if (!d) return
// Forget the singleton handle first so a concurrent/second disconnect is a
// no-op and the next execute() reconnects instead of reusing a dead handle.
if (!dbh) this.dbh = null
if (d.connection?._closing) return
if (_debug) console.log(`MySQL connection id ${d.connection?.connectionId}`)
await d.end()
}

debug(val) {
Expand Down
20 changes: 17 additions & 3 deletions lib/user/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,28 @@ import { describe, it, after, before } from 'node:test'
import User from '../index.js'
import Group from '../../group/index.js'

import userCase from './user.json' with { type: 'json' }
import groupCase from '../../group/test/group.json' with { type: 'json' }
import userJson from './user.json' with { type: 'json' }
import groupJson from '../../group/test/group.json' with { type: 'json' }

// This suite soft-deletes and restores its user, so it uses private fixtures
// rather than the shared 4096 user/group that the concurrently-run permission
// and session suites depend on staying present.
const userCase = {
...userJson,
id: 4085,
gid: 4085,
username: 'unit-test-lib',
email: 'unit-test-lib@example.com',
}
const groupCase = { ...groupJson, id: 4085, name: 'usertest.example.com' }

before(async () => {
await Group.create(groupCase)
})

after(async () => {
await User.destroy({ id: userCase.id })
await Group.destroy({ id: groupCase.id })
await User.disconnect()
})

Expand Down Expand Up @@ -49,7 +63,7 @@ describe('user', function () {
})

it('finds existing user by username', async () => {
const u = await User.get({ username: 'unit-test' })
const u = await User.get({ username: userCase.username })
assert.deepEqual(sanitizeActual(u[0]), sanitize(userCase))
})
})
Expand Down
19 changes: 18 additions & 1 deletion lib/zone/store/mysql.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ import { mapToDbColumn } from '../../util.js'
const zoneDbMap = { id: 'nt_zone_id', gid: 'nt_group_id' }
const boolFields = ['deleted']

// include_subgroups passes gid as a list of group ids; filter with IN(...).
function applyGidList(query, params, gidList) {
if (!gidList) return [query, params]
const connector = /\bWHERE\b/.test(query) ? ' AND' : ' WHERE'
if (gidList.length === 0) return [`${query}${connector} nt_group_id IN (NULL)`, params]
const placeholders = gidList.map(() => '?').join(', ')
return [`${query}${connector} nt_group_id IN (${placeholders})`, [...params, ...gidList]]
}

function applyZoneFilters(query, params, filters = {}) {
let nextQuery = query
const nextParams = [...params]
Expand Down Expand Up @@ -54,6 +63,9 @@ class ZoneRepoMySQL extends ZoneBase {
args = JSON.parse(JSON.stringify(args))
args.deleted = args.deleted ?? false

const gidList = Array.isArray(args.gid) ? args.gid : null
if (gidList) delete args.gid

const filters = {
search: args.search,
zone_like: args.zone_like,
Expand Down Expand Up @@ -101,6 +113,7 @@ class ZoneRepoMySQL extends ZoneBase {
)

let [finalQuery, finalParams] = applyZoneFilters(query, params, filters)
;[finalQuery, finalParams] = applyGidList(finalQuery, finalParams, gidList)
finalQuery += ` ORDER BY ${sortBy} ${sortDir}`

const rows = await Mysql.execute(`${finalQuery}${sqlLimit}`, finalParams)
Expand Down Expand Up @@ -138,6 +151,9 @@ class ZoneRepoMySQL extends ZoneBase {
args = JSON.parse(JSON.stringify(args))
args.deleted = args.deleted ?? false

const gidList = Array.isArray(args.gid) ? args.gid : null
if (gidList) delete args.gid

const filters = {
search: args.search,
zone_like: args.zone_like,
Expand All @@ -153,7 +169,8 @@ class ZoneRepoMySQL extends ZoneBase {
mapToDbColumn(args, zoneDbMap),
)

const [finalQuery, finalParams] = applyZoneFilters(query, params, filters)
let [finalQuery, finalParams] = applyZoneFilters(query, params, filters)
;[finalQuery, finalParams] = applyGidList(finalQuery, finalParams, gidList)
const rows = await Mysql.execute(finalQuery, finalParams)
return rows?.[0]?.total ?? 0
}
Expand Down
6 changes: 4 additions & 2 deletions lib/zone/store/toml.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ class ZoneRepoTOML extends ZoneBase {

// Direct field filters
if (id !== undefined) zones = zones.filter((z) => z.id === id)
if (gid !== undefined) zones = zones.filter((z) => z.gid === gid)
if (Array.isArray(gid)) zones = zones.filter((z) => gid.includes(z.gid))
else if (gid !== undefined) zones = zones.filter((z) => z.gid === gid)
if (zone !== undefined) zones = zones.filter((z) => z.zone === zone)
if (deletedArg === false) zones = zones.filter((z) => !z.deleted)
else if (deletedArg !== undefined) zones = zones.filter((z) => Boolean(z.deleted) === Boolean(deletedArg))
Expand Down Expand Up @@ -130,7 +131,8 @@ class ZoneRepoTOML extends ZoneBase {
let zones = await this._load()

if (id !== undefined) zones = zones.filter((z) => z.id === id)
if (gid !== undefined) zones = zones.filter((z) => z.gid === gid)
if (Array.isArray(gid)) zones = zones.filter((z) => gid.includes(z.gid))
else if (gid !== undefined) zones = zones.filter((z) => z.gid === gid)
if (deletedArg === false) zones = zones.filter((z) => !z.deleted)
else if (deletedArg !== undefined) zones = zones.filter((z) => Boolean(z.deleted) === Boolean(deletedArg))

Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nictool/api",
"version": "3.0.0-alpha.12",
"version": "3.0.0-alpha.13",
"description": "NicTool API",
"main": "index.js",
"type": "module",
Expand Down Expand Up @@ -54,14 +54,14 @@
},
"dependencies": {
"@hapi/cookie": "^12.0.1",
"@hapi/hapi": "^21.4.9",
"@hapi/hapi": "^21.4.10",
"@hapi/hoek": "^11.0.7",
"@hapi/inert": "^7.1.2",
"@hapi/jwt": "^3.2.4",
"@hapi/vision": "^7.0.3",
"@msimerson/hapi-openapi": "^18.0.0",
"@nictool/dns-resource-record": "^1.8.0",
"@nictool/validate": "^0.9.0",
"@nictool/validate": "^0.9.1",
"joi": "^18.2.3",
"mysql2": "^3.23.1",
"qs": "^6.15.3",
Expand Down
23 changes: 7 additions & 16 deletions routes/group.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,23 +51,14 @@ function GroupRoutes(server) {
include_subgroups: request.query.include_subgroups === true,
})

if (groups.length !== 1 && !request.query.include_subgroups) {
return h
.response({
meta: {
api: meta.api,
msg: `No unique group match`,
},
})
.code(204)
}

// Return an array like the other object types (zone/nameserver/user/
// zone_record) rather than a bare object, for a consistent API contract.
return h
.response({
group: request.query.include_subgroups ? groups : groups[0],
group: groups,
meta: {
api: meta.api,
msg: `here's your group`,
msg: `here's your group(s)`,
},
})
.code(200)
Expand All @@ -92,7 +83,7 @@ function GroupRoutes(server) {

return h
.response({
group: groups[0],
group: groups,
meta: {
api: meta.api,
msg: `I created this group`,
Expand Down Expand Up @@ -121,7 +112,7 @@ function GroupRoutes(server) {

return h
.response({
group: groups[0],
group: groups,
meta: {
api: meta.api,
msg: `I updated this group`,
Expand Down Expand Up @@ -177,7 +168,7 @@ function GroupRoutes(server) {

return h
.response({
group: groups[0],
group: groups,
meta: {
api: meta.api,
msg: `I deleted that group`,
Expand Down
Loading
Loading