knosky 0.5.0 → 0.6.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/CHANGELOG.md +21 -0
- package/README.md +25 -1
- package/SECURITY.md +14 -0
- package/action/post-comment.mjs +89 -0
- package/action.yml +62 -0
- package/core/benchmark-results.mjs +225 -0
- package/core/bundle.mjs +7 -1
- package/core/comparison.mjs +189 -0
- package/core/constants.mjs +13 -0
- package/core/cross-repo.mjs +111 -0
- package/core/escalate.mjs +68 -0
- package/core/freshness.mjs +194 -0
- package/core/fs-indexer.mjs +6 -0
- package/core/key-store.mjs +348 -0
- package/core/ledger.mjs +141 -0
- package/core/lod.mjs +155 -0
- package/core/multi-model-benchmark.mjs +405 -0
- package/core/onboarding.mjs +223 -0
- package/core/protocol-spec.mjs +460 -0
- package/core/route.mjs +5 -0
- package/core/schema.mjs +26 -1
- package/package.json +3 -1
- package/renderer/city.template.html +130 -21
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// KnoSky signing-key rotation/revocation lifecycle (SAT-446 / Hardening Addendum 2).
|
|
2
|
+
// In-process HMAC-SHA256 key store — local-first, no external KMS, no telemetry.
|
|
3
|
+
// Callers own persistence; this module is pure in-memory state + crypto.
|
|
4
|
+
// Pure Node stdlib, ESM — no third-party dependencies.
|
|
5
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Internal helpers
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
/** Generate a random 16-byte key id as a 32-char lowercase hex string. */
|
|
12
|
+
function newKeyId() {
|
|
13
|
+
return randomBytes(16).toString('hex');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Compute HMAC-SHA256 over a canonical JSON serialisation of `payload`.
|
|
18
|
+
* "Canonical" = top-level keys sorted alphabetically before serialisation.
|
|
19
|
+
* Returns a lowercase 64-char hex string.
|
|
20
|
+
*
|
|
21
|
+
* @param {Buffer} keyBuf
|
|
22
|
+
* @param {object} payload
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
function hmacPayload(keyBuf, payload) {
|
|
26
|
+
const sorted = Object.fromEntries(
|
|
27
|
+
Object.keys(payload).sort().map(k => [k, payload[k]]),
|
|
28
|
+
);
|
|
29
|
+
return createHmac('sha256', keyBuf).update(JSON.stringify(sorted)).digest('hex');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Strip `key_id` and `sig` from a manifest object to get the signing payload.
|
|
34
|
+
* These two fields are metadata added *after* signing and must not be part
|
|
35
|
+
* of the signed content.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} manifest
|
|
38
|
+
* @returns {object}
|
|
39
|
+
*/
|
|
40
|
+
function signingPayload(manifest) {
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const [k, v] of Object.entries(manifest)) {
|
|
43
|
+
if (k === 'key_id' || k === 'sig') continue;
|
|
44
|
+
out[k] = v;
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// createKeyStore
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create a new in-memory key store with a single initial active key.
|
|
55
|
+
*
|
|
56
|
+
* The returned object has the following shape:
|
|
57
|
+
* ```
|
|
58
|
+
* {
|
|
59
|
+
* keys: Map<key_id, KeyEntry>,
|
|
60
|
+
* activeKeyId: string | null,
|
|
61
|
+
* }
|
|
62
|
+
* ```
|
|
63
|
+
*
|
|
64
|
+
* where each `KeyEntry` is:
|
|
65
|
+
* ```
|
|
66
|
+
* { key_id: string, status: 'active'|'rotated'|'revoked', created_at: string, raw: Buffer }
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* @returns {{ keys: Map<string, object>, activeKeyId: string }}
|
|
70
|
+
*/
|
|
71
|
+
export function createKeyStore() {
|
|
72
|
+
const ks = { keys: new Map(), activeKeyId: null };
|
|
73
|
+
_issueKey(ks);
|
|
74
|
+
return ks;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @internal
|
|
79
|
+
* Issue a new active key. Demotes the current active key to `'rotated'` if
|
|
80
|
+
* one exists (rotated keys remain verifiable; only revoked ones are rejected).
|
|
81
|
+
*
|
|
82
|
+
* @param {{ keys: Map<string,object>, activeKeyId: string|null }} ks
|
|
83
|
+
* @returns {string} The newly-issued key_id.
|
|
84
|
+
*/
|
|
85
|
+
function _issueKey(ks) {
|
|
86
|
+
// Demote any current active key → 'rotated' (still usable for verification).
|
|
87
|
+
if (ks.activeKeyId !== null) {
|
|
88
|
+
const prev = ks.keys.get(ks.activeKeyId);
|
|
89
|
+
if (prev && prev.status === 'active') prev.status = 'rotated';
|
|
90
|
+
}
|
|
91
|
+
const key_id = newKeyId();
|
|
92
|
+
ks.keys.set(key_id, {
|
|
93
|
+
key_id,
|
|
94
|
+
status: 'active',
|
|
95
|
+
created_at: new Date().toISOString(),
|
|
96
|
+
raw: randomBytes(32),
|
|
97
|
+
});
|
|
98
|
+
ks.activeKeyId = key_id;
|
|
99
|
+
return key_id;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// rotateKey
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Rotate the signing key: demote the current active key to `'rotated'` and
|
|
108
|
+
* issue a fresh one.
|
|
109
|
+
*
|
|
110
|
+
* Manifests already signed with the old (now `'rotated'`) key remain
|
|
111
|
+
* verifiable. Only `'revoked'` keys are rejected by {@link verifyManifest}.
|
|
112
|
+
*
|
|
113
|
+
* @param {{ keys: Map<string,object>, activeKeyId: string|null }} ks
|
|
114
|
+
* @returns {string} The new active key_id.
|
|
115
|
+
*/
|
|
116
|
+
export function rotateKey(ks) {
|
|
117
|
+
return _issueKey(ks);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// revokeKey / makeRevocationApproval
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The canonical payload that approval signers HMAC to authorise a revocation.
|
|
126
|
+
* Keys are sorted alphabetically (matching {@link hmacPayload}).
|
|
127
|
+
*
|
|
128
|
+
* @param {string} targetKeyId
|
|
129
|
+
* @returns {{ action: string, target_key_id: string }}
|
|
130
|
+
*/
|
|
131
|
+
function revocationPayload(targetKeyId) {
|
|
132
|
+
// Return in key-sorted order so hmacPayload produces a deterministic digest.
|
|
133
|
+
return { action: 'revoke_key', target_key_id: targetKeyId };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Produce a revocation-approval token signed by `signingKeyId`.
|
|
138
|
+
*
|
|
139
|
+
* The token is an `{ key_id, sig }` object that can be collected from multiple
|
|
140
|
+
* non-revoked keys and passed as the `approvals` array to {@link revokeKey}
|
|
141
|
+
* to satisfy the quorum requirement.
|
|
142
|
+
*
|
|
143
|
+
* Throws if `signingKeyId` is unknown, already revoked, or equal to
|
|
144
|
+
* `targetKeyId` (a key may not approve its own revocation).
|
|
145
|
+
*
|
|
146
|
+
* @param {{ keys: Map<string,object> }} ks
|
|
147
|
+
* @param {string} signingKeyId
|
|
148
|
+
* @param {string} targetKeyId
|
|
149
|
+
* @returns {{ key_id: string, sig: string }}
|
|
150
|
+
*/
|
|
151
|
+
export function makeRevocationApproval(ks, signingKeyId, targetKeyId) {
|
|
152
|
+
if (signingKeyId === targetKeyId) {
|
|
153
|
+
throw new Error('makeRevocationApproval: signing key and target key must differ');
|
|
154
|
+
}
|
|
155
|
+
const entry = ks.keys.get(signingKeyId);
|
|
156
|
+
if (!entry) {
|
|
157
|
+
throw new Error(`makeRevocationApproval: unknown key_id ${JSON.stringify(signingKeyId)}`);
|
|
158
|
+
}
|
|
159
|
+
if (entry.status === 'revoked') {
|
|
160
|
+
throw new Error(`makeRevocationApproval: signing key ${JSON.stringify(signingKeyId)} is revoked`);
|
|
161
|
+
}
|
|
162
|
+
const sig = hmacPayload(entry.raw, revocationPayload(targetKeyId));
|
|
163
|
+
return { key_id: signingKeyId, sig };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Revoke a key by id.
|
|
168
|
+
*
|
|
169
|
+
* After revocation {@link verifyManifest} rejects any manifest that was
|
|
170
|
+
* signed with the revoked key. Revocation is permanent within a key-store
|
|
171
|
+
* instance. The operation is idempotent — revoking an already-revoked key
|
|
172
|
+
* is a no-op.
|
|
173
|
+
*
|
|
174
|
+
* If the revoked key was the active signing key, `activeKeyId` is cleared to
|
|
175
|
+
* `null`; callers must {@link rotateKey} to obtain a new active key.
|
|
176
|
+
*
|
|
177
|
+
* **Authorization / quorum gate (SAT-472)**
|
|
178
|
+
*
|
|
179
|
+
* Let `M` be the number of non-revoked keys other than `keyId`. The
|
|
180
|
+
* required quorum is `Math.floor(M / 2) + 1` (strict majority of peers).
|
|
181
|
+
* When `M === 0` (the target is the only remaining key) no approvals are
|
|
182
|
+
* needed. Each approval in the `approvals` array must be a valid
|
|
183
|
+
* {@link makeRevocationApproval} token signed by a distinct, non-revoked key
|
|
184
|
+
* that is not `keyId` itself.
|
|
185
|
+
*
|
|
186
|
+
* This ensures a single compromised key cannot unilaterally revoke all
|
|
187
|
+
* others: it can only contribute one approval, which is always less than
|
|
188
|
+
* the strict majority needed when `M >= 2`.
|
|
189
|
+
*
|
|
190
|
+
* Throws if `keyId` is not present in the store or if the quorum is not met.
|
|
191
|
+
*
|
|
192
|
+
* @param {{ keys: Map<string,object>, activeKeyId: string|null }} ks
|
|
193
|
+
* @param {string} keyId
|
|
194
|
+
* @param {{ key_id: string, sig: string }[]} [approvals=[]]
|
|
195
|
+
*/
|
|
196
|
+
export function revokeKey(ks, keyId, approvals = []) {
|
|
197
|
+
if (!ks.keys.has(keyId)) {
|
|
198
|
+
throw new Error(`revokeKey: unknown key_id ${JSON.stringify(keyId)}`);
|
|
199
|
+
}
|
|
200
|
+
const entry = ks.keys.get(keyId);
|
|
201
|
+
if (entry.status === 'revoked') return; // idempotent
|
|
202
|
+
|
|
203
|
+
// Count non-revoked peer keys (all keys except the revocation target).
|
|
204
|
+
const peers = [...ks.keys.values()].filter(
|
|
205
|
+
e => e.key_id !== keyId && e.status !== 'revoked',
|
|
206
|
+
);
|
|
207
|
+
const required = peers.length > 0 ? Math.floor(peers.length / 2) + 1 : 0;
|
|
208
|
+
|
|
209
|
+
if (required > 0) {
|
|
210
|
+
// Validate each approval token over the canonical revocation payload.
|
|
211
|
+
const payload = revocationPayload(keyId);
|
|
212
|
+
const seen = new Set();
|
|
213
|
+
let valid = 0;
|
|
214
|
+
|
|
215
|
+
for (const approval of approvals) {
|
|
216
|
+
const { key_id: aKeyId, sig } = approval ?? {};
|
|
217
|
+
if (!aKeyId || !sig) continue;
|
|
218
|
+
if (aKeyId === keyId) continue; // target cannot self-approve
|
|
219
|
+
if (seen.has(aKeyId)) continue; // deduplicate per signer
|
|
220
|
+
|
|
221
|
+
const aEntry = ks.keys.get(aKeyId);
|
|
222
|
+
if (!aEntry || aEntry.status === 'revoked') continue;
|
|
223
|
+
|
|
224
|
+
const expected = hmacPayload(aEntry.raw, payload);
|
|
225
|
+
if (typeof sig !== 'string' || sig.length !== expected.length) continue;
|
|
226
|
+
|
|
227
|
+
let match = false;
|
|
228
|
+
try {
|
|
229
|
+
match = timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(sig, 'hex'));
|
|
230
|
+
} catch { match = false; }
|
|
231
|
+
|
|
232
|
+
if (match) {
|
|
233
|
+
seen.add(aKeyId);
|
|
234
|
+
valid++;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (valid < required) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`revokeKey: quorum not met — ${required} approval(s) required from peer keys, got ${valid}`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
entry.status = 'revoked';
|
|
246
|
+
// If the active key was just revoked, clear the active pointer.
|
|
247
|
+
if (ks.activeKeyId === keyId) ks.activeKeyId = null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// signManifest
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Sign a manifest with the store's current active key.
|
|
256
|
+
*
|
|
257
|
+
* Returns a shallow copy of `manifest` with two new fields appended:
|
|
258
|
+
* - `key_id` — identifies the signing key for later verification.
|
|
259
|
+
* - `sig` — lowercase hex HMAC-SHA256 over the canonical signing payload
|
|
260
|
+
* (all manifest fields except `key_id` and `sig` themselves,
|
|
261
|
+
* with top-level keys sorted alphabetically).
|
|
262
|
+
*
|
|
263
|
+
* Throws if there is no active key in the store (e.g. after revoking the
|
|
264
|
+
* active key without rotating first).
|
|
265
|
+
*
|
|
266
|
+
* @param {{ keys: Map<string,object>, activeKeyId: string|null }} ks
|
|
267
|
+
* @param {object} manifest Typically the result of {@link makeIntentManifest}.
|
|
268
|
+
* @returns {object} Manifest copy decorated with `key_id` and `sig`.
|
|
269
|
+
*/
|
|
270
|
+
export function signManifest(ks, manifest) {
|
|
271
|
+
const entry = ks.activeKeyId != null ? ks.keys.get(ks.activeKeyId) : null;
|
|
272
|
+
if (!entry || entry.status !== 'active') {
|
|
273
|
+
throw new Error('signManifest: no active key in store — rotate to obtain one');
|
|
274
|
+
}
|
|
275
|
+
const payload = signingPayload(manifest);
|
|
276
|
+
const sig = hmacPayload(entry.raw, payload);
|
|
277
|
+
return { ...manifest, key_id: entry.key_id, sig };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// verifyManifest
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Verify a signed manifest.
|
|
286
|
+
*
|
|
287
|
+
* Returns `{ ok: true }` when the signature is valid and the signing key is
|
|
288
|
+
* present and not revoked. Otherwise returns `{ ok: false, reason }`.
|
|
289
|
+
*
|
|
290
|
+
* Never throws on malformed input — treats it as a verification failure.
|
|
291
|
+
*
|
|
292
|
+
* | `reason` | Meaning |
|
|
293
|
+
* |-------------------|-----------------------------------------------------|
|
|
294
|
+
* | `'missing_key_id'`| `key_id` field absent from the manifest. |
|
|
295
|
+
* | `'unknown_key'` | `key_id` is not registered in this store. |
|
|
296
|
+
* | `'key_revoked'` | The key exists but has been revoked. |
|
|
297
|
+
* | `'bad_signature'` | HMAC did not match — data was tampered or wrong key.|
|
|
298
|
+
*
|
|
299
|
+
* @param {{ keys: Map<string,object>, activeKeyId: string|null }} ks
|
|
300
|
+
* @param {object} signedManifest
|
|
301
|
+
* @returns {{ ok: boolean, reason?: string }}
|
|
302
|
+
*/
|
|
303
|
+
export function verifyManifest(ks, signedManifest) {
|
|
304
|
+
const { key_id, sig } = signedManifest;
|
|
305
|
+
|
|
306
|
+
if (!key_id) return { ok: false, reason: 'missing_key_id' };
|
|
307
|
+
|
|
308
|
+
const entry = ks.keys.get(key_id);
|
|
309
|
+
if (!entry) return { ok: false, reason: 'unknown_key' };
|
|
310
|
+
if (entry.status === 'revoked') return { ok: false, reason: 'key_revoked' };
|
|
311
|
+
|
|
312
|
+
const payload = signingPayload(signedManifest);
|
|
313
|
+
const expected = hmacPayload(entry.raw, payload);
|
|
314
|
+
|
|
315
|
+
// Use timingSafeEqual to prevent timing-side-channel leaks.
|
|
316
|
+
// Both buffers must have the same length; a length mismatch is a sig failure.
|
|
317
|
+
const eBuf = Buffer.from(expected, 'hex');
|
|
318
|
+
if (typeof sig !== 'string' || sig.length !== expected.length) {
|
|
319
|
+
return { ok: false, reason: 'bad_signature' };
|
|
320
|
+
}
|
|
321
|
+
const aBuf = Buffer.from(sig, 'hex');
|
|
322
|
+
let match;
|
|
323
|
+
try {
|
|
324
|
+
match = timingSafeEqual(eBuf, aBuf);
|
|
325
|
+
} catch {
|
|
326
|
+
match = false;
|
|
327
|
+
}
|
|
328
|
+
return match ? { ok: true } : { ok: false, reason: 'bad_signature' };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// getKey
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Return the metadata for a registered key (without the raw key material).
|
|
337
|
+
* Returns `null` if `keyId` is not in the store.
|
|
338
|
+
*
|
|
339
|
+
* @param {{ keys: Map<string,object> }} ks
|
|
340
|
+
* @param {string} keyId
|
|
341
|
+
* @returns {{ key_id: string, status: string, created_at: string } | null}
|
|
342
|
+
*/
|
|
343
|
+
export function getKey(ks, keyId) {
|
|
344
|
+
const entry = ks.keys.get(keyId);
|
|
345
|
+
if (!entry) return null;
|
|
346
|
+
// Never expose the raw key material to callers.
|
|
347
|
+
return { key_id: entry.key_id, status: entry.status, created_at: entry.created_at };
|
|
348
|
+
}
|
package/core/ledger.mjs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// KnoSky ledger high-water-mark guard (SAT-443 / V13).
|
|
2
|
+
//
|
|
3
|
+
// Independently persists the highest-seen ledger sequence number in a
|
|
4
|
+
// dedicated file (separate from the ledger itself) so that a truncation
|
|
5
|
+
// attack that replaces the whole ledger cannot roll back the sequence.
|
|
6
|
+
//
|
|
7
|
+
// Anti-truncation rule (D-164 TUF-evolution): a ledger state whose sequence
|
|
8
|
+
// number is STRICTLY LOWER than the persisted high-water mark is refused.
|
|
9
|
+
//
|
|
10
|
+
// The HWM file is a minimal JSON document: { "ledger_hwm": <integer> }
|
|
11
|
+
// It is intentionally kept narrow — no ledger content lives here.
|
|
12
|
+
//
|
|
13
|
+
// Pure Node stdlib, ESM — no third-party dependencies.
|
|
14
|
+
|
|
15
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, openSync, fsyncSync, closeSync } from 'node:fs';
|
|
16
|
+
import { dirname } from 'node:path';
|
|
17
|
+
import { MAX_PLAUSIBLE_LEDGER_SEQ } from './constants.mjs';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// HWM file I/O
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read the persisted high-water mark from `hwmPath`.
|
|
25
|
+
* Returns 0 when the file does not exist (first run — any sequence is valid).
|
|
26
|
+
* Throws on I/O errors other than ENOENT, and on malformed content (fail-closed).
|
|
27
|
+
*
|
|
28
|
+
* @param {string} hwmPath Path to the independently-persisted HWM file.
|
|
29
|
+
* @returns {number} Stored sequence number (non-negative integer).
|
|
30
|
+
*/
|
|
31
|
+
export function readHwm(hwmPath) {
|
|
32
|
+
let text;
|
|
33
|
+
try {
|
|
34
|
+
text = readFileSync(hwmPath, 'utf8');
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (err.code === 'ENOENT') return 0;
|
|
37
|
+
throw err; // permission error — fail-closed
|
|
38
|
+
}
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(text);
|
|
42
|
+
} catch {
|
|
43
|
+
throw new Error(`ledger HWM file is not valid JSON: ${hwmPath}`);
|
|
44
|
+
}
|
|
45
|
+
if (!Number.isInteger(parsed.ledger_hwm) || parsed.ledger_hwm < 0) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`ledger HWM file has invalid ledger_hwm: ${JSON.stringify(parsed.ledger_hwm)} in ${hwmPath}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return parsed.ledger_hwm;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Persist `seq` as the current high-water mark.
|
|
55
|
+
* Creates parent directories if they do not already exist.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} hwmPath Path to write.
|
|
58
|
+
* @param {number} seq New HWM value (non-negative integer).
|
|
59
|
+
*/
|
|
60
|
+
export function writeHwm(hwmPath, seq) {
|
|
61
|
+
if (!Number.isInteger(seq) || seq < 0) {
|
|
62
|
+
throw new TypeError(
|
|
63
|
+
`ledger_hwm must be a non-negative integer, got: ${JSON.stringify(seq)}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
const dir = dirname(hwmPath);
|
|
67
|
+
mkdirSync(dir, { recursive: true });
|
|
68
|
+
// Atomic write: write to a sibling temp file, then rename into place.
|
|
69
|
+
// This prevents a partial/corrupt HWM file on crash or power loss —
|
|
70
|
+
// critical because a corrupt HWM would either silently reset the guard
|
|
71
|
+
// (ENOENT path) or throw (parse error), both of which weaken security.
|
|
72
|
+
const tmp = hwmPath + '.tmp';
|
|
73
|
+
writeFileSync(tmp, JSON.stringify({ ledger_hwm: seq }) + '\n', 'utf8');
|
|
74
|
+
// fsync the temp file's contents before rename — otherwise the rename can
|
|
75
|
+
// land on disk before the data it points to does (SAT-474 hardening review).
|
|
76
|
+
const tmpFd = openSync(tmp, 'r');
|
|
77
|
+
try { fsyncSync(tmpFd); } finally { closeSync(tmpFd); }
|
|
78
|
+
renameSync(tmp, hwmPath);
|
|
79
|
+
// fsync the containing directory so the rename itself (the directory-entry
|
|
80
|
+
// update) is durable — without this, a crash immediately after renameSync
|
|
81
|
+
// can leave the old HWM file name visible on some filesystems/mount options
|
|
82
|
+
// (e.g. ext4 without data=ordered). POSIX-specific guarantee; best-effort
|
|
83
|
+
// on platforms where directory fsync isn't supported.
|
|
84
|
+
try {
|
|
85
|
+
const dirFd = openSync(dir, 'r');
|
|
86
|
+
try { fsyncSync(dirFd); } finally { closeSync(dirFd); }
|
|
87
|
+
} catch { /* best-effort; not all platforms support directory fsync */ }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// checkAndAdvance — the core anti-truncation guard
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Enforce the high-water-mark invariant for incoming ledger state.
|
|
96
|
+
*
|
|
97
|
+
* Reads the persisted HWM from `hwmPath`, then applies the anti-truncation
|
|
98
|
+
* rule:
|
|
99
|
+
* - `seq` STRICTLY LESS than HWM → refuse. The HWM file is NOT updated.
|
|
100
|
+
* - `seq` EQUAL to HWM → accept (idempotent replay). HWM written.
|
|
101
|
+
* - `seq` GREATER than HWM → accept and advance. HWM written.
|
|
102
|
+
*
|
|
103
|
+
* Returning `{ ok: false }` means the caller must treat the incoming ledger
|
|
104
|
+
* state as invalid and stop processing — it must not be applied or surfaced
|
|
105
|
+
* as authoritative.
|
|
106
|
+
*
|
|
107
|
+
* @param {number} seq Claimed sequence number from the incoming ledger state.
|
|
108
|
+
* @param {string} hwmPath Path to the independently-persisted HWM file.
|
|
109
|
+
* @returns {{ ok: boolean, seq: number, hwm: number, error?: string }}
|
|
110
|
+
*/
|
|
111
|
+
// Second layer of defense: enforced here too (not only in
|
|
112
|
+
// core/freshness.mjs's extractLedgerSeq) so checkAndAdvance is safe even if
|
|
113
|
+
// called directly. Imports the single source of truth from constants.mjs
|
|
114
|
+
// (a dependency-free module) so the two layers cannot silently diverge.
|
|
115
|
+
export function checkAndAdvance(seq, hwmPath) {
|
|
116
|
+
if (!Number.isInteger(seq) || seq < 0 || seq > MAX_PLAUSIBLE_LEDGER_SEQ) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
seq,
|
|
120
|
+
hwm: -1,
|
|
121
|
+
error: `seq must be a non-negative integer no greater than ${MAX_PLAUSIBLE_LEDGER_SEQ}, got: ${JSON.stringify(seq)}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const hwm = readHwm(hwmPath);
|
|
126
|
+
|
|
127
|
+
if (seq < hwm) {
|
|
128
|
+
// Anti-truncation guard: incoming sequence is below the highest-seen mark.
|
|
129
|
+
// Do NOT update the file — the old (higher) mark must remain authoritative.
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
seq,
|
|
133
|
+
hwm,
|
|
134
|
+
error: `ledger sequence ${seq} is below the high-water mark ${hwm} — refusing (anti-truncation guard)`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// seq >= hwm: advance (or re-confirm) the mark.
|
|
139
|
+
writeHwm(hwmPath, seq);
|
|
140
|
+
return { ok: true, seq, hwm };
|
|
141
|
+
}
|
package/core/lod.mjs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// core/lod.mjs — Level-of-detail, viewport culling, clustering, and streaming
|
|
2
|
+
// utilities for large single-repo city graphs (SAT-447).
|
|
3
|
+
//
|
|
4
|
+
// All functions are pure (no side-effects, no DOM/canvas imports) so they can
|
|
5
|
+
// be used both in the Node.js test harness and inlined into the browser renderer.
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// LOD tier constants
|
|
9
|
+
// Each tier drives rendering fidelity in city.template.html.
|
|
10
|
+
// FULL — full-detail buildings, cars, trees, props, shadows, connections
|
|
11
|
+
// SIMPLE — buildings only (no cars / trees / props / shadows)
|
|
12
|
+
// DOT — each building renders as a coloured dot (very-far-out zoom)
|
|
13
|
+
// CLUSTER— entire district collapses to a single cluster badge
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
export const LOD_FULL = 0; // zoom >= LOD_FULL_THRESHOLD
|
|
16
|
+
export const LOD_SIMPLE = 1; // zoom >= LOD_SIMPLE_THRESHOLD
|
|
17
|
+
export const LOD_DOT = 2; // zoom >= LOD_DOT_THRESHOLD
|
|
18
|
+
export const LOD_CLUSTER = 3; // zoom < LOD_DOT_THRESHOLD
|
|
19
|
+
|
|
20
|
+
// Zoom boundary constants (cam.zoom scale, matching renderer defaults)
|
|
21
|
+
export const LOD_FULL_THRESHOLD = 0.35; // below → drop cars/trees/props/shadows
|
|
22
|
+
export const LOD_SIMPLE_THRESHOLD = 0.12; // below → dots only
|
|
23
|
+
export const LOD_DOT_THRESHOLD = 0.06; // below → cluster badges
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Map a camera zoom value to an LOD tier.
|
|
27
|
+
* @param {number} zoom
|
|
28
|
+
* @returns {0|1|2|3}
|
|
29
|
+
*/
|
|
30
|
+
export function lodForZoom(zoom) {
|
|
31
|
+
if (zoom >= LOD_FULL_THRESHOLD) return LOD_FULL;
|
|
32
|
+
if (zoom >= LOD_SIMPLE_THRESHOLD) return LOD_SIMPLE;
|
|
33
|
+
if (zoom >= LOD_DOT_THRESHOLD) return LOD_DOT;
|
|
34
|
+
return LOD_CLUSTER;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Viewport culling
|
|
39
|
+
// Works in canvas/screen-space: test an iso-world point against a padded
|
|
40
|
+
// screen rectangle. Call once per draw to compute the clip region, then
|
|
41
|
+
// call isVisible() per object.
|
|
42
|
+
//
|
|
43
|
+
// cam = { x, y, zoom } (renderer camera object)
|
|
44
|
+
// cw = canvas CSS width
|
|
45
|
+
// ch = canvas CSS height
|
|
46
|
+
// pad = extra margin in world-units so objects at the edge aren't clipped
|
|
47
|
+
// (default: 2 tile-widths, 64px each → 128)
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compute an axis-aligned world-space clip rect for the current camera view.
|
|
52
|
+
* Returns { wx0, wy0, wx1, wy1 } in *world* coordinates (pre-camera transform).
|
|
53
|
+
* @param {{ x: number, y: number, zoom: number }} cam
|
|
54
|
+
* @param {number} cw canvas CSS width
|
|
55
|
+
* @param {number} ch canvas CSS height
|
|
56
|
+
* @param {number} [pad=128] extra padding around the edges in world units
|
|
57
|
+
* @returns {{ wx0: number, wy0: number, wx1: number, wy1: number }}
|
|
58
|
+
*/
|
|
59
|
+
export function viewportClip(cam, cw, ch, pad = 128) {
|
|
60
|
+
// Screen corners → world coords: worldX = (screenX - cam.x) / cam.zoom
|
|
61
|
+
const wx0 = (0 - cam.x) / cam.zoom - pad;
|
|
62
|
+
const wy0 = (0 - cam.y) / cam.zoom - pad;
|
|
63
|
+
const wx1 = (cw - cam.x) / cam.zoom + pad;
|
|
64
|
+
const wy1 = (ch - cam.y) / cam.zoom + pad;
|
|
65
|
+
return { wx0, wy0, wx1, wy1 };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Return true if the iso-world point (wx, wy) is inside the clip rect.
|
|
70
|
+
* @param {number} wx
|
|
71
|
+
* @param {number} wy
|
|
72
|
+
* @param {{ wx0: number, wy0: number, wx1: number, wy1: number }} clip
|
|
73
|
+
* @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
export function isVisible(wx, wy, clip) {
|
|
76
|
+
return wx >= clip.wx0 && wx <= clip.wx1 && wy >= clip.wy0 && wy <= clip.wy1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Clustering
|
|
81
|
+
// Group city nodes by their district and compute a single representative
|
|
82
|
+
// "cluster badge" position + count. The badge sits at the
|
|
83
|
+
// district centroid in world-space.
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build an array of cluster descriptors from the current NODES array.
|
|
88
|
+
* Each cluster has: { districtId, label, color, count, wx, wy }
|
|
89
|
+
*
|
|
90
|
+
* @param {Array<{ d: string, bx: number, by: number }>} nodes renderer-adapted nodes (with bx/by world pos)
|
|
91
|
+
* @param {{ [id: string]: { name: string, color: string } }} dcfg DCFG district config
|
|
92
|
+
* @returns {Array<{ districtId: string, label: string, color: string, count: number, wx: number, wy: number }>}
|
|
93
|
+
*/
|
|
94
|
+
export function buildClusters(nodes, dcfg) {
|
|
95
|
+
const acc = {};
|
|
96
|
+
for (const n of nodes) {
|
|
97
|
+
const k = n.d;
|
|
98
|
+
if (!acc[k]) acc[k] = { sumX: 0, sumY: 0, count: 0 };
|
|
99
|
+
acc[k].sumX += n.bx;
|
|
100
|
+
acc[k].sumY += n.by;
|
|
101
|
+
acc[k].count++;
|
|
102
|
+
}
|
|
103
|
+
return Object.keys(acc).map(k => {
|
|
104
|
+
const a = acc[k];
|
|
105
|
+
const cfg = dcfg[k] || { name: k, color: '#7c83a3' };
|
|
106
|
+
return {
|
|
107
|
+
districtId: k,
|
|
108
|
+
label: cfg.name,
|
|
109
|
+
color: cfg.color,
|
|
110
|
+
count: a.count,
|
|
111
|
+
wx: a.sumX / a.count,
|
|
112
|
+
wy: a.sumY / a.count,
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Streaming geometry build
|
|
119
|
+
// For large repos (node count >= STREAM_THRESHOLD) instead of building the
|
|
120
|
+
// entire OBJ array synchronously during ingest, the renderer schedules small
|
|
121
|
+
// batches of geometry across animation frames. This function splits a nodes
|
|
122
|
+
// array into fixed-size batches.
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/** Minimum node count before streaming geometry build is preferred. */
|
|
126
|
+
export const STREAM_THRESHOLD = 400;
|
|
127
|
+
|
|
128
|
+
/** Nodes processed per animation-frame batch. */
|
|
129
|
+
export const STREAM_BATCH_SIZE = 80;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Split an array into batches of `size`.
|
|
133
|
+
* @template T
|
|
134
|
+
* @param {T[]} items
|
|
135
|
+
* @param {number} [size=STREAM_BATCH_SIZE]
|
|
136
|
+
* @returns {T[][]}
|
|
137
|
+
*/
|
|
138
|
+
export function makeBatches(items, size = STREAM_BATCH_SIZE) {
|
|
139
|
+
if (!Array.isArray(items)) throw new TypeError('items must be an array');
|
|
140
|
+
if (!Number.isInteger(size) || size < 1) throw new RangeError('size must be a positive integer');
|
|
141
|
+
const batches = [];
|
|
142
|
+
for (let i = 0; i < items.length; i += size) {
|
|
143
|
+
batches.push(items.slice(i, i + size));
|
|
144
|
+
}
|
|
145
|
+
return batches;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Return true when the node count warrants streaming geometry build.
|
|
150
|
+
* @param {number} nodeCount
|
|
151
|
+
* @returns {boolean}
|
|
152
|
+
*/
|
|
153
|
+
export function shouldStream(nodeCount) {
|
|
154
|
+
return nodeCount >= STREAM_THRESHOLD;
|
|
155
|
+
}
|