Review "changes 8" fix round for org-redact6. - launcher: a harness module's throw names its system code or class, never its message; preflight, loader, bundle, checkout, sandbox and route text is relayed with host paths replaced (relay()). - claude-code and pi preflights name MOSAIC_CLAUDE_BIN and the repository's node_modules, never a path; persona and skill bundle errors and sandboxProblem name their file or the sandbox by label. - seat session: thread store, legacy store, overlap, busy lock and newRun refusals name the place under the data root. - discord: unknown keys, binding names and turn ids are described when not plain; approvals, journal and repository read errors name the file and code; git drops stderr lines that carry a host path and a spawn failure names its code only. - seat, control-board: registration fields and seen keys are described when not plain; invalid seat name and layout state the constraint. - organization: the registry names the config directory and registry file by label; --project is quoted only when it is a plain name; systemConfigProblem moves to its own module. - scripts/mosaic-config.mjs: the config by where it came from, the data root and its marker by role, keys and arguments only when plain, and error codes in place of raw messages. Exit codes unchanged. - webui sso-config: an unknown console key is named only when plain. Sentinel tests in each package's path-redaction-labels file fail on BASE; pins that quoted a path now assert the label and its absence.
77 lines
4.9 KiB
JavaScript
77 lines
4.9 KiB
JavaScript
// Q175 phase 1 (#1690): the shape of config.json's optional `console` key. The one loader,
|
|
// scripts/mosaic-config.mjs, runs this on every read, so a bad key refuses the config for every
|
|
// tool (design section 4, C1). The Console's --sso start runs it again before it uses the value.
|
|
// Shape only: nothing here touches the network or a slot file. A refusal names the key path and
|
|
// never quotes a value, so a refusal can't print a client id or a subject.
|
|
|
|
// The asymmetric algorithms oauth4webapi 3.8.8 verifies (its supported(), build/index.js). No HS*
|
|
// and no none, so a client secret can never be a signing key.
|
|
export const ALGS = Object.freeze(['PS256', 'ES256', 'RS256', 'PS384', 'ES384', 'RS384', 'PS512', 'ES512', 'RS512', 'Ed25519', 'EdDSA', 'ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87']);
|
|
const PROVIDER_ID = /^[a-z0-9-]{1,32}$/;
|
|
const SLOT = /^[a-z0-9-]{1,64}$/;
|
|
const SUB = /^[\x21-\x7e]{1,255}$/;
|
|
const CLIENT_ID = /^[\x21-\x7e]{1,255}$/;
|
|
// The name goes where the preview's --allow-user went (serve.mjs USER): visible ASCII, no comma.
|
|
const NAME = /^[\x21-\x2b\x2d-\x7e]{1,64}$/;
|
|
// The bus keeps these for the tester and the human role (section 6), so no subject can sign in as one.
|
|
const RESERVED_NAMES = ['tester', 'human'];
|
|
|
|
const object = v => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
function refuse(where, why) {
|
|
throw new Error(`${where} ${why}`);
|
|
}
|
|
function keys(value, where, allowed, required = allowed) {
|
|
if (!object(value)) refuse(where, 'must be a JSON object');
|
|
// #1873: an unknown key is named only when it is a plain name; a key can be anything, a host path too.
|
|
for (const key of Object.keys(value)) if (!allowed.includes(key)) refuse(/^[A-Za-z_][A-Za-z0-9_-]{0,63}$/.test(key) ? `${where}.${key}` : where, /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/.test(key) ? 'is not a supported key' : 'has a key that is not a plain name');
|
|
for (const key of required) if (!Object.hasOwn(value, key)) refuse(`${where}.${key}`, 'is required');
|
|
}
|
|
function httpsURL(value, where) {
|
|
let url;
|
|
if (typeof value !== 'string' || value.length > 512) refuse(where, 'must be an https URL of at most 512 characters');
|
|
try { url = new URL(value); } catch { refuse(where, 'must be an https URL'); }
|
|
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || value.includes('?') || value.includes('#')) {
|
|
refuse(where, 'must be https with no credentials, query or fragment');
|
|
}
|
|
return url;
|
|
}
|
|
|
|
// Returns the value unchanged when it passes; throws an Error naming the bad key path otherwise.
|
|
export function checkConsole(value, where = 'console') {
|
|
keys(value, where, ['sso']);
|
|
const sso = value.sso, at = `${where}.sso`;
|
|
keys(sso, at, ['publicOrigin', 'providers']);
|
|
const origin = httpsURL(sso.publicOrigin, `${at}.publicOrigin`);
|
|
if (origin.origin !== sso.publicOrigin) refuse(`${at}.publicOrigin`, 'must be an https origin with no path, query, fragment or trailing slash');
|
|
if (!Array.isArray(sso.providers) || sso.providers.length === 0 || sso.providers.length > 8) refuse(`${at}.providers`, 'must be a list of 1 to 8 providers');
|
|
const ids = new Set();
|
|
sso.providers.forEach((p, i) => {
|
|
const pat = `${at}.providers[${i}]`;
|
|
keys(p, pat, ['id', 'issuer', 'clientId', 'clientSecret', 'algs', 'subjects']);
|
|
if (typeof p.id !== 'string' || !PROVIDER_ID.test(p.id)) refuse(`${pat}.id`, 'must match [a-z0-9-]{1,32}');
|
|
if (ids.has(p.id)) refuse(`${pat}.id`, 'repeats an earlier provider id');
|
|
ids.add(p.id);
|
|
httpsURL(p.issuer, `${pat}.issuer`);
|
|
if (typeof p.clientId !== 'string' || !CLIENT_ID.test(p.clientId)) refuse(`${pat}.clientId`, 'must be 1 to 255 visible characters');
|
|
keys(p.clientSecret, `${pat}.clientSecret`, ['slot']);
|
|
if (typeof p.clientSecret.slot !== 'string' || !SLOT.test(p.clientSecret.slot)) refuse(`${pat}.clientSecret.slot`, 'must match [a-z0-9-]{1,64}');
|
|
if (!Array.isArray(p.algs) || p.algs.length === 0) refuse(`${pat}.algs`, 'must be a non-empty list');
|
|
p.algs.forEach((alg, j) => {
|
|
if (!ALGS.includes(alg)) refuse(`${pat}.algs[${j}]`, `must be one of ${ALGS.join(', ')}; HS* and none are refused`);
|
|
});
|
|
if (new Set(p.algs).size !== p.algs.length) refuse(`${pat}.algs`, 'repeats an algorithm');
|
|
if (!Array.isArray(p.subjects) || p.subjects.length === 0 || p.subjects.length > 64) refuse(`${pat}.subjects`, 'must be a list of 1 to 64 subjects');
|
|
const subs = new Set();
|
|
p.subjects.forEach((s, j) => {
|
|
const sat = `${pat}.subjects[${j}]`;
|
|
keys(s, sat, ['sub', 'name']);
|
|
if (typeof s.sub !== 'string' || !SUB.test(s.sub)) refuse(`${sat}.sub`, 'must be 1 to 255 visible characters');
|
|
if (subs.has(s.sub)) refuse(`${sat}.sub`, 'repeats an earlier subject');
|
|
subs.add(s.sub);
|
|
if (typeof s.name !== 'string' || !NAME.test(s.name)) refuse(`${sat}.name`, 'must be 1 to 64 visible characters');
|
|
if (RESERVED_NAMES.includes(s.name.toLowerCase())) refuse(`${sat}.name`, `must not be ${RESERVED_NAMES.join(' or ')}`);
|
|
});
|
|
});
|
|
return value;
|
|
}
|