knosky 0.6.3 → 0.7.0

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +149 -93
  2. package/CREDITS.md +14 -14
  3. package/LICENSE.md +76 -76
  4. package/LIMITATIONS.md +33 -23
  5. package/PRIVACY.md +30 -30
  6. package/README.md +170 -117
  7. package/SECURITY.md +78 -46
  8. package/action/post-comment.mjs +94 -89
  9. package/action.yml +62 -62
  10. package/bin/knosky.mjs +279 -105
  11. package/core/CONTRACT.md +70 -70
  12. package/core/append-only-checkpoint.mjs +215 -0
  13. package/core/audit-writer.mjs +317 -0
  14. package/core/benchmark-results.mjs +225 -225
  15. package/core/bundle.mjs +178 -178
  16. package/core/churn.mjs +23 -23
  17. package/core/ci.mjs +268 -268
  18. package/core/comparison.mjs +189 -189
  19. package/core/config.mjs +189 -189
  20. package/core/constants.mjs +13 -13
  21. package/core/contract.mjs +123 -123
  22. package/core/cross-repo.mjs +111 -111
  23. package/core/decision-codes.mjs +92 -0
  24. package/core/destination.mjs +161 -161
  25. package/core/district-classification.mjs +111 -0
  26. package/core/doctor-scorecard.mjs +369 -0
  27. package/core/domain-store.mjs +347 -0
  28. package/core/edges.mjs +43 -43
  29. package/core/escalate.mjs +68 -68
  30. package/core/freshness.mjs +198 -194
  31. package/core/fs-indexer.mjs +218 -218
  32. package/core/key-store.mjs +348 -348
  33. package/core/layout.mjs +46 -46
  34. package/core/ledger.mjs +176 -141
  35. package/core/local-ipc-identity.mjs +500 -0
  36. package/core/lod.mjs +155 -155
  37. package/core/mode-b.mjs +410 -0
  38. package/core/multi-model-benchmark.mjs +405 -405
  39. package/core/net-lockdown.mjs +421 -0
  40. package/core/onboarding.mjs +223 -223
  41. package/core/operator-auth.mjs +317 -0
  42. package/core/overlays.mjs +45 -45
  43. package/core/policy-lattice.mjs +142 -0
  44. package/core/pr-comment.mjs +198 -198
  45. package/core/protocol-spec.mjs +460 -460
  46. package/core/provenance.mjs +320 -0
  47. package/core/retrieve.mjs +63 -63
  48. package/core/route.mjs +304 -304
  49. package/core/schema.mjs +275 -275
  50. package/core/signing-tiers.mjs +1265 -0
  51. package/core/swarm-bench.mjs +106 -0
  52. package/core/swarm-coordinator.mjs +867 -0
  53. package/core/trust-root-rekey.mjs +410 -0
  54. package/mcp/server.mjs +264 -108
  55. package/package.json +56 -46
  56. package/renderer/art/kenney/buildingTiles_sheet.xml +130 -130
  57. package/renderer/art/kenney/cityDetails_sheet.xml +12 -12
  58. package/renderer/art/kenney/landscapeTiles_sheet.xml +129 -129
  59. package/renderer/art/kenney/sheet_allCars.xml +545 -545
  60. package/renderer/build-rich.mjs +43 -43
  61. package/renderer/city.template.html +808 -808
  62. package/ssot/decision-codes.json +133 -0
  63. package/ssot/ladder-l0-l3.md +232 -0
  64. package/ssot/tool-menu.json +130 -0
package/core/config.mjs CHANGED
@@ -1,189 +1,189 @@
1
- // KnoSky protocol config schema, loader, and validator (SAT-426 / KSV2-P1).
2
- // Pure Node stdlib, ESM — no third-party dependencies.
3
- import fs from 'node:fs';
4
- import path from 'node:path';
5
-
6
- // ---------------------------------------------------------------------------
7
- // DEFAULTS — the canonical safe config for every knosky project.
8
- // ---------------------------------------------------------------------------
9
-
10
- /** @type {Readonly<KnoskyConfig>} */
11
- export const DEFAULTS = Object.freeze({
12
- knosky_protocol: '1.0',
13
- telemetry: false,
14
- absolute_paths: false,
15
- fail_on_secret: true,
16
- allow_excerpts: false,
17
- max_excerpt_chars: 0,
18
- categories: [],
19
- ignore: [],
20
- });
21
-
22
- // ---------------------------------------------------------------------------
23
- // parseConfigYaml — minimal YAML-subset parser, no deps.
24
- // Supports: `key: value` lines, booleans, integers, bare/quoted strings,
25
- // block lists (`- item` lines following a `key:` line with no inline value).
26
- // Ignores blank lines and # comments.
27
- // Throws on any line it cannot interpret for a known key.
28
- // ---------------------------------------------------------------------------
29
-
30
- /**
31
- * Parse a minimal YAML subset used by `.knosky/config.yml`.
32
- *
33
- * @param {string} text Raw file contents.
34
- * @returns {Record<string, unknown>} Parsed key/value map.
35
- */
36
- export function parseConfigYaml(text) {
37
- const result = {};
38
- const lines = text.split(/\r?\n/);
39
- let i = 0;
40
-
41
- while (i < lines.length) {
42
- const raw = lines[i];
43
- // Strip inline comment and trim
44
- const line = raw.replace(/#.*$/, '').trimEnd();
45
- i++;
46
-
47
- if (!line.trim()) continue; // blank / comment-only
48
-
49
- // Must be a `key: [value]` line at the top level (no leading spaces)
50
- const kvMatch = line.match(/^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)/);
51
- if (!kvMatch) {
52
- throw new Error(`parseConfigYaml: unexpected line: ${JSON.stringify(raw)}`);
53
- }
54
- const key = kvMatch[1];
55
- const rest = kvMatch[2].trim();
56
-
57
- if (rest === '') {
58
- // Possibly a block list — collect ` - item` lines that follow
59
- const items = [];
60
- while (i < lines.length) {
61
- const next = lines[i].replace(/#.*$/, '');
62
- if (/^\s*-\s/.test(next)) {
63
- items.push(next.replace(/^\s*-\s*/, '').trim());
64
- i++;
65
- } else {
66
- break;
67
- }
68
- }
69
- result[key] = items;
70
- } else {
71
- result[key] = parseScalar(rest, key);
72
- }
73
- }
74
-
75
- return result;
76
- }
77
-
78
- /**
79
- * Parse a scalar YAML value: boolean, integer, or string (bare or quoted).
80
- *
81
- * @param {string} raw The raw value string (already trimmed).
82
- * @param {string} key The key name, used for error messages.
83
- * @returns {boolean|number|string}
84
- */
85
- function parseScalar(raw, key) {
86
- if (raw === 'true') return true;
87
- if (raw === 'false') return false;
88
-
89
- // Integer
90
- if (/^-?\d+$/.test(raw)) return parseInt(raw, 10);
91
-
92
- // Double-quoted string
93
- if (raw.startsWith('"') && raw.endsWith('"')) return raw.slice(1, -1);
94
-
95
- // Single-quoted string
96
- if (raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1);
97
-
98
- // Bare string — must not contain characters that signal unhandled YAML
99
- if (/^[A-Za-z0-9._\-/ ]+$/.test(raw)) return raw;
100
-
101
- throw new Error(`parseConfigYaml: cannot parse value for key "${key}": ${JSON.stringify(raw)}`);
102
- }
103
-
104
- // ---------------------------------------------------------------------------
105
- // loadConfig — read + parse `.knosky/config.yml`, merge over DEFAULTS.
106
- // Fail-closed: throws on parse error; returns DEFAULTS copy if file absent.
107
- // ---------------------------------------------------------------------------
108
-
109
- /**
110
- * Load `.knosky/config.yml` from `root` and merge it over {@link DEFAULTS}.
111
- * Returns a plain object (not frozen). Throws if the file exists but is
112
- * malformed.
113
- *
114
- * @param {string} root Absolute (or relative) project root.
115
- * @returns {Record<string, unknown>} Merged config object.
116
- */
117
- export function loadConfig(root) {
118
- const cfgPath = path.join(root, '.knosky', 'config.yml');
119
-
120
- let text;
121
- try {
122
- text = fs.readFileSync(cfgPath, 'utf8');
123
- } catch (err) {
124
- if (err.code === 'ENOENT') {
125
- // File absent — return a safe copy of defaults
126
- return { ...DEFAULTS, categories: [], ignore: [] };
127
- }
128
- throw err; // permission error, etc — still fail-closed
129
- }
130
-
131
- // File exists: parse (throws on malformed — never silently fall back)
132
- const parsed = parseConfigYaml(text);
133
-
134
- // Deep-merge: parsed wins per-key; arrays are replaced, not concatenated.
135
- return { ...DEFAULTS, categories: [], ignore: [], ...parsed };
136
- }
137
-
138
- // ---------------------------------------------------------------------------
139
- // validateConfig — collect all constraint violations, return { ok, errors }.
140
- // ---------------------------------------------------------------------------
141
-
142
- const BOOLEAN_KEYS = ['telemetry', 'absolute_paths', 'fail_on_secret', 'allow_excerpts'];
143
-
144
- /**
145
- * Validate a config object against the KnoSky protocol schema.
146
- * Collects every violation; `ok` is true only when `errors` is empty.
147
- *
148
- * @param {Record<string, unknown>} cfg Config object (e.g. from {@link loadConfig}).
149
- * @returns {{ ok: boolean, errors: string[] }}
150
- */
151
- export function validateConfig(cfg) {
152
- const errors = [];
153
- const knownKeys = new Set(Object.keys(DEFAULTS));
154
-
155
- // knosky_protocol must be the string "1.0"
156
- if (cfg.knosky_protocol !== '1.0') {
157
- errors.push(`knosky_protocol must be "1.0", got: ${JSON.stringify(cfg.knosky_protocol)}`);
158
- }
159
-
160
- // Boolean fields
161
- for (const k of BOOLEAN_KEYS) {
162
- if (typeof cfg[k] !== 'boolean') {
163
- errors.push(`${k} must be a boolean, got: ${JSON.stringify(cfg[k])}`);
164
- }
165
- }
166
-
167
- // max_excerpt_chars must be integer >= 0
168
- const mec = cfg.max_excerpt_chars;
169
- if (!Number.isInteger(mec) || mec < 0) {
170
- errors.push(`max_excerpt_chars must be an integer >= 0, got: ${JSON.stringify(mec)}`);
171
- }
172
-
173
- // categories and ignore must be arrays of strings
174
- for (const k of ['categories', 'ignore']) {
175
- const val = cfg[k];
176
- if (!Array.isArray(val) || val.some(v => typeof v !== 'string')) {
177
- errors.push(`${k} must be an array of strings, got: ${JSON.stringify(val)}`);
178
- }
179
- }
180
-
181
- // Unknown keys
182
- for (const k of Object.keys(cfg)) {
183
- if (!knownKeys.has(k)) {
184
- errors.push(`unknown config key: ${k}`);
185
- }
186
- }
187
-
188
- return { ok: errors.length === 0, errors };
189
- }
1
+ // KnoSky protocol config schema, loader, and validator (SAT-426 / KSV2-P1).
2
+ // Pure Node stdlib, ESM — no third-party dependencies.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // DEFAULTS — the canonical safe config for every knosky project.
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /** @type {Readonly<KnoskyConfig>} */
11
+ export const DEFAULTS = Object.freeze({
12
+ knosky_protocol: '1.0',
13
+ telemetry: false,
14
+ absolute_paths: false,
15
+ fail_on_secret: true,
16
+ allow_excerpts: false,
17
+ max_excerpt_chars: 0,
18
+ categories: [],
19
+ ignore: [],
20
+ });
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // parseConfigYaml — minimal YAML-subset parser, no deps.
24
+ // Supports: `key: value` lines, booleans, integers, bare/quoted strings,
25
+ // block lists (`- item` lines following a `key:` line with no inline value).
26
+ // Ignores blank lines and # comments.
27
+ // Throws on any line it cannot interpret for a known key.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Parse a minimal YAML subset used by `.knosky/config.yml`.
32
+ *
33
+ * @param {string} text Raw file contents.
34
+ * @returns {Record<string, unknown>} Parsed key/value map.
35
+ */
36
+ export function parseConfigYaml(text) {
37
+ const result = {};
38
+ const lines = text.split(/\r?\n/);
39
+ let i = 0;
40
+
41
+ while (i < lines.length) {
42
+ const raw = lines[i];
43
+ // Strip inline comment and trim
44
+ const line = raw.replace(/#.*$/, '').trimEnd();
45
+ i++;
46
+
47
+ if (!line.trim()) continue; // blank / comment-only
48
+
49
+ // Must be a `key: [value]` line at the top level (no leading spaces)
50
+ const kvMatch = line.match(/^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)/);
51
+ if (!kvMatch) {
52
+ throw new Error(`parseConfigYaml: unexpected line: ${JSON.stringify(raw)}`);
53
+ }
54
+ const key = kvMatch[1];
55
+ const rest = kvMatch[2].trim();
56
+
57
+ if (rest === '') {
58
+ // Possibly a block list — collect ` - item` lines that follow
59
+ const items = [];
60
+ while (i < lines.length) {
61
+ const next = lines[i].replace(/#.*$/, '');
62
+ if (/^\s*-\s/.test(next)) {
63
+ items.push(next.replace(/^\s*-\s*/, '').trim());
64
+ i++;
65
+ } else {
66
+ break;
67
+ }
68
+ }
69
+ result[key] = items;
70
+ } else {
71
+ result[key] = parseScalar(rest, key);
72
+ }
73
+ }
74
+
75
+ return result;
76
+ }
77
+
78
+ /**
79
+ * Parse a scalar YAML value: boolean, integer, or string (bare or quoted).
80
+ *
81
+ * @param {string} raw The raw value string (already trimmed).
82
+ * @param {string} key The key name, used for error messages.
83
+ * @returns {boolean|number|string}
84
+ */
85
+ function parseScalar(raw, key) {
86
+ if (raw === 'true') return true;
87
+ if (raw === 'false') return false;
88
+
89
+ // Integer
90
+ if (/^-?\d+$/.test(raw)) return parseInt(raw, 10);
91
+
92
+ // Double-quoted string
93
+ if (raw.startsWith('"') && raw.endsWith('"')) return raw.slice(1, -1);
94
+
95
+ // Single-quoted string
96
+ if (raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1);
97
+
98
+ // Bare string — must not contain characters that signal unhandled YAML
99
+ if (/^[A-Za-z0-9._\-/ ]+$/.test(raw)) return raw;
100
+
101
+ throw new Error(`parseConfigYaml: cannot parse value for key "${key}": ${JSON.stringify(raw)}`);
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // loadConfig — read + parse `.knosky/config.yml`, merge over DEFAULTS.
106
+ // Fail-closed: throws on parse error; returns DEFAULTS copy if file absent.
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Load `.knosky/config.yml` from `root` and merge it over {@link DEFAULTS}.
111
+ * Returns a plain object (not frozen). Throws if the file exists but is
112
+ * malformed.
113
+ *
114
+ * @param {string} root Absolute (or relative) project root.
115
+ * @returns {Record<string, unknown>} Merged config object.
116
+ */
117
+ export function loadConfig(root) {
118
+ const cfgPath = path.join(root, '.knosky', 'config.yml');
119
+
120
+ let text;
121
+ try {
122
+ text = fs.readFileSync(cfgPath, 'utf8');
123
+ } catch (err) {
124
+ if (err.code === 'ENOENT') {
125
+ // File absent — return a safe copy of defaults
126
+ return { ...DEFAULTS, categories: [], ignore: [] };
127
+ }
128
+ throw err; // permission error, etc — still fail-closed
129
+ }
130
+
131
+ // File exists: parse (throws on malformed — never silently fall back)
132
+ const parsed = parseConfigYaml(text);
133
+
134
+ // Deep-merge: parsed wins per-key; arrays are replaced, not concatenated.
135
+ return { ...DEFAULTS, categories: [], ignore: [], ...parsed };
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // validateConfig — collect all constraint violations, return { ok, errors }.
140
+ // ---------------------------------------------------------------------------
141
+
142
+ const BOOLEAN_KEYS = ['telemetry', 'absolute_paths', 'fail_on_secret', 'allow_excerpts'];
143
+
144
+ /**
145
+ * Validate a config object against the KnoSky protocol schema.
146
+ * Collects every violation; `ok` is true only when `errors` is empty.
147
+ *
148
+ * @param {Record<string, unknown>} cfg Config object (e.g. from {@link loadConfig}).
149
+ * @returns {{ ok: boolean, errors: string[] }}
150
+ */
151
+ export function validateConfig(cfg) {
152
+ const errors = [];
153
+ const knownKeys = new Set(Object.keys(DEFAULTS));
154
+
155
+ // knosky_protocol must be the string "1.0"
156
+ if (cfg.knosky_protocol !== '1.0') {
157
+ errors.push(`knosky_protocol must be "1.0", got: ${JSON.stringify(cfg.knosky_protocol)}`);
158
+ }
159
+
160
+ // Boolean fields
161
+ for (const k of BOOLEAN_KEYS) {
162
+ if (typeof cfg[k] !== 'boolean') {
163
+ errors.push(`${k} must be a boolean, got: ${JSON.stringify(cfg[k])}`);
164
+ }
165
+ }
166
+
167
+ // max_excerpt_chars must be integer >= 0
168
+ const mec = cfg.max_excerpt_chars;
169
+ if (!Number.isInteger(mec) || mec < 0) {
170
+ errors.push(`max_excerpt_chars must be an integer >= 0, got: ${JSON.stringify(mec)}`);
171
+ }
172
+
173
+ // categories and ignore must be arrays of strings
174
+ for (const k of ['categories', 'ignore']) {
175
+ const val = cfg[k];
176
+ if (!Array.isArray(val) || val.some(v => typeof v !== 'string')) {
177
+ errors.push(`${k} must be an array of strings, got: ${JSON.stringify(val)}`);
178
+ }
179
+ }
180
+
181
+ // Unknown keys
182
+ for (const k of Object.keys(cfg)) {
183
+ if (!knownKeys.has(k)) {
184
+ errors.push(`unknown config key: ${k}`);
185
+ }
186
+ }
187
+
188
+ return { ok: errors.length === 0, errors };
189
+ }
@@ -1,13 +1,13 @@
1
- // Shared, dependency-free constants used by more than one core module.
2
- // Kept in its own file (no imports, no side effects) so any core/*.mjs can
3
- // import from it without creating a circular dependency between modules.
4
-
5
- // Upper bound for a plausible ledger sequence (git commit count). 1 billion
6
- // is far beyond any real repo's commit count and far below
7
- // Number.MAX_SAFE_INTEGER (~9e15), so it rejects implausible/attacker-
8
- // supplied values (e.g. 1e100) with no precision-loss risk of its own.
9
- // Enforced independently at two layers — core/freshness.mjs's
10
- // extractLedgerSeq (structural validation) and core/ledger.mjs's
11
- // checkAndAdvance (the persisted guard itself) — both import this single
12
- // source of truth so the two layers cannot silently diverge.
13
- export const MAX_PLAUSIBLE_LEDGER_SEQ = 1_000_000_000;
1
+ // Shared, dependency-free constants used by more than one core module.
2
+ // Kept in its own file (no imports, no side effects) so any core/*.mjs can
3
+ // import from it without creating a circular dependency between modules.
4
+
5
+ // Upper bound for a plausible ledger sequence (git commit count). 1 billion
6
+ // is far beyond any real repo's commit count and far below
7
+ // Number.MAX_SAFE_INTEGER (~9e15), so it rejects implausible/attacker-
8
+ // supplied values (e.g. 1e100) with no precision-loss risk of its own.
9
+ // Enforced independently at two layers — core/freshness.mjs's
10
+ // extractLedgerSeq (structural validation) and core/ledger.mjs's
11
+ // checkAndAdvance (the persisted guard itself) — both import this single
12
+ // source of truth so the two layers cannot silently diverge.
13
+ export const MAX_PLAUSIBLE_LEDGER_SEQ = 1_000_000_000;
package/core/contract.mjs CHANGED
@@ -1,123 +1,123 @@
1
- // KC city-data CONTRACT v2 — the shared spine.
2
- // Generalizes the fixed-4-district v1 → N categories. Pointers + projections ONLY (D-146); never bodies.
3
- // Council fixes baked in: serialization ALLOWLIST at the edge + best-effort DENYLIST scrub.
4
-
5
- export const SCHEMA_VERSION = '2.0';
6
-
7
- // The ONLY node fields that may ever be serialized into the index. Anything else is dropped/flagged.
8
- export const NODE_FIELD_ALLOWLIST = [
9
- 'id', 'kind', 'title', 'summary', 'category', 'status',
10
- 'fact_date', 'tags', 'headings', 'links', 'churn', 'provenance', 'visibility', 'sensitive',
11
- ];
12
- export const NODE_REQUIRED = ['id', 'kind', 'title', 'category', 'links', 'provenance'];
13
- export const PROVENANCE_REQUIRED = ['store', 'ref'];
14
- export const CATEGORY_REQUIRED = ['id', 'label', 'order'];
15
- export const SUMMARY_MAX = 200;
16
-
17
- // Detection patterns for the fail-closed safe-share audit (broader than the scrub list below).
18
- export const SECRET_PATTERNS = [
19
- [/AKIA[0-9A-Z]{16}/g, 'aws-access-key'],
20
- [/-----BEGIN [A-Z ]*PRIVATE KEY-----/g, 'private-key'],
21
- [/\bgh[posru]_[A-Za-z0-9]{20,}\b/g, 'github-token'],
22
- [/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, 'github-pat'],
23
- [/\bsk-[A-Za-z0-9]{20,}\b/g, 'openai-key'],
24
- [/\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g, 'stripe-key'],
25
- [/\bAIza[0-9A-Za-z_\-]{35}\b/g, 'google-api-key'],
26
- [/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, 'slack-token'],
27
- [/\bglpat-[A-Za-z0-9_\-]{20,}\b/g, 'gitlab-pat'],
28
- [/\bnpm_[A-Za-z0-9]{36}\b/g, 'npm-token'],
29
- [/\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\b/g, 'jwt'],
30
- ];
31
- export function findSecrets(text) {
32
- const s = String(text); const hits = [];
33
- for (const [re, kind] of SECRET_PATTERNS) { const m = s.match(re); if (m && m.length) hits.push([kind, m.length]); }
34
- return hits;
35
- }
36
-
37
- // Best-effort secret/PII denylist applied to emitted text projections. Backed by the indexer's
38
- // fail-closed findSecrets() audit on the final artifact (defense in depth, not a single boundary).
39
- const DENY = [
40
- /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, // emails
41
- /\b(?:api[_-]?key|secret|token|password|passwd|bearer)\b\s*[:=]\s*\S+/gi,
42
- ...SECRET_PATTERNS.map(([re]) => re),
43
- ];
44
- // Optional project-specific sensitive terms (e.g. an employer/customer name) → redacted too.
45
- let EXTRA_TERMS = [];
46
- export function setRedactTerms(terms) {
47
- EXTRA_TERMS = (terms || []).map(t => String(t).trim()).filter(Boolean)
48
- .map(t => new RegExp('\\b' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'gi'));
49
- }
50
- export function scrubText(s) {
51
- if (typeof s !== 'string') return s;
52
- let out = s;
53
- for (const re of DENY) out = out.replace(re, '[REDACTED]');
54
- for (const re of EXTRA_TERMS) out = out.replace(re, '[REDACTED]');
55
- return out;
56
- }
57
-
58
- // Default path/dir ignores (the indexer also honors the repo's .gitignore + a user .kcignore).
59
- export const IGNORE_DEFAULTS = [
60
- /(^|\/)\.git(\/|$)/, /(^|\/)node_modules(\/|$)/, /(^|\/)secrets?(\/|$)/i,
61
- /(^|\/)keys?(\/|$)/i, /(^|\/)\.env/i, /(^|\/)dist(\/|$)/,
62
- ];
63
-
64
- // Serialize one node: keep ONLY allowlisted fields, scrub text, clamp summary.
65
- export function serializeNode(n) {
66
- const o = {};
67
- for (const k of NODE_FIELD_ALLOWLIST) if (n[k] !== undefined) o[k] = n[k];
68
- if (typeof o.title === 'string') o.title = scrubText(o.title);
69
- if (typeof o.summary === 'string') o.summary = scrubText(o.summary).slice(0, SUMMARY_MAX);
70
- if (Array.isArray(o.headings)) o.headings = o.headings.map(scrubText);
71
- if (Array.isArray(o.tags)) o.tags = o.tags.map(scrubText);
72
- return o;
73
- }
74
-
75
- // Validate a city envelope against the contract. Returns { ok, errors[] }.
76
- export function validateCity(city) {
77
- const errors = [];
78
- if (!city || typeof city !== 'object') return { ok: false, errors: ['city is not an object'] };
79
- if (city.schema_version !== SCHEMA_VERSION) errors.push(`schema_version != ${SCHEMA_VERSION}`);
80
- if (!Array.isArray(city.categories)) errors.push('missing categories[] manifest');
81
- if (!Array.isArray(city.nodes)) errors.push('missing nodes[]');
82
- const catIds = new Set((city.categories || []).map(c => c.id));
83
- (city.categories || []).forEach((c, i) => {
84
- for (const k of CATEGORY_REQUIRED) if (c[k] === undefined) errors.push(`category[${i}] missing ${k}`);
85
- });
86
- const ids = new Set();
87
- (city.nodes || []).forEach((n, i) => {
88
- for (const k of NODE_REQUIRED) if (n[k] === undefined) errors.push(`node[${i}] missing ${k}`);
89
- if (n.category && !catIds.has(n.category)) errors.push(`node[${i}] category '${n.category}' not in manifest`);
90
- if (n.id) { if (ids.has(n.id)) errors.push(`duplicate id ${n.id}`); ids.add(n.id); }
91
- if (n.provenance) for (const k of PROVENANCE_REQUIRED) if (n.provenance[k] === undefined) errors.push(`node[${i}] provenance missing ${k}`);
92
- if (typeof n.summary === 'string' && n.summary.length > SUMMARY_MAX) errors.push(`node[${i}] summary > ${SUMMARY_MAX}`);
93
- for (const k of Object.keys(n)) if (!NODE_FIELD_ALLOWLIST.includes(k)) errors.push(`node[${i}] non-allowlisted field '${k}'`);
94
- });
95
- if (city.node_count !== undefined && city.node_count !== (city.nodes || []).length) errors.push('node_count mismatch');
96
- return { ok: errors.length === 0, errors };
97
- }
98
-
99
- const PALETTE = ['#4f8cff', '#34c759', '#ff9f0a', '#af52de', '#ff375f', '#5ac8fa', '#ffd60a', '#bf5af2', '#30d158', '#ff6482'];
100
-
101
- // Build a categories[] manifest from a list of category ids (stable order + palette).
102
- export function deriveCategories(ids) {
103
- return ids.map((id, i) => ({
104
- id, label: String(id).charAt(0).toUpperCase() + String(id).slice(1),
105
- color: PALETTE[i % PALETTE.length], order: i,
106
- }));
107
- }
108
-
109
- // Legacy adapter: v1 (district, no categories[]) → v2 (category + categories[] manifest), via the allowlist.
110
- export function adaptLegacy(cityV1) {
111
- const ids = [...new Set((cityV1.nodes || []).map(n => n.district).filter(Boolean))];
112
- const categories = deriveCategories(ids);
113
- const nodes = (cityV1.nodes || []).map(n => {
114
- const { district, ...rest } = n;
115
- return serializeNode({ ...rest, category: district });
116
- });
117
- return {
118
- schema_version: SCHEMA_VERSION,
119
- generated_at: cityV1.generated_at || new Date().toISOString(),
120
- source: { kind: 'legacy', ref: cityV1.source_rev || 'unknown', rev: cityV1.source_rev || 'unknown' },
121
- categories, node_count: nodes.length, nodes,
122
- };
123
- }
1
+ // KC city-data CONTRACT v2 — the shared spine.
2
+ // Generalizes the fixed-4-district v1 → N categories. Pointers + projections ONLY (D-146); never bodies.
3
+ // Council fixes baked in: serialization ALLOWLIST at the edge + best-effort DENYLIST scrub.
4
+
5
+ export const SCHEMA_VERSION = '2.0';
6
+
7
+ // The ONLY node fields that may ever be serialized into the index. Anything else is dropped/flagged.
8
+ export const NODE_FIELD_ALLOWLIST = [
9
+ 'id', 'kind', 'title', 'summary', 'category', 'status',
10
+ 'fact_date', 'tags', 'headings', 'links', 'churn', 'provenance', 'visibility', 'sensitive',
11
+ ];
12
+ export const NODE_REQUIRED = ['id', 'kind', 'title', 'category', 'links', 'provenance'];
13
+ export const PROVENANCE_REQUIRED = ['store', 'ref'];
14
+ export const CATEGORY_REQUIRED = ['id', 'label', 'order'];
15
+ export const SUMMARY_MAX = 200;
16
+
17
+ // Detection patterns for the fail-closed safe-share audit (broader than the scrub list below).
18
+ export const SECRET_PATTERNS = [
19
+ [/AKIA[0-9A-Z]{16}/g, 'aws-access-key'],
20
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----/g, 'private-key'],
21
+ [/\bgh[posru]_[A-Za-z0-9]{20,}\b/g, 'github-token'],
22
+ [/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, 'github-pat'],
23
+ [/\bsk-[A-Za-z0-9]{20,}\b/g, 'openai-key'],
24
+ [/\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g, 'stripe-key'],
25
+ [/\bAIza[0-9A-Za-z_\-]{35}\b/g, 'google-api-key'],
26
+ [/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, 'slack-token'],
27
+ [/\bglpat-[A-Za-z0-9_\-]{20,}\b/g, 'gitlab-pat'],
28
+ [/\bnpm_[A-Za-z0-9]{36}\b/g, 'npm-token'],
29
+ [/\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\b/g, 'jwt'],
30
+ ];
31
+ export function findSecrets(text) {
32
+ const s = String(text); const hits = [];
33
+ for (const [re, kind] of SECRET_PATTERNS) { const m = s.match(re); if (m && m.length) hits.push([kind, m.length]); }
34
+ return hits;
35
+ }
36
+
37
+ // Best-effort secret/PII denylist applied to emitted text projections. Backed by the indexer's
38
+ // fail-closed findSecrets() audit on the final artifact (defense in depth, not a single boundary).
39
+ const DENY = [
40
+ /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, // emails
41
+ /\b(?:api[_-]?key|secret|token|password|passwd|bearer)\b\s*[:=]\s*\S+/gi,
42
+ ...SECRET_PATTERNS.map(([re]) => re),
43
+ ];
44
+ // Optional project-specific sensitive terms (e.g. an employer/customer name) → redacted too.
45
+ let EXTRA_TERMS = [];
46
+ export function setRedactTerms(terms) {
47
+ EXTRA_TERMS = (terms || []).map(t => String(t).trim()).filter(Boolean)
48
+ .map(t => new RegExp('\\b' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'gi'));
49
+ }
50
+ export function scrubText(s) {
51
+ if (typeof s !== 'string') return s;
52
+ let out = s;
53
+ for (const re of DENY) out = out.replace(re, '[REDACTED]');
54
+ for (const re of EXTRA_TERMS) out = out.replace(re, '[REDACTED]');
55
+ return out;
56
+ }
57
+
58
+ // Default path/dir ignores (the indexer also honors the repo's .gitignore + a user .kcignore).
59
+ export const IGNORE_DEFAULTS = [
60
+ /(^|\/)\.git(\/|$)/, /(^|\/)node_modules(\/|$)/, /(^|\/)secrets?(\/|$)/i,
61
+ /(^|\/)keys?(\/|$)/i, /(^|\/)\.env/i, /(^|\/)dist(\/|$)/,
62
+ ];
63
+
64
+ // Serialize one node: keep ONLY allowlisted fields, scrub text, clamp summary.
65
+ export function serializeNode(n) {
66
+ const o = {};
67
+ for (const k of NODE_FIELD_ALLOWLIST) if (n[k] !== undefined) o[k] = n[k];
68
+ if (typeof o.title === 'string') o.title = scrubText(o.title);
69
+ if (typeof o.summary === 'string') o.summary = scrubText(o.summary).slice(0, SUMMARY_MAX);
70
+ if (Array.isArray(o.headings)) o.headings = o.headings.map(scrubText);
71
+ if (Array.isArray(o.tags)) o.tags = o.tags.map(scrubText);
72
+ return o;
73
+ }
74
+
75
+ // Validate a city envelope against the contract. Returns { ok, errors[] }.
76
+ export function validateCity(city) {
77
+ const errors = [];
78
+ if (!city || typeof city !== 'object') return { ok: false, errors: ['city is not an object'] };
79
+ if (city.schema_version !== SCHEMA_VERSION) errors.push(`schema_version != ${SCHEMA_VERSION}`);
80
+ if (!Array.isArray(city.categories)) errors.push('missing categories[] manifest');
81
+ if (!Array.isArray(city.nodes)) errors.push('missing nodes[]');
82
+ const catIds = new Set((city.categories || []).map(c => c.id));
83
+ (city.categories || []).forEach((c, i) => {
84
+ for (const k of CATEGORY_REQUIRED) if (c[k] === undefined) errors.push(`category[${i}] missing ${k}`);
85
+ });
86
+ const ids = new Set();
87
+ (city.nodes || []).forEach((n, i) => {
88
+ for (const k of NODE_REQUIRED) if (n[k] === undefined) errors.push(`node[${i}] missing ${k}`);
89
+ if (n.category && !catIds.has(n.category)) errors.push(`node[${i}] category '${n.category}' not in manifest`);
90
+ if (n.id) { if (ids.has(n.id)) errors.push(`duplicate id ${n.id}`); ids.add(n.id); }
91
+ if (n.provenance) for (const k of PROVENANCE_REQUIRED) if (n.provenance[k] === undefined) errors.push(`node[${i}] provenance missing ${k}`);
92
+ if (typeof n.summary === 'string' && n.summary.length > SUMMARY_MAX) errors.push(`node[${i}] summary > ${SUMMARY_MAX}`);
93
+ for (const k of Object.keys(n)) if (!NODE_FIELD_ALLOWLIST.includes(k)) errors.push(`node[${i}] non-allowlisted field '${k}'`);
94
+ });
95
+ if (city.node_count !== undefined && city.node_count !== (city.nodes || []).length) errors.push('node_count mismatch');
96
+ return { ok: errors.length === 0, errors };
97
+ }
98
+
99
+ const PALETTE = ['#4f8cff', '#34c759', '#ff9f0a', '#af52de', '#ff375f', '#5ac8fa', '#ffd60a', '#bf5af2', '#30d158', '#ff6482'];
100
+
101
+ // Build a categories[] manifest from a list of category ids (stable order + palette).
102
+ export function deriveCategories(ids) {
103
+ return ids.map((id, i) => ({
104
+ id, label: String(id).charAt(0).toUpperCase() + String(id).slice(1),
105
+ color: PALETTE[i % PALETTE.length], order: i,
106
+ }));
107
+ }
108
+
109
+ // Legacy adapter: v1 (district, no categories[]) → v2 (category + categories[] manifest), via the allowlist.
110
+ export function adaptLegacy(cityV1) {
111
+ const ids = [...new Set((cityV1.nodes || []).map(n => n.district).filter(Boolean))];
112
+ const categories = deriveCategories(ids);
113
+ const nodes = (cityV1.nodes || []).map(n => {
114
+ const { district, ...rest } = n;
115
+ return serializeNode({ ...rest, category: district });
116
+ });
117
+ return {
118
+ schema_version: SCHEMA_VERSION,
119
+ generated_at: cityV1.generated_at || new Date().toISOString(),
120
+ source: { kind: 'legacy', ref: cityV1.source_rev || 'unknown', rev: cityV1.source_rev || 'unknown' },
121
+ categories, node_count: nodes.length, nodes,
122
+ };
123
+ }