Skip to content

Commit 0ebfc25

Browse files
authored
add: TOML stores for group, nameserver, permission, session (#47)
1 parent 8561a86 commit 0ebfc25

File tree

15 files changed

+1251
-322
lines changed

15 files changed

+1251
-322
lines changed

.github/copilot-instructions.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# NicTool API Copilot Instructions
2+
3+
## Commands
4+
5+
- Install dependencies: `npm install`
6+
- Start the API in production mode: `npm run start`
7+
- Start the API in watch mode: `npm run develop`
8+
- Run the full test suite: `npm test`
9+
- Run one test file with fixture setup/teardown: `npm test -- routes/session.test.js`
10+
- Run one library test file with fixture setup/teardown: `npm test -- lib/config.test.js`
11+
- Run tests in watch mode: `npm run watch`
12+
- Run lint: `npm run lint`
13+
- Run formatting check: `npm run prettier`
14+
- Run coverage: `npm run test:coverage`
15+
16+
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.
17+
18+
## Architecture
19+
20+
- `server.js` only starts the Hapi server from `routes/index.js`.
21+
- `routes/index.js` is the composition root: it loads TOML config via `lib/config.js`, registers Hapi plugins, configures JWT auth as the default auth strategy, enables Swagger docs, and mounts each resource route module.
22+
- `routes/*.js` own HTTP concerns only. They validate requests and responses with `@nictool/validate`, coerce route/query params, call the corresponding `lib/` module, and return NicTool’s standard JSON envelope with resource data plus `meta.api.version`.
23+
- `lib/<resource>/index.js` is usually a backend selector. The active implementation comes from `NICTOOL_DATA_STORE` and defaults to MySQL; some resources also support TOML, MongoDB, or Elasticsearch backends.
24+
- The real persistence logic lives under `lib/<resource>/store/*.js`. These stores translate between API-friendly field names and the legacy NicTool schema (`nt_*` columns), using helpers like `mapToDbColumn`, `objectToDb`, and `dbToObject`.
25+
- `lib/mysql.js` is the shared query builder/executor. Repositories rely on it for SQL generation rather than embedding ad hoc parameter handling in route files.
26+
- Auth is session-backed but bearer-token based: `POST /session` authenticates through the session repo, creates an `nt_user_session` row via `lib/session/index.js`, and returns a JWT. Almost every other route runs under the default JWT auth strategy.
27+
- Group, user, and permission behavior is coupled. Group creation also creates a group-level permission row, user reads attach effective permissions, and permission resolution falls back from explicit user permissions to group permissions.
28+
- DNS data has an extra translation layer. `lib/zone_record/store/mysql.js` validates records with `@nictool/dns-resource-record` and maps NicTool’s legacy zone-record columns to RFC-style record fields.
29+
- Config comes from `conf.d/*.toml`, with runtime overrides from `NICTOOL_DB_*` and `NICTOOL_HTTP_*`. `lib/config.js` also auto-loads TLS material from any `.pem` file in `conf.d/`.
30+
31+
## Conventions
32+
33+
- Preserve the route/lib split: request parsing, auth, and response shaping stay in `routes/`; DB and domain logic stay in `lib/`.
34+
- Keep request and response schemas in sync with `@nictool/validate`. Route handlers consistently declare both `validate` and `response.schema`.
35+
- Return the existing response envelope shape instead of raw rows. Resource payloads use keys like `user`, `group`, `zone`, `zone_record`, or `nameserver`, and responses include `meta: { api, msg }`.
36+
- Default auth is global. New public routes must opt out explicitly with route-level auth config, like `auth: { mode: 'try' }` on `POST /session`.
37+
- Soft delete is the default behavior across repositories. `delete()` usually sets `deleted = 1`, reads hide deleted rows unless `deleted: true` or `?deleted=true` is passed, and `destroy()` is reserved for hard-delete cleanup in tests or fixtures.
38+
- 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.
39+
- 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.
40+
- 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.
41+
- 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.
42+
- 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.

lib/group/store/toml.js

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import fs from 'node:fs/promises'
2+
import path from 'node:path'
3+
import { fileURLToPath } from 'node:url'
4+
5+
import { parse, stringify } from 'smol-toml'
6+
7+
import GroupBase from './base.js'
8+
9+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
10+
11+
const defaultPermissions = {
12+
inherit: false,
13+
self_write: false,
14+
group: { create: false, write: false, delete: false },
15+
nameserver: { usable: [], create: false, write: false, delete: false },
16+
zone: { create: false, write: false, delete: false, delegate: false },
17+
zonerecord: { create: false, write: false, delete: false, delegate: false },
18+
user: { create: false, write: false, delete: false },
19+
}
20+
21+
function resolveStorePath(filename) {
22+
const base = process.env.NICTOOL_DATA_STORE_PATH
23+
if (base) return path.join(base, filename)
24+
return path.resolve(__dirname, '../../../conf.d', filename)
25+
}
26+
27+
class GroupRepoTOML extends GroupBase {
28+
constructor(args = {}) {
29+
super(args)
30+
this._filePath = resolveStorePath('group.toml')
31+
}
32+
33+
async _load() {
34+
try {
35+
const str = await fs.readFile(this._filePath, 'utf8')
36+
const data = parse(str)
37+
return Array.isArray(data.group) ? data.group : []
38+
} catch (err) {
39+
if (err.code === 'ENOENT') return []
40+
throw err
41+
}
42+
}
43+
44+
async _save(groups) {
45+
await fs.mkdir(path.dirname(this._filePath), { recursive: true })
46+
await fs.writeFile(this._filePath, stringify({ group: groups }))
47+
}
48+
49+
_postProcess(row, deletedArg) {
50+
const r = JSON.parse(JSON.stringify(row))
51+
r.deleted = Boolean(r.deleted)
52+
if (r.permissions?.nameserver && !Array.isArray(r.permissions.nameserver.usable)) {
53+
r.permissions.nameserver.usable = []
54+
}
55+
if (deletedArg === false) delete r.deleted
56+
return r
57+
}
58+
59+
// BFS over parent_gid relationships to collect all descendant group ids.
60+
_collectSubgroupIds(groups, gid) {
61+
const ids = []
62+
const queue = [gid]
63+
while (queue.length) {
64+
const cur = queue.shift()
65+
for (const g of groups) {
66+
if (g.parent_gid === cur && !ids.includes(g.id)) {
67+
ids.push(g.id)
68+
queue.push(g.id)
69+
}
70+
}
71+
}
72+
return ids
73+
}
74+
75+
async create(args) {
76+
args = JSON.parse(JSON.stringify(args))
77+
78+
if (args.id) {
79+
const existing = await this.get({ id: args.id })
80+
if (existing.length === 1) return existing[0].id
81+
}
82+
83+
const usable_ns = args.usable_ns ?? []
84+
delete args.usable_ns
85+
86+
const gid = args.id
87+
args.permissions = {
88+
...JSON.parse(JSON.stringify(defaultPermissions)),
89+
id: gid,
90+
name: `Group ${args.name} perms`,
91+
user: { id: gid, create: false, write: false, delete: false },
92+
group: { id: gid, create: false, write: false, delete: false },
93+
nameserver: {
94+
usable: Array.isArray(usable_ns) ? usable_ns : [],
95+
create: false,
96+
write: false,
97+
delete: false,
98+
},
99+
}
100+
101+
const groups = await this._load()
102+
groups.push(args)
103+
await this._save(groups)
104+
return gid
105+
}
106+
107+
async get(args_orig) {
108+
const args = JSON.parse(JSON.stringify(args_orig))
109+
const deletedArg = args.deleted ?? false
110+
const include_subgroups = args.include_subgroups === true
111+
112+
let groups = await this._load()
113+
114+
if (args.id !== undefined) {
115+
if (include_subgroups) {
116+
const subIds = this._collectSubgroupIds(groups, args.id)
117+
const allIds = [args.id, ...subIds]
118+
groups = groups.filter((g) => allIds.includes(g.id))
119+
} else {
120+
groups = groups.filter((g) => g.id === args.id)
121+
}
122+
}
123+
124+
if (args.parent_gid !== undefined) groups = groups.filter((g) => g.parent_gid === args.parent_gid)
125+
if (args.name !== undefined) groups = groups.filter((g) => g.name === args.name)
126+
127+
if (deletedArg === false) groups = groups.filter((g) => !g.deleted)
128+
else if (deletedArg !== undefined)
129+
groups = groups.filter((g) => Boolean(g.deleted) === Boolean(deletedArg))
130+
131+
return groups.map((g) => this._postProcess(g, deletedArg))
132+
}
133+
134+
async put(args) {
135+
if (!args.id) return false
136+
args = JSON.parse(JSON.stringify(args))
137+
const id = args.id
138+
delete args.id
139+
140+
const usable_ns = args.usable_ns
141+
delete args.usable_ns
142+
143+
const groups = await this._load()
144+
const idx = groups.findIndex((g) => g.id === id)
145+
if (idx === -1) return false
146+
147+
if (usable_ns !== undefined && groups[idx].permissions) {
148+
groups[idx].permissions.nameserver = {
149+
...groups[idx].permissions.nameserver,
150+
usable: Array.isArray(usable_ns) ? usable_ns : [],
151+
}
152+
}
153+
154+
if (Object.keys(args).length > 0) {
155+
groups[idx] = { ...groups[idx], ...args }
156+
}
157+
158+
await this._save(groups)
159+
return true
160+
}
161+
162+
async delete(args) {
163+
const groups = await this._load()
164+
const idx = groups.findIndex((g) => g.id === args.id)
165+
if (idx === -1) return false
166+
167+
groups[idx].deleted = args.deleted ?? true
168+
await this._save(groups)
169+
return true
170+
}
171+
172+
async destroy(args) {
173+
const groups = await this._load()
174+
const before = groups.length
175+
const filtered = groups.filter((g) => g.id !== args.id)
176+
if (filtered.length === before) return false
177+
await this._save(filtered)
178+
return true
179+
}
180+
}
181+
182+
export default GroupRepoTOML

lib/nameserver/store/toml.js

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import fs from 'node:fs/promises'
2+
import path from 'node:path'
3+
import { fileURLToPath } from 'node:url'
4+
5+
import { parse, stringify } from 'smol-toml'
6+
7+
import NameserverBase from './base.js'
8+
9+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
10+
11+
const boolFields = ['deleted']
12+
13+
// Fields that default to empty string when absent or null
14+
const emptyStringFields = ['description', 'address6', 'remote_login', 'logdir', 'datadir']
15+
16+
function resolveStorePath(filename) {
17+
const base = process.env.NICTOOL_DATA_STORE_PATH
18+
if (base) return path.join(base, filename)
19+
return path.resolve(__dirname, '../../../conf.d', filename)
20+
}
21+
22+
class NameserverRepoTOML extends NameserverBase {
23+
constructor(args = {}) {
24+
super(args)
25+
this._filePath = resolveStorePath('nameserver.toml')
26+
}
27+
28+
async _load() {
29+
try {
30+
const str = await fs.readFile(this._filePath, 'utf8')
31+
const data = parse(str)
32+
return Array.isArray(data.nameserver) ? data.nameserver : []
33+
} catch (err) {
34+
if (err.code === 'ENOENT') return []
35+
throw err
36+
}
37+
}
38+
39+
async _save(nameservers) {
40+
await fs.mkdir(path.dirname(this._filePath), { recursive: true })
41+
await fs.writeFile(this._filePath, stringify({ nameserver: nameservers }))
42+
}
43+
44+
_postProcess(row, deletedArg) {
45+
const r = JSON.parse(JSON.stringify(row))
46+
47+
for (const b of boolFields) r[b] = Boolean(r[b])
48+
for (const f of emptyStringFields) {
49+
if ([null, undefined].includes(r[f])) r[f] = ''
50+
}
51+
52+
// Ensure the nested export object is always present and well-formed.
53+
// Unlike MySQL (which joins nt_nameserver_export_type), TOML stores the
54+
// type name inline, so no translation is needed.
55+
if (!r.export || typeof r.export !== 'object') r.export = {}
56+
if ([null, undefined].includes(r.export.type)) r.export.type = ''
57+
if ([null, undefined].includes(r.export.interval)) r.export.interval = 0
58+
if ([null, undefined].includes(r.export.status)) r.export.status = ''
59+
r.export.serials = Boolean(r.export.serials)
60+
61+
if (deletedArg === false) delete r.deleted
62+
return r
63+
}
64+
65+
async create(args) {
66+
if (args.id) {
67+
const existing = await this.get({ id: args.id })
68+
if (existing.length === 1) return existing[0].id
69+
}
70+
71+
const nameservers = await this._load()
72+
nameservers.push(JSON.parse(JSON.stringify(args)))
73+
await this._save(nameservers)
74+
return args.id
75+
}
76+
77+
async get(args) {
78+
args = JSON.parse(JSON.stringify(args))
79+
const deletedArg = args.deleted ?? false
80+
81+
let nameservers = await this._load()
82+
83+
if (args.id !== undefined) nameservers = nameservers.filter((n) => n.id === args.id)
84+
if (args.gid !== undefined) nameservers = nameservers.filter((n) => n.gid === args.gid)
85+
if (args.name !== undefined) nameservers = nameservers.filter((n) => n.name === args.name)
86+
if (deletedArg === false) nameservers = nameservers.filter((n) => !n.deleted)
87+
else if (deletedArg !== undefined)
88+
nameservers = nameservers.filter((n) => Boolean(n.deleted) === Boolean(deletedArg))
89+
90+
return nameservers.map((n) => this._postProcess(n, deletedArg))
91+
}
92+
93+
async put(args) {
94+
if (!args.id) return false
95+
const nameservers = await this._load()
96+
const idx = nameservers.findIndex((n) => n.id === args.id)
97+
if (idx === -1) return false
98+
99+
nameservers[idx] = { ...nameservers[idx], ...JSON.parse(JSON.stringify(args)) }
100+
await this._save(nameservers)
101+
return true
102+
}
103+
104+
async delete(args) {
105+
const nameservers = await this._load()
106+
const idx = nameservers.findIndex((n) => n.id === args.id)
107+
if (idx === -1) return false
108+
109+
nameservers[idx].deleted = args.deleted ?? true
110+
await this._save(nameservers)
111+
return true
112+
}
113+
114+
async destroy(args) {
115+
const nameservers = await this._load()
116+
const before = nameservers.length
117+
const filtered = nameservers.filter((n) => n.id !== args.id)
118+
if (filtered.length === before) return false
119+
await this._save(filtered)
120+
return true
121+
}
122+
}
123+
124+
export default NameserverRepoTOML

0 commit comments

Comments
 (0)