@better-forge/config-as-code 0.1.0 → 0.1.1
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/LICENSE +21 -21
- package/README.md +10 -10
- package/package.json +51 -51
- package/src/document.ts +199 -199
- package/src/engine.ts +258 -258
- package/src/index.ts +16 -16
- package/src/keys.ts +64 -64
- package/src/routes.ts +143 -143
- package/src/yaml.ts +64 -64
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 BetterStatusPage contributors
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BetterStatusPage contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# @better-forge/config-as-code
|
|
2
|
-
|
|
3
|
-
Configuration as code: the objects of an application as YAML documents, exported and applied back.
|
|
4
|
-
|
|
5
|
-
- `createConfigEngine` reads a stream of documents (`---` separated), each with a `kind` registered by the application, checks the whole file, then either reports what would change (`dryRun`) or applies it in one transaction, writes the audit entries after the commit and exports the documents again. A kind supplies how to parse, plan, apply and export its documents; the engine supplies the checks that do not depend on the kind (keys, unknown fields, `__proto__`, at most 100 problems), vault references by name, and the order of everything.
|
|
6
|
-
- `createConfigRoutes` serves `GET /export` and `POST /validate`, `POST /apply` with the permission checks per kind (API token resource or user role), the media type and size checks.
|
|
7
|
-
- `toYaml`, `toYamlStream`, `readDocuments` write and read the YAML (1.1-safe quoting, no folding, limits on documents and aliases).
|
|
8
|
-
- Entity keys: `isValidEntityKey`, `slugifyKey`, `keyForNew` and `backfillKeys` give every object the stable key a document refers to it by.
|
|
9
|
-
|
|
10
|
-
The database, the transaction and the audit log are supplied by the application. Depends on `@better-forge/contracts` (token permissions) and `yaml`.
|
|
1
|
+
# @better-forge/config-as-code
|
|
2
|
+
|
|
3
|
+
Configuration as code: the objects of an application as YAML documents, exported and applied back.
|
|
4
|
+
|
|
5
|
+
- `createConfigEngine` reads a stream of documents (`---` separated), each with a `kind` registered by the application, checks the whole file, then either reports what would change (`dryRun`) or applies it in one transaction, writes the audit entries after the commit and exports the documents again. A kind supplies how to parse, plan, apply and export its documents; the engine supplies the checks that do not depend on the kind (keys, unknown fields, `__proto__`, at most 100 problems), vault references by name, and the order of everything.
|
|
6
|
+
- `createConfigRoutes` serves `GET /export` and `POST /validate`, `POST /apply` with the permission checks per kind (API token resource or user role), the media type and size checks.
|
|
7
|
+
- `toYaml`, `toYamlStream`, `readDocuments` write and read the YAML (1.1-safe quoting, no folding, limits on documents and aliases).
|
|
8
|
+
- Entity keys: `isValidEntityKey`, `slugifyKey`, `keyForNew` and `backfillKeys` give every object the stable key a document refers to it by.
|
|
9
|
+
|
|
10
|
+
The database, the transaction and the audit log are supplied by the application. Depends on `@better-forge/contracts` (token permissions) and `yaml`.
|
package/package.json
CHANGED
|
@@ -1,51 +1,51 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@better-forge/config-as-code",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "YAML configuration engine with a registry of object kinds.",
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"repository": {
|
|
7
|
-
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/BElluu/Better-Forge.git",
|
|
9
|
-
"directory": "packages/config-as-code"
|
|
10
|
-
},
|
|
11
|
-
"homepage": "https://github.com/BElluu/Better-Forge/tree/main/packages/config-as-code#readme",
|
|
12
|
-
"bugs": {
|
|
13
|
-
"url": "https://github.com/BElluu/Better-Forge/issues"
|
|
14
|
-
},
|
|
15
|
-
"publishConfig": {
|
|
16
|
-
"access": "public"
|
|
17
|
-
},
|
|
18
|
-
"type": "module",
|
|
19
|
-
"main": "./dist/index.js",
|
|
20
|
-
"types": "./dist/index.d.ts",
|
|
21
|
-
"exports": {
|
|
22
|
-
".": {
|
|
23
|
-
"types": "./dist/index.d.ts",
|
|
24
|
-
"import": "./dist/index.js"
|
|
25
|
-
},
|
|
26
|
-
"./package.json": "./package.json"
|
|
27
|
-
},
|
|
28
|
-
"files": [
|
|
29
|
-
"dist",
|
|
30
|
-
"src",
|
|
31
|
-
"!src/**/*.test.*"
|
|
32
|
-
],
|
|
33
|
-
"scripts": {
|
|
34
|
-
"build": "tsc -p tsconfig.json",
|
|
35
|
-
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
|
|
36
|
-
},
|
|
37
|
-
"peerDependencies": {
|
|
38
|
-
"fastify": "^5.3.2"
|
|
39
|
-
},
|
|
40
|
-
"devDependencies": {
|
|
41
|
-
"typescript": "^5.7.3",
|
|
42
|
-
"fastify": "^5.3.2"
|
|
43
|
-
},
|
|
44
|
-
"dependencies": {
|
|
45
|
-
"yaml": "^2.9.1",
|
|
46
|
-
"@better-forge/contracts": "0.1.
|
|
47
|
-
},
|
|
48
|
-
"engines": {
|
|
49
|
-
"node": ">=26.10.0"
|
|
50
|
-
}
|
|
51
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@better-forge/config-as-code",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "YAML configuration engine with a registry of object kinds.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/BElluu/Better-Forge.git",
|
|
9
|
+
"directory": "packages/config-as-code"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/BElluu/Better-Forge/tree/main/packages/config-as-code#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/BElluu/Better-Forge/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"src",
|
|
31
|
+
"!src/**/*.test.*"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.json",
|
|
35
|
+
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"fastify": "^5.3.2"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"typescript": "^5.7.3",
|
|
42
|
+
"fastify": "^5.3.2"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"yaml": "^2.9.1",
|
|
46
|
+
"@better-forge/contracts": "0.1.1"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=26.10.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/document.ts
CHANGED
|
@@ -1,199 +1,199 @@
|
|
|
1
|
-
export type Json = Record<string, unknown>
|
|
2
|
-
export const isObject = (value: unknown): value is Json => !!value && typeof value === 'object' && !Array.isArray(value)
|
|
3
|
-
|
|
4
|
-
export interface ConfigProblem { path: string; message: string }
|
|
5
|
-
|
|
6
|
-
/** The file is not valid; `problems` lists what is wrong with it, not just the first thing. */
|
|
7
|
-
export class ConfigInvalidError extends Error {
|
|
8
|
-
constructor(readonly problems: ConfigProblem[]) {
|
|
9
|
-
super(problems[0] ? `${problems[0].path}: ${problems[0].message}` : 'Invalid configuration')
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/** Most problems a file reports: the first ones are the ones to fix first. */
|
|
14
|
-
const MAX_PROBLEMS = 100
|
|
15
|
-
|
|
16
|
-
/** Collects what is wrong with a file. */
|
|
17
|
-
export class Problems {
|
|
18
|
-
readonly list: ConfigProblem[] = []
|
|
19
|
-
add = (path: string, message: string) => { if (this.list.length < MAX_PROBLEMS) this.list.push({ path, message }) }
|
|
20
|
-
get any() { return this.list.length > 0 }
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Shortens text that comes from a request before it is repeated in an answer, so an error cannot be made to echo a megabyte. */
|
|
24
|
-
export function clip(value: string, max = 80): string {
|
|
25
|
-
return value.length > max ? `${value.slice(0, max)}…` : value
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Whether `value` nests objects or lists deeper than `max` levels. Checked without recursion, so it cannot overflow the stack. */
|
|
29
|
-
export function nestedDeeperThan(value: unknown, max: number): boolean {
|
|
30
|
-
const pending: Array<[unknown, number]> = [[value, 0]]
|
|
31
|
-
while (pending.length) {
|
|
32
|
-
const [current, depth] = pending.pop()!
|
|
33
|
-
if (!current || typeof current !== 'object') continue
|
|
34
|
-
if (depth >= max) return true
|
|
35
|
-
for (const inner of Object.values(current)) pending.push([inner, depth + 1])
|
|
36
|
-
}
|
|
37
|
-
return false
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** The same value written with its keys in a fixed order, so equal settings compare equal whatever their order. */
|
|
41
|
-
export function canonical(value: unknown): string {
|
|
42
|
-
return JSON.stringify(value, (_key, inner) => isObject(inner) ? Object.fromEntries(Object.keys(inner).sort().map((key) => [key, inner[key]])) : inner)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Orders documents by key. A tie sorts the second one first, which never happens: keys are unique. */
|
|
46
|
-
export const byKey = (a: { key: string }, b: { key: string }) => (a.key < b.key ? -1 : 1)
|
|
47
|
-
|
|
48
|
-
/** Reports every field of `entry` that is not in `allowed`. */
|
|
49
|
-
export function unknownFields(entry: Json, allowed: readonly string[], path: string, problems: Problems): void {
|
|
50
|
-
for (const field of Object.keys(entry)) if (!allowed.includes(field)) problems.add(`${path}.${clip(field)}`, 'is not a known setting')
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Where a name the parser would treat specially (`__proto__`) appears in a document, or null. Walks without recursion. */
|
|
54
|
-
export function forbiddenNameAt(document: unknown, label: string): string | null {
|
|
55
|
-
const pending: Array<[unknown, string]> = [[document, label]]
|
|
56
|
-
while (pending.length) {
|
|
57
|
-
const [value, path] = pending.pop()!
|
|
58
|
-
if (!value || typeof value !== 'object') continue
|
|
59
|
-
if (!Array.isArray(value) && Object.prototype.hasOwnProperty.call(value, '__proto__')) return `${path}.__proto__`
|
|
60
|
-
const entries: Array<[string, unknown]> = Array.isArray(value) ? value.map((item, index) => [`[${index}]`, item]) : Object.entries(value)
|
|
61
|
-
for (const [name, inner] of entries) pending.push([inner, name.startsWith('[') ? `${path}${name}` : `${path}.${clip(name)}`])
|
|
62
|
-
}
|
|
63
|
-
return null
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** A dependency cycle among `edges` (key -> keys it depends on), as `a → b → a`, or null. */
|
|
67
|
-
export function findCycle(edges: Map<string, string[]>): string | null {
|
|
68
|
-
const done = new Set<string>()
|
|
69
|
-
const stack: string[] = []
|
|
70
|
-
const visit = (key: string): string | null => {
|
|
71
|
-
const at = stack.indexOf(key)
|
|
72
|
-
if (at >= 0) return [...stack.slice(at), key].join(' → ')
|
|
73
|
-
if (done.has(key)) return null
|
|
74
|
-
stack.push(key)
|
|
75
|
-
for (const next of edges.get(key) ?? []) {
|
|
76
|
-
const cycle = visit(next)
|
|
77
|
-
if (cycle) return cycle
|
|
78
|
-
}
|
|
79
|
-
stack.pop()
|
|
80
|
-
done.add(key)
|
|
81
|
-
return null
|
|
82
|
-
}
|
|
83
|
-
for (const key of edges.keys()) {
|
|
84
|
-
const cycle = visit(key)
|
|
85
|
-
if (cycle) return cycle
|
|
86
|
-
}
|
|
87
|
-
return null
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// ── References to vault secrets ──────────────────────────────────────────────
|
|
91
|
-
|
|
92
|
-
/** The vaults and secrets of an installation, as far as a file needs to name them. */
|
|
93
|
-
export interface VaultRows {
|
|
94
|
-
vaults: ReadonlyArray<{ id: number; name: string }>
|
|
95
|
-
secrets: ReadonlyArray<{ id: number; vaultId: number; name: string }>
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** A vault secret named instead of numbered, so the file means the same on another installation. */
|
|
99
|
-
export interface ConfigVaultRef { vault: string; secret: string; fieldMapping?: Record<string, string> }
|
|
100
|
-
|
|
101
|
-
/** `{ vault: 'Production', secret: 'db-login' }` back to the ids this installation knows them by. */
|
|
102
|
-
export function createVaultResolver(rows: VaultRows) {
|
|
103
|
-
const vaultsByName = new Map<string, number[]>()
|
|
104
|
-
for (const vault of rows.vaults) vaultsByName.set(vault.name, [...(vaultsByName.get(vault.name) ?? []), vault.id])
|
|
105
|
-
const secretId = new Map(rows.secrets.map((secret) => [`${secret.vaultId}:${secret.name}`, secret.id]))
|
|
106
|
-
|
|
107
|
-
return function resolve(value: unknown, path: string, problems: Problems): unknown {
|
|
108
|
-
if (Array.isArray(value)) return value.map((item, index) => resolve(item, `${path}[${index}]`, problems))
|
|
109
|
-
if (!isObject(value)) return value
|
|
110
|
-
return Object.fromEntries(Object.entries(value).map(([key, inner]) => {
|
|
111
|
-
if (key !== 'vault') return [key, resolve(inner, `${path}.${key}`, problems)]
|
|
112
|
-
const at = `${path}.vault`
|
|
113
|
-
if (!isObject(inner) || typeof inner['vault'] !== 'string' || typeof inner['secret'] !== 'string') {
|
|
114
|
-
problems.add(at, 'must name the vault and the secret: { vault: <name>, secret: <name> }')
|
|
115
|
-
return [key, inner]
|
|
116
|
-
}
|
|
117
|
-
if ('vaultId' in inner || 'secretId' in inner) problems.add(at, 'a configuration file names a vault secret, it does not use numeric ids')
|
|
118
|
-
const found = vaultsByName.get(inner['vault']) ?? []
|
|
119
|
-
if (found.length !== 1) {
|
|
120
|
-
problems.add(at, found.length === 0
|
|
121
|
-
? (inner['vault'].startsWith('(deleted') ? `${clip(inner['vault'])} refers to a vault that no longer exists` : `unknown vault "${clip(inner['vault'])}"`)
|
|
122
|
-
: `more than one vault is named "${clip(inner['vault'])}"; rename one of them`)
|
|
123
|
-
return [key, inner]
|
|
124
|
-
}
|
|
125
|
-
const secret = secretId.get(`${found[0]}:${inner['secret']}`)
|
|
126
|
-
if (secret === undefined) {
|
|
127
|
-
problems.add(at, inner['secret'].startsWith('(deleted') ? `${clip(inner['secret'])} refers to a secret that no longer exists` : `vault "${clip(inner['vault'])}" has no secret "${clip(inner['secret'])}"`)
|
|
128
|
-
return [key, inner]
|
|
129
|
-
}
|
|
130
|
-
return [key, { vaultId: found[0], secretId: secret, ...(isObject(inner['fieldMapping']) ? { fieldMapping: inner['fieldMapping'] } : {}) }]
|
|
131
|
-
}))
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** The file would refer to something it cannot name unambiguously, so it could not be read back. */
|
|
136
|
-
export class ConfigExportError extends Error {}
|
|
137
|
-
|
|
138
|
-
const deleted = (what: string, id: unknown) => `(deleted ${what} ${String(id)})`
|
|
139
|
-
|
|
140
|
-
function isVaultRef(value: unknown): value is { vaultId: number; secretId: number; fieldMapping?: unknown } {
|
|
141
|
-
return isObject(value) && typeof value['vaultId'] === 'number' && typeof value['secretId'] === 'number'
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Replaces every `vault: { vaultId, secretId }` reference in a config with the names of the vault and secret. */
|
|
145
|
-
export function createVaultExporter(rows: VaultRows) {
|
|
146
|
-
const nameCount = new Map<string, number>()
|
|
147
|
-
for (const vault of rows.vaults) nameCount.set(vault.name, (nameCount.get(vault.name) ?? 0) + 1)
|
|
148
|
-
const vaultName = new Map(rows.vaults.map((vault) => [vault.id, vault.name]))
|
|
149
|
-
const secret = new Map(rows.secrets.map((row) => [row.id, row]))
|
|
150
|
-
|
|
151
|
-
function exportRef(ref: { vaultId: number; secretId: number; fieldMapping?: unknown }): ConfigVaultRef {
|
|
152
|
-
const vault = vaultName.get(ref.vaultId)
|
|
153
|
-
const found = secret.get(ref.secretId)
|
|
154
|
-
if (vault !== undefined && (nameCount.get(vault) ?? 0) > 1) {
|
|
155
|
-
throw new ConfigExportError(`More than one vault is named "${vault}", so a reference to it cannot be exported. Rename one of them.`)
|
|
156
|
-
}
|
|
157
|
-
return {
|
|
158
|
-
vault: vault ?? deleted('vault', ref.vaultId),
|
|
159
|
-
secret: found && found.vaultId === ref.vaultId ? found.name : deleted('secret', ref.secretId),
|
|
160
|
-
...(isObject(ref.fieldMapping) ? { fieldMapping: ref.fieldMapping as Record<string, string> } : {}),
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return function exportRefs(value: unknown): unknown {
|
|
165
|
-
if (Array.isArray(value)) return value.map((item) => exportRefs(item))
|
|
166
|
-
if (!isObject(value)) return value
|
|
167
|
-
return Object.fromEntries(Object.entries(value).map(([key, inner]) =>
|
|
168
|
-
[key, key === 'vault' && isVaultRef(inner) ? exportRef(inner) : exportRefs(inner)]))
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ── Fields every kind of document reads the same way ─────────────────────────
|
|
173
|
-
|
|
174
|
-
/** A list of keys (`items: [a, b]`), without repeats; problems are reported at `path`. */
|
|
175
|
-
export function keyList(value: unknown, path: string, problems: Problems, isValidKey: (key: unknown) => boolean): string[] {
|
|
176
|
-
if (value === undefined) return []
|
|
177
|
-
if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) {
|
|
178
|
-
problems.add(path, 'must be a list of keys')
|
|
179
|
-
return []
|
|
180
|
-
}
|
|
181
|
-
for (const key of value) if (!isValidKey(key)) problems.add(path, `"${clip(key)}" is not a valid key`)
|
|
182
|
-
return [...new Set(value as string[])]
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** The key of a document, or '' (with a problem) when it has none that is usable; a repeat within `seen` is a problem too. */
|
|
186
|
-
export function documentKey(
|
|
187
|
-
entry: Json, path: string, seen: Set<string>, problems: Problems,
|
|
188
|
-
keys: { isValid: (key: unknown) => boolean; error: string },
|
|
189
|
-
): string {
|
|
190
|
-
const key = entry['key']
|
|
191
|
-
if (!keys.isValid(key)) {
|
|
192
|
-
problems.add(`${path}.key`, key === undefined ? 'is required' : keys.error)
|
|
193
|
-
return ''
|
|
194
|
-
}
|
|
195
|
-
const text = key as string
|
|
196
|
-
if (seen.has(text)) problems.add(`${path}.key`, `"${clip(text)}" is used by another document`)
|
|
197
|
-
seen.add(text)
|
|
198
|
-
return text
|
|
199
|
-
}
|
|
1
|
+
export type Json = Record<string, unknown>
|
|
2
|
+
export const isObject = (value: unknown): value is Json => !!value && typeof value === 'object' && !Array.isArray(value)
|
|
3
|
+
|
|
4
|
+
export interface ConfigProblem { path: string; message: string }
|
|
5
|
+
|
|
6
|
+
/** The file is not valid; `problems` lists what is wrong with it, not just the first thing. */
|
|
7
|
+
export class ConfigInvalidError extends Error {
|
|
8
|
+
constructor(readonly problems: ConfigProblem[]) {
|
|
9
|
+
super(problems[0] ? `${problems[0].path}: ${problems[0].message}` : 'Invalid configuration')
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Most problems a file reports: the first ones are the ones to fix first. */
|
|
14
|
+
const MAX_PROBLEMS = 100
|
|
15
|
+
|
|
16
|
+
/** Collects what is wrong with a file. */
|
|
17
|
+
export class Problems {
|
|
18
|
+
readonly list: ConfigProblem[] = []
|
|
19
|
+
add = (path: string, message: string) => { if (this.list.length < MAX_PROBLEMS) this.list.push({ path, message }) }
|
|
20
|
+
get any() { return this.list.length > 0 }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Shortens text that comes from a request before it is repeated in an answer, so an error cannot be made to echo a megabyte. */
|
|
24
|
+
export function clip(value: string, max = 80): string {
|
|
25
|
+
return value.length > max ? `${value.slice(0, max)}…` : value
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether `value` nests objects or lists deeper than `max` levels. Checked without recursion, so it cannot overflow the stack. */
|
|
29
|
+
export function nestedDeeperThan(value: unknown, max: number): boolean {
|
|
30
|
+
const pending: Array<[unknown, number]> = [[value, 0]]
|
|
31
|
+
while (pending.length) {
|
|
32
|
+
const [current, depth] = pending.pop()!
|
|
33
|
+
if (!current || typeof current !== 'object') continue
|
|
34
|
+
if (depth >= max) return true
|
|
35
|
+
for (const inner of Object.values(current)) pending.push([inner, depth + 1])
|
|
36
|
+
}
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The same value written with its keys in a fixed order, so equal settings compare equal whatever their order. */
|
|
41
|
+
export function canonical(value: unknown): string {
|
|
42
|
+
return JSON.stringify(value, (_key, inner) => isObject(inner) ? Object.fromEntries(Object.keys(inner).sort().map((key) => [key, inner[key]])) : inner)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Orders documents by key. A tie sorts the second one first, which never happens: keys are unique. */
|
|
46
|
+
export const byKey = (a: { key: string }, b: { key: string }) => (a.key < b.key ? -1 : 1)
|
|
47
|
+
|
|
48
|
+
/** Reports every field of `entry` that is not in `allowed`. */
|
|
49
|
+
export function unknownFields(entry: Json, allowed: readonly string[], path: string, problems: Problems): void {
|
|
50
|
+
for (const field of Object.keys(entry)) if (!allowed.includes(field)) problems.add(`${path}.${clip(field)}`, 'is not a known setting')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Where a name the parser would treat specially (`__proto__`) appears in a document, or null. Walks without recursion. */
|
|
54
|
+
export function forbiddenNameAt(document: unknown, label: string): string | null {
|
|
55
|
+
const pending: Array<[unknown, string]> = [[document, label]]
|
|
56
|
+
while (pending.length) {
|
|
57
|
+
const [value, path] = pending.pop()!
|
|
58
|
+
if (!value || typeof value !== 'object') continue
|
|
59
|
+
if (!Array.isArray(value) && Object.prototype.hasOwnProperty.call(value, '__proto__')) return `${path}.__proto__`
|
|
60
|
+
const entries: Array<[string, unknown]> = Array.isArray(value) ? value.map((item, index) => [`[${index}]`, item]) : Object.entries(value)
|
|
61
|
+
for (const [name, inner] of entries) pending.push([inner, name.startsWith('[') ? `${path}${name}` : `${path}.${clip(name)}`])
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A dependency cycle among `edges` (key -> keys it depends on), as `a → b → a`, or null. */
|
|
67
|
+
export function findCycle(edges: Map<string, string[]>): string | null {
|
|
68
|
+
const done = new Set<string>()
|
|
69
|
+
const stack: string[] = []
|
|
70
|
+
const visit = (key: string): string | null => {
|
|
71
|
+
const at = stack.indexOf(key)
|
|
72
|
+
if (at >= 0) return [...stack.slice(at), key].join(' → ')
|
|
73
|
+
if (done.has(key)) return null
|
|
74
|
+
stack.push(key)
|
|
75
|
+
for (const next of edges.get(key) ?? []) {
|
|
76
|
+
const cycle = visit(next)
|
|
77
|
+
if (cycle) return cycle
|
|
78
|
+
}
|
|
79
|
+
stack.pop()
|
|
80
|
+
done.add(key)
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
for (const key of edges.keys()) {
|
|
84
|
+
const cycle = visit(key)
|
|
85
|
+
if (cycle) return cycle
|
|
86
|
+
}
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── References to vault secrets ──────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/** The vaults and secrets of an installation, as far as a file needs to name them. */
|
|
93
|
+
export interface VaultRows {
|
|
94
|
+
vaults: ReadonlyArray<{ id: number; name: string }>
|
|
95
|
+
secrets: ReadonlyArray<{ id: number; vaultId: number; name: string }>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A vault secret named instead of numbered, so the file means the same on another installation. */
|
|
99
|
+
export interface ConfigVaultRef { vault: string; secret: string; fieldMapping?: Record<string, string> }
|
|
100
|
+
|
|
101
|
+
/** `{ vault: 'Production', secret: 'db-login' }` back to the ids this installation knows them by. */
|
|
102
|
+
export function createVaultResolver(rows: VaultRows) {
|
|
103
|
+
const vaultsByName = new Map<string, number[]>()
|
|
104
|
+
for (const vault of rows.vaults) vaultsByName.set(vault.name, [...(vaultsByName.get(vault.name) ?? []), vault.id])
|
|
105
|
+
const secretId = new Map(rows.secrets.map((secret) => [`${secret.vaultId}:${secret.name}`, secret.id]))
|
|
106
|
+
|
|
107
|
+
return function resolve(value: unknown, path: string, problems: Problems): unknown {
|
|
108
|
+
if (Array.isArray(value)) return value.map((item, index) => resolve(item, `${path}[${index}]`, problems))
|
|
109
|
+
if (!isObject(value)) return value
|
|
110
|
+
return Object.fromEntries(Object.entries(value).map(([key, inner]) => {
|
|
111
|
+
if (key !== 'vault') return [key, resolve(inner, `${path}.${key}`, problems)]
|
|
112
|
+
const at = `${path}.vault`
|
|
113
|
+
if (!isObject(inner) || typeof inner['vault'] !== 'string' || typeof inner['secret'] !== 'string') {
|
|
114
|
+
problems.add(at, 'must name the vault and the secret: { vault: <name>, secret: <name> }')
|
|
115
|
+
return [key, inner]
|
|
116
|
+
}
|
|
117
|
+
if ('vaultId' in inner || 'secretId' in inner) problems.add(at, 'a configuration file names a vault secret, it does not use numeric ids')
|
|
118
|
+
const found = vaultsByName.get(inner['vault']) ?? []
|
|
119
|
+
if (found.length !== 1) {
|
|
120
|
+
problems.add(at, found.length === 0
|
|
121
|
+
? (inner['vault'].startsWith('(deleted') ? `${clip(inner['vault'])} refers to a vault that no longer exists` : `unknown vault "${clip(inner['vault'])}"`)
|
|
122
|
+
: `more than one vault is named "${clip(inner['vault'])}"; rename one of them`)
|
|
123
|
+
return [key, inner]
|
|
124
|
+
}
|
|
125
|
+
const secret = secretId.get(`${found[0]}:${inner['secret']}`)
|
|
126
|
+
if (secret === undefined) {
|
|
127
|
+
problems.add(at, inner['secret'].startsWith('(deleted') ? `${clip(inner['secret'])} refers to a secret that no longer exists` : `vault "${clip(inner['vault'])}" has no secret "${clip(inner['secret'])}"`)
|
|
128
|
+
return [key, inner]
|
|
129
|
+
}
|
|
130
|
+
return [key, { vaultId: found[0], secretId: secret, ...(isObject(inner['fieldMapping']) ? { fieldMapping: inner['fieldMapping'] } : {}) }]
|
|
131
|
+
}))
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The file would refer to something it cannot name unambiguously, so it could not be read back. */
|
|
136
|
+
export class ConfigExportError extends Error {}
|
|
137
|
+
|
|
138
|
+
const deleted = (what: string, id: unknown) => `(deleted ${what} ${String(id)})`
|
|
139
|
+
|
|
140
|
+
function isVaultRef(value: unknown): value is { vaultId: number; secretId: number; fieldMapping?: unknown } {
|
|
141
|
+
return isObject(value) && typeof value['vaultId'] === 'number' && typeof value['secretId'] === 'number'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Replaces every `vault: { vaultId, secretId }` reference in a config with the names of the vault and secret. */
|
|
145
|
+
export function createVaultExporter(rows: VaultRows) {
|
|
146
|
+
const nameCount = new Map<string, number>()
|
|
147
|
+
for (const vault of rows.vaults) nameCount.set(vault.name, (nameCount.get(vault.name) ?? 0) + 1)
|
|
148
|
+
const vaultName = new Map(rows.vaults.map((vault) => [vault.id, vault.name]))
|
|
149
|
+
const secret = new Map(rows.secrets.map((row) => [row.id, row]))
|
|
150
|
+
|
|
151
|
+
function exportRef(ref: { vaultId: number; secretId: number; fieldMapping?: unknown }): ConfigVaultRef {
|
|
152
|
+
const vault = vaultName.get(ref.vaultId)
|
|
153
|
+
const found = secret.get(ref.secretId)
|
|
154
|
+
if (vault !== undefined && (nameCount.get(vault) ?? 0) > 1) {
|
|
155
|
+
throw new ConfigExportError(`More than one vault is named "${vault}", so a reference to it cannot be exported. Rename one of them.`)
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
vault: vault ?? deleted('vault', ref.vaultId),
|
|
159
|
+
secret: found && found.vaultId === ref.vaultId ? found.name : deleted('secret', ref.secretId),
|
|
160
|
+
...(isObject(ref.fieldMapping) ? { fieldMapping: ref.fieldMapping as Record<string, string> } : {}),
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return function exportRefs(value: unknown): unknown {
|
|
165
|
+
if (Array.isArray(value)) return value.map((item) => exportRefs(item))
|
|
166
|
+
if (!isObject(value)) return value
|
|
167
|
+
return Object.fromEntries(Object.entries(value).map(([key, inner]) =>
|
|
168
|
+
[key, key === 'vault' && isVaultRef(inner) ? exportRef(inner) : exportRefs(inner)]))
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Fields every kind of document reads the same way ─────────────────────────
|
|
173
|
+
|
|
174
|
+
/** A list of keys (`items: [a, b]`), without repeats; problems are reported at `path`. */
|
|
175
|
+
export function keyList(value: unknown, path: string, problems: Problems, isValidKey: (key: unknown) => boolean): string[] {
|
|
176
|
+
if (value === undefined) return []
|
|
177
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) {
|
|
178
|
+
problems.add(path, 'must be a list of keys')
|
|
179
|
+
return []
|
|
180
|
+
}
|
|
181
|
+
for (const key of value) if (!isValidKey(key)) problems.add(path, `"${clip(key)}" is not a valid key`)
|
|
182
|
+
return [...new Set(value as string[])]
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The key of a document, or '' (with a problem) when it has none that is usable; a repeat within `seen` is a problem too. */
|
|
186
|
+
export function documentKey(
|
|
187
|
+
entry: Json, path: string, seen: Set<string>, problems: Problems,
|
|
188
|
+
keys: { isValid: (key: unknown) => boolean; error: string },
|
|
189
|
+
): string {
|
|
190
|
+
const key = entry['key']
|
|
191
|
+
if (!keys.isValid(key)) {
|
|
192
|
+
problems.add(`${path}.key`, key === undefined ? 'is required' : keys.error)
|
|
193
|
+
return ''
|
|
194
|
+
}
|
|
195
|
+
const text = key as string
|
|
196
|
+
if (seen.has(text)) problems.add(`${path}.key`, `"${clip(text)}" is used by another document`)
|
|
197
|
+
seen.add(text)
|
|
198
|
+
return text
|
|
199
|
+
}
|