@starci/hfs 4.0.9 → 4.0.10
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 -0
- package/lint/run.mjs +80 -7
- package/package.json +3 -2
- package/runtime/engine/admission.mjs +308 -0
- package/runtime/engine/canonical-json.mjs +10 -0
- package/runtime/engine/config.mjs +36 -44
- package/runtime/engine/db/blob.mjs +315 -0
- package/runtime/engine/db/ledger-paths.mjs +83 -0
- package/runtime/engine/db/ledger.mjs +1118 -0
- package/runtime/engine/db/machine-connection.mjs +135 -0
- package/runtime/engine/db/machine-schema.mjs +84 -0
- package/runtime/engine/db/machine.mjs +1367 -0
- package/runtime/engine/db/migrations/machine/0001-init.sql +924 -0
- package/runtime/engine/db/migrations/runtime/0001-init.sql +1108 -0
- package/runtime/engine/db/provider-reservations.mjs +101 -0
- package/runtime/engine/digest.mjs +16 -0
- package/runtime/engine/refuse.mjs +11 -0
- package/runtime/engine/secrets.mjs +130 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +10 -10
- package/runtime/knowledge/hfs/rules.yaml +8 -8
- package/runtime/modules/kernel/failure-codes.yaml +40 -0
- package/runtime/modules/models/registry.yaml +1 -39
- package/runtime/modules/models/runtimes.yaml +0 -6
- package/runtime/modules/ops/_labels.yaml +53 -0
- package/runtime/scripts/api/fs/lib.mjs +6 -0
- package/runtime/scripts/api/git/lib.mjs +2 -0
- package/runtime/scripts/api/node/lib.mjs +14 -0
- package/runtime/scripts/api/node/spawn-node.mjs +6 -0
- package/runtime/scripts/api/process/lib.mjs +111 -0
- package/runtime/scripts/api/process/owned-process.mjs +78 -0
- package/runtime/scripts/api/process/resolve-real-tool.mjs +46 -0
- package/runtime/scripts/api/process/run-program.mjs +7 -0
- package/runtime/scripts/api/process/stop-owned-process.mjs +9 -0
- package/runtime/scripts/api/sops/decrypt.mjs +9 -16
- package/runtime/scripts/api/sops/lib.mjs +230 -10
- package/runtime/scripts/api/sops/seal.mjs +8 -4
- package/runtime/scripts/connectors/lib.mjs +488 -0
- package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +1 -1
- package/runtime/scripts/hfs/secret.mjs +45 -11
- package/runtime/scripts/lib/clip.mjs +19 -0
- package/runtime/scripts/lib/display-names.mjs +259 -0
- package/runtime/scripts/lib/example-refs.mjs +158 -0
- package/runtime/scripts/lib/fs-kind.mjs +14 -4
- package/runtime/scripts/lib/json-schema.mjs +52 -0
- package/runtime/scripts/lib/mutation-fence.mjs +15 -0
- package/runtime/scripts/lib/path-key.mjs +4 -4
- package/runtime/scripts/lib/process-identity.mjs +6 -0
- package/runtime/scripts/lib/read-yaml.mjs +18 -0
- package/runtime/scripts/lib/redact.mjs +161 -0
- package/runtime/scripts/lib/sleep.mjs +7 -0
- package/runtime/scripts/lib/sops-envelope.mjs +54 -0
- package/runtime/scripts/lib/source-phrases.mjs +34 -0
- package/runtime/scripts/lib/sqlite.mjs +21 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// redact.mjs — the ONE redaction module (alpha.3, ARCHITECTURE-DB §4.2). Every blob put of text bytes
|
|
2
|
+
// (report attachments, check stdout/stderr/output, terminal transcripts, CLI sessions) and every log write
|
|
3
|
+
// passes through here first; the harness UI server reuses it. A blob whose bytes went through redactText is
|
|
4
|
+
// stamped blobs.redaction = REDACTION_VERSION; bytes that cannot be filtered (images, video, archives) are
|
|
5
|
+
// stamped 'binary'.
|
|
6
|
+
//
|
|
7
|
+
// It blanks: provider tokens and JWTs (secret-patterns.mjs, the push scan's list), auth headers, URL query
|
|
8
|
+
// secrets, URL credentials (scheme://user:pass@host), PEM private-key blocks (the whole block), KEY=value /
|
|
9
|
+
// KEY: value pairs whose key names a secret (FOO_API_KEY=..., password: ...), and the declared secrets of a
|
|
10
|
+
// product repo's .starcistacks (their env-style names as keys, and their values wherever they appear).
|
|
11
|
+
// Keys and surrounding words stay, so a redacted transcript still reads.
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { FORBIDDEN_FILES, SECRET_PATTERNS } from './secret-patterns.mjs';
|
|
15
|
+
import { parseYaml } from '../../engine/yaml.mjs';
|
|
16
|
+
import { altOf } from './source-phrases.mjs';
|
|
17
|
+
import { redactResolvedSecrets } from '../../engine/secrets.mjs';
|
|
18
|
+
|
|
19
|
+
const REDACTION_VERSION = 'v1';
|
|
20
|
+
export const MARK = '[redacted]';
|
|
21
|
+
|
|
22
|
+
const withGlobal = (re) => new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`);
|
|
23
|
+
const PATTERNS = SECRET_PATTERNS.filter((rule) => rule.name !== 'private-key-block').map((rule) => ({ ...rule, g: withGlobal(rule.re) }));
|
|
24
|
+
// Words that make an env-style key a secret, matched per `_`-separated segment of the name (FOO_API_KEY,
|
|
25
|
+
// KEYCLOAK_ADMIN_PASSWORD, GH_TOKEN, MINIO_SECRET_KEY, DATABASE_URL is not; TESTS_PASSED is not).
|
|
26
|
+
const SECRET_SEGMENTS = new Set(['PASSWORD', 'PASSWD', 'PWD', 'PASS', 'SECRET', 'SECRETS', 'TOKEN', 'APIKEY', 'KEY', 'CREDENTIAL', 'CREDENTIALS', 'COOKIE', 'OTP', 'DSN', 'PAT']);
|
|
27
|
+
const isSecretEnvName = (name) => String(name).replace(/^export\s+/, '').replace(/_FILE$/, '').split('_').some((seg) => SECRET_SEGMENTS.has(seg));
|
|
28
|
+
const PEM_BLOCK = /-----BEGIN ((?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?)-----[\s\S]*?(?:-----END \1-----|$)/g;
|
|
29
|
+
const RULES = [
|
|
30
|
+
{ name: 'url-credentials', re: /\b([a-z][a-z0-9+.-]*:\/\/)([^\s:@/"'<>]+):([^\s@/"'<>]+)@/gi, to: (m, scheme, user) => `${scheme}${user}:${MARK}@` },
|
|
31
|
+
{ name: 'auth-header', re: /\b(Bearer|Basic|Token)(\s+)[A-Za-z0-9._~+/=-]{8,}/gi, to: (m, a, b) => `${a}${b}${MARK}` },
|
|
32
|
+
{ name: 'url-secret', re: /([?&#](?:access_token|refresh_token|id_token|token|key|api_key|apikey|secret|code|password|otp|sig|signature)=)([^&#\s"']+)/gi, to: (m, a) => `${a}${MARK}` },
|
|
33
|
+
{ name: 'otp', re: new RegExp(`\\b(otp|one[-_ ]?time[-_ ]?(?:code|password|pin)|verification[-_ ]?code|2fa[-_ ]?code|${altOf('redact.otpLabel')})(\\s*[:=]?\\s*["']?)(\\d{4,8})\\b`, 'giu'), to: (m, a, b) => `${a}${b}${MARK}` },
|
|
34
|
+
// ENV_STYLE_KEY=value / ENV_STYLE_KEY: value (shell exports, dotenv, compose, CLI echo).
|
|
35
|
+
{ name: 'env-secret', re: /\b((?:export\s+)?[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*)(\s*[:=]\s*["']?)((?!\[redacted)[^\s"'`]{3,})/g,
|
|
36
|
+
to: (m, a, b, c) => (isSecretEnvName(a) && !c.startsWith('/run/secrets/') && !c.startsWith('$') ? `${a}${b}${MARK}` : m) },
|
|
37
|
+
{ name: 'keyed-secret', re: /\b(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|auth[_-]?token|session[_-]?token|private[_-]?key|otp|pin[_-]?code|cookie|set-cookie)(["']?\s*[:=]\s*["']?)((?!\[redacted)[^\s"',;&}]{3,})/gi, to: (m, a, b) => `${a}${b}${MARK}` },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** Keys whose value is a secret whatever it looks like; the key stays, the value goes. */
|
|
41
|
+
export const SECRET_KEY = /^(?:password|passwd|pwd|pass|secret|otp|pin|pincode|pin_code|credential|credentials|authorization|cookie|cookies|set-cookie|private[_-]?key|client[_-]?secret|api[_-]?key|apikey|[a-z_-]*token|[a-z_-]*secret)$/i;
|
|
42
|
+
|
|
43
|
+
// ------------------------------------------------------------------------------ declared stack secrets
|
|
44
|
+
// A product repo's .starcistacks/<stack>/stack.yaml `secrets:` block names each secret and the runtime file
|
|
45
|
+
// that holds its value. Values are refreshed from their declared inputs; values never leave the filter.
|
|
46
|
+
const STACK_DIRS = ['.starcistacks'];
|
|
47
|
+
const secretValues = new Set();
|
|
48
|
+
const secretNames = new Set();
|
|
49
|
+
const envName = (name) => String(name).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').toUpperCase();
|
|
50
|
+
const readSafe = (file) => { try { return fs.readFileSync(file, 'utf8'); } catch { return null; } };
|
|
51
|
+
const listSafe = (dir) => { try { return fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; } };
|
|
52
|
+
|
|
53
|
+
/** A value this process must never write out (a secret it resolved itself). Values under 6 chars are ignored. */
|
|
54
|
+
function addSecretValue(value) {
|
|
55
|
+
const v = typeof value === 'string' ? value.trim() : '';
|
|
56
|
+
if (v.length >= 6 && !/^\[redacted/.test(v)) secretValues.add(v);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Learn the declared secrets of every .starcistacks stack under `repoRoot` (idempotent). */
|
|
60
|
+
export function learnStackSecrets(repoRoot) {
|
|
61
|
+
if (typeof repoRoot !== 'string' || !repoRoot) return;
|
|
62
|
+
const root = path.resolve(repoRoot);
|
|
63
|
+
for (const stackDir of STACK_DIRS) {
|
|
64
|
+
for (const entry of listSafe(path.join(root, stackDir))) {
|
|
65
|
+
if (!entry.isDirectory()) continue;
|
|
66
|
+
const dir = path.join(root, stackDir, entry.name);
|
|
67
|
+
const text = readSafe(path.join(dir, 'stack.yaml'));
|
|
68
|
+
let doc = null;
|
|
69
|
+
try { doc = text ? parseYaml(text) : null; } catch { doc = null; }
|
|
70
|
+
const secrets = doc && typeof doc.secrets === 'object' && doc.secrets ? doc.secrets : {};
|
|
71
|
+
for (const [name, spec] of Object.entries(secrets)) {
|
|
72
|
+
secretNames.add(envName(name));
|
|
73
|
+
if (spec && typeof spec.file === 'string') addSecretValue(readSafe(path.resolve(dir, spec.file)));
|
|
74
|
+
}
|
|
75
|
+
// Plaintext runtime copies of the secrets (git-ignored): their values are secrets too.
|
|
76
|
+
for (const file of listSafe(path.join(dir, 'runtime', 'files'))) if (file.isFile()) addSecretValue(readSafe(path.join(dir, 'runtime', 'files', file.name)));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
82
|
+
let namesRe = null, namesSize = -1;
|
|
83
|
+
const declaredNameRule = () => {
|
|
84
|
+
if (namesSize !== secretNames.size) {
|
|
85
|
+
namesSize = secretNames.size;
|
|
86
|
+
namesRe = secretNames.size ? new RegExp(`\\b((?:${[...secretNames].map(escapeRe).join('|')})(?:_FILE)?)(\\s*[:=]\\s*["']?)((?!\\[redacted)[^\\s"'\`]{3,})`, 'g') : null;
|
|
87
|
+
}
|
|
88
|
+
return namesRe;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// --------------------------------------------------------------------------------------------- text
|
|
92
|
+
/**
|
|
93
|
+
* `text` with every secret value blanked. `repoRoots` (optional) are product repos whose .starcistacks
|
|
94
|
+
* declarations are learned first.
|
|
95
|
+
*/
|
|
96
|
+
export function redactText(text, { repoRoots = [] } = {}) {
|
|
97
|
+
if (typeof text !== 'string' || !text) return text;
|
|
98
|
+
for (const root of repoRoots) learnStackSecrets(root);
|
|
99
|
+
let out = redactResolvedSecrets(text).replace(PEM_BLOCK, (m, kind) => `[redacted:pem ${kind}]`);
|
|
100
|
+
for (const value of secretValues) if (out.includes(value)) out = out.split(value).join('[redacted:stack-secret]');
|
|
101
|
+
for (const rule of PATTERNS) {
|
|
102
|
+
rule.g.lastIndex = 0;
|
|
103
|
+
out = out.replace(rule.g, (match, value) => {
|
|
104
|
+
if (rule.name === 'assigned-secret') return rule.placeholder?.test(value ?? '') ? match : match.replace(value, MARK);
|
|
105
|
+
return `[redacted:${rule.name}]`;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const declared = declaredNameRule();
|
|
109
|
+
if (declared) { declared.lastIndex = 0; out = out.replace(declared, (m, a, b) => `${a}${b}${MARK}`); }
|
|
110
|
+
for (const rule of RULES) { rule.re.lastIndex = 0; out = out.replace(rule.re, rule.to); }
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A path that is a secret by being one (an env file, a key file, .secrets/): blanked, its rule named. */
|
|
115
|
+
export function redactPath(p) {
|
|
116
|
+
if (typeof p !== 'string') return p;
|
|
117
|
+
const slashed = p.replace(/\\/g, '/');
|
|
118
|
+
const rule = FORBIDDEN_FILES.find((r) => r.test(slashed));
|
|
119
|
+
return rule ? `[redacted:${rule.name}]` : redactText(p);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A deep copy of `value` with secret-named keys blanked and every string redacted. */
|
|
123
|
+
export function redactData(value, key = null, depth = 0) {
|
|
124
|
+
if (depth > 8) return '[depth]';
|
|
125
|
+
if (key && SECRET_KEY.test(key) && value != null && typeof value !== 'object' && typeof value !== 'boolean') return MARK;
|
|
126
|
+
if (typeof value === 'string') return /(?:path|ref|file)$/i.test(key ?? '') ? redactPath(value) : redactText(value);
|
|
127
|
+
if (Array.isArray(value)) return value.map((v) => redactData(v, null, depth + 1));
|
|
128
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactData(v, k, depth + 1)]));
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// -------------------------------------------------------------------------------------------- bytes
|
|
133
|
+
/** True for media types whose bytes are text the filter can read. */
|
|
134
|
+
export const isTextMedia = (mediaType) => /^text\/|[/+](?:json|xml|yaml|x-yaml|javascript|x-ndjson|x-diff|x-patch|csv|sql|x-sh)\b|^application\/(?:json|xml|yaml|javascript|x-ndjson|x-diff|x-patch|sql)$/i
|
|
135
|
+
.test(String(mediaType ?? ''));
|
|
136
|
+
|
|
137
|
+
/** The supported text encoding selected by a byte-order mark. */
|
|
138
|
+
export function textEncodingOf(bytes) {
|
|
139
|
+
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) return 'utf-16le';
|
|
140
|
+
if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) return 'utf-16be';
|
|
141
|
+
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return 'utf-8-bom';
|
|
142
|
+
return 'utf-8';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Decode supported text without accepting malformed bytes as sanitized evidence. */
|
|
146
|
+
export function decodeText(bytes) {
|
|
147
|
+
const encoding = textEncodingOf(bytes);
|
|
148
|
+
return new TextDecoder(encoding === 'utf-8-bom' ? 'utf-8' : encoding, { fatal: true }).decode(bytes);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Bytes ready for a blob put: text bytes redacted ({redaction:'v1'}), anything else untouched
|
|
153
|
+
* ({redaction:'binary'}). The blob sha is then taken over the returned bytes.
|
|
154
|
+
*/
|
|
155
|
+
export function redactBytes(bytes, mediaType, { repoRoots = [] } = {}) {
|
|
156
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
157
|
+
if (!isTextMedia(mediaType)) return { bytes: buf, redaction: 'binary' };
|
|
158
|
+
const text = decodeText(buf);
|
|
159
|
+
const clean = redactText(text, { repoRoots });
|
|
160
|
+
return { bytes: Buffer.from(clean, 'utf8'), redaction: REDACTION_VERSION };
|
|
161
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// sleep.mjs — the waits a loop picks between: `sleep` yields the event loop (async code, polls and
|
|
2
|
+
// backoff in the connectors and the watchdog loops), `sleepSync` blocks the thread for synchronous
|
|
3
|
+
// code (lock polls, rename retry — the blocking twin lives in ./sleep-sync.mjs).
|
|
4
|
+
export { sleepSync } from './sleep-sync.mjs';
|
|
5
|
+
|
|
6
|
+
/** Resolve after `ms` milliseconds. */
|
|
7
|
+
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -56,3 +56,57 @@ export function isSopsEnvelope(text) {
|
|
|
56
56
|
}
|
|
57
57
|
try { return sopsTree(parseYaml(src)); } catch { return false; }
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
/** Admit one flat age envelope for the native selected-identity invocation; this is shape admission, never cryptographic proof. */
|
|
61
|
+
export function selectedAgeEnvelope(text, format, recipient) {
|
|
62
|
+
const refuse = reason => ({ ok: false, reason });
|
|
63
|
+
if (!['binary', 'json', 'yaml', 'dotenv'].includes(format)) return refuse('unsupported-format');
|
|
64
|
+
let meta;
|
|
65
|
+
try {
|
|
66
|
+
if (format === 'dotenv') {
|
|
67
|
+
const pairs = new Map();
|
|
68
|
+
for (const line of String(text).split('\n')) {
|
|
69
|
+
if (!line || line.startsWith('#')) continue;
|
|
70
|
+
if (line.includes('\r')) return refuse('unsupported-dotenv');
|
|
71
|
+
const at = line.indexOf('=');
|
|
72
|
+
if (at < 1) return refuse('unsupported-dotenv');
|
|
73
|
+
const name = line.slice(0, at);
|
|
74
|
+
if (pairs.has(name)) return refuse('duplicate-key');
|
|
75
|
+
pairs.set(name, line.slice(at + 1).replaceAll('\\n', '\n'));
|
|
76
|
+
}
|
|
77
|
+
meta = {};
|
|
78
|
+
const age = {};
|
|
79
|
+
for (const [name, value] of pairs) {
|
|
80
|
+
if (!name.startsWith('sops_')) continue;
|
|
81
|
+
const key = name.slice('sops_'.length);
|
|
82
|
+
if (key === 'age__list_0__map_recipient') age.recipient = value;
|
|
83
|
+
else if (key === 'age__list_0__map_enc') age.enc = value;
|
|
84
|
+
else if (key === 'mac_only_encrypted') {
|
|
85
|
+
if (value !== 'false') return refuse('partial-mac');
|
|
86
|
+
meta[key] = false;
|
|
87
|
+
} else if (key.includes('__')) return refuse('unsupported-key-group');
|
|
88
|
+
else meta[key] = value;
|
|
89
|
+
}
|
|
90
|
+
meta.age = [age];
|
|
91
|
+
} else {
|
|
92
|
+
const doc = parseYaml(String(text));
|
|
93
|
+
if (format !== 'yaml') JSON.parse(String(text));
|
|
94
|
+
if (format === 'binary' && (!doc || Object.keys(doc).length !== 2 || !Object.hasOwn(doc, 'data') || !Object.hasOwn(doc, 'sops') || typeof doc.data !== 'string')) return refuse('unsupported-binary-shape');
|
|
95
|
+
meta = doc?.sops;
|
|
96
|
+
}
|
|
97
|
+
} catch { return refuse('invalid-envelope'); }
|
|
98
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta) || !isSopsEnvelope(text)) return refuse('invalid-envelope');
|
|
99
|
+
if (Object.hasOwn(meta, 'key_groups') || Object.hasOwn(meta, 'shamir_threshold')) return refuse('unsupported-key-group');
|
|
100
|
+
const providers = ['kms', 'gcp_kms', 'hckms', 'azure_kv', 'hc_vault', 'pgp'];
|
|
101
|
+
for (const name of providers) if (Object.hasOwn(meta, name) && (!Array.isArray(meta[name]) || meta[name].length)) return refuse('non-age-provider');
|
|
102
|
+
const fields = new Set([...providers, 'age', 'lastmodified', 'mac', 'version', 'unencrypted_suffix', 'encrypted_suffix', 'unencrypted_regex', 'encrypted_regex', 'unencrypted_comment_regex', 'encrypted_comment_regex', 'mac_only_encrypted']);
|
|
103
|
+
if (Object.keys(meta).some(name => !fields.has(name))) return refuse('unknown-metadata');
|
|
104
|
+
if (Object.hasOwn(meta, 'mac_only_encrypted') && meta.mac_only_encrypted !== false) return refuse('partial-mac');
|
|
105
|
+
if (!Array.isArray(meta.age) || meta.age.length !== 1) return refuse('unsupported-age-set');
|
|
106
|
+
const entry = meta.age[0];
|
|
107
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry) || Object.keys(entry).some(name => !['recipient', 'enc'].includes(name))) return refuse('invalid-age-entry');
|
|
108
|
+
if (typeof entry.recipient !== 'string' || entry.recipient !== recipient) return refuse('recipient-mismatch');
|
|
109
|
+
if (typeof entry.enc !== 'string' || !entry.enc.startsWith('-----BEGIN AGE ENCRYPTED FILE-----\n') || !entry.enc.includes('\n-----END AGE ENCRYPTED FILE-----')) return refuse('invalid-age-entry');
|
|
110
|
+
if (typeof meta.version !== 'string' || !/^\d+\.\d+\.\d+$/.test(meta.version) || typeof meta.lastmodified !== 'string' || !Number.isFinite(Date.parse(meta.lastmodified))) return refuse('invalid-envelope');
|
|
111
|
+
return { ok: true };
|
|
112
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// source-phrases.mjs - the Vietnamese phrase lists runtime source matches against owner or product text are DATA,
|
|
2
|
+
// not source: they live in modules/goal/source-phrases.yaml (its leaf keys are declared Vietnamese fields,
|
|
3
|
+
// scripts/lib/language.mjs DECLARED_VIETNAMESE_FIELDS - like the archetypes.yaml phrase lists) so the matchers'
|
|
4
|
+
// source stays English. A list is plain words or regex alternatives, per its comment in the lexicon.
|
|
5
|
+
//
|
|
6
|
+
// phrasesOf('owed.ownerWord') -> the list as written (['<owner word>', ...])
|
|
7
|
+
// altOf('owed.ownerWord') -> the regex alternation 'a|b|c'; '(?!)' - never matches - when absent
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { skillRoot } from '../../engine/runtime-root.mjs';
|
|
11
|
+
import { parseYaml } from '../../engine/yaml.mjs';
|
|
12
|
+
|
|
13
|
+
const SOURCE_PHRASES_FILE = 'modules/goal/source-phrases.yaml';
|
|
14
|
+
|
|
15
|
+
const cache = new Map();
|
|
16
|
+
|
|
17
|
+
/** The lexicon's `phrases` map ({<group>: {<list>: string[]}}), cached per root. */
|
|
18
|
+
function sourcePhrases(root = skillRoot) {
|
|
19
|
+
if (!cache.has(root)) {
|
|
20
|
+
let doc = null;
|
|
21
|
+
try { doc = parseYaml(fs.readFileSync(path.join(root, ...SOURCE_PHRASES_FILE.split('/')), 'utf8')); } catch { doc = null; }
|
|
22
|
+
cache.set(root, doc && typeof doc.phrases === 'object' && doc.phrases ? doc.phrases : {});
|
|
23
|
+
}
|
|
24
|
+
return cache.get(root);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The phrase list `dotKey` (`<group>.<list>`) of `root`, [] when absent. */
|
|
28
|
+
export function phrasesOf(dotKey, root = skillRoot) {
|
|
29
|
+
const value = dotKey.split('.').reduce((v, k) => (v && typeof v === 'object' ? v[k] : undefined), sourcePhrases(root));
|
|
30
|
+
return (Array.isArray(value) ? value : []).map(String);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The regex alternation of `dotKey` ('a|b|c'); '(?!)' - a never-match - when the list is empty. */
|
|
34
|
+
export const altOf = (dotKey, root = skillRoot) => phrasesOf(dotKey, root).join('|') || '(?!)';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// sqlite.mjs — the small better-sqlite3 probes the ledger and machine stores share. The handle is the
|
|
2
|
+
// caller's; these helpers prepare statements on it and return its own result shapes.
|
|
3
|
+
|
|
4
|
+
/** Whether the database holds a table `name` (`views: true` also counts views). */
|
|
5
|
+
export const hasTable = (db, name, { views = false } = {}) =>
|
|
6
|
+
Boolean(db.prepare(`SELECT 1 FROM sqlite_master WHERE type${views ? " IN ('table','view')" : "='table'"} AND name=?`).get(name));
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* INSERT the [column, value] `pairs` into `table` (`orIgnore` spells OR IGNORE); returns the statement's
|
|
10
|
+
* run() result. Column naming and value encoding are the caller's — this is only the statement shape.
|
|
11
|
+
*/
|
|
12
|
+
export const insertPairs = (db, table, pairs, { orIgnore = false } = {}) =>
|
|
13
|
+
db.prepare(`INSERT ${orIgnore ? 'OR IGNORE ' : ''}INTO ${table}(${pairs.map((p) => p[0]).join(',')}) VALUES(${pairs.map(() => '?').join(',')})`)
|
|
14
|
+
.run(...pairs.map((p) => p[1]));
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The per-store `insertRow`: binds the caller's own [column, value] mapper (`cellsOf` — where the store's
|
|
18
|
+
* column validation and value encoding live) to an insertPairs statement.
|
|
19
|
+
*/
|
|
20
|
+
export const insertRowWith = (cellsOf) => (db, table, row, { orIgnore = false } = {}) =>
|
|
21
|
+
insertPairs(db, table, cellsOf(db, table, row), { orIgnore });
|