agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
155 lines • 6.57 kB
JavaScript
/**
* strategies/config — the identity config object, its boot refusal, and the
* mapping from environment variables onto it.
*
* One prefix, `IDENTITY_`. The library reads a plain object
* ({@link IdentityConfig}); {@link identityConfigFromEnv} is the one mapping
* from `process.env`-shaped input onto it, so an app that keeps its settings
* elsewhere builds the object itself and loses nothing.
*/
import { IDENTITY_STRATEGIES, KEYS_IN_A_LATER_RELEASE, KEYS_IN_THIS_RELEASE, } from './vocabulary.js';
/**
* The deployment's identity configuration cannot be started. Raised at BOOT,
* never per request: a wrong or missing setting must stop the install, not
* turn into a door that is quietly open or permanently 503.
*
* Names the key, says why, says what to set. Never prints a secret or a claim
* value — only names and URLs the operator wrote.
*/
export class IdentityConfigError extends Error {
code = 'ERR_IDENTITY_CONFIG';
/** The env name of the key at fault, when one key is. */
key;
constructor(sentence, key) {
super(`[identity] ${sentence}`);
this.name = 'IdentityConfigError';
if (key !== undefined)
this.key = key;
}
}
/**
* Map `IDENTITY_*` environment variables onto an {@link IdentityConfig}.
*
* An empty value counts as unset (an env file's `KEY=`). Values are trimmed —
* they are configuration, not ids. Refused here, by name:
*
* - a lower-case `identity_*` name (a likely typo that would otherwise be ignored);
* - a key a LATER release reads (`IDENTITY_LDAP_URL`, `IDENTITY_CLIENT_ID`, …)
* — "not in this release", never silently ignored;
* - any other `IDENTITY_*` name — a typo must not become a default;
* - a strategy name that is not one of the five;
* - a value that does not parse (`IDENTITY_CLOCK_TOLERANCE_SECONDS=soon`).
*
* Skipped, never read or printed: the {@link FOREIGN_PLATFORM_KEYS} a hosting
* platform injects (`IDENTITY_ENDPOINT`, `IDENTITY_HEADER`, …).
*
* @example
* const choice = await identityFromConfig(identityConfigFromEnv(process.env), {
* production: process.env.NODE_ENV === 'production',
* });
*/
export function identityConfigFromEnv(env) {
const config = {};
for (const name of Object.keys(env).sort()) {
if (!name.toUpperCase().startsWith('IDENTITY_'))
continue;
// Another platform's own variables, never read and never printed.
if (FOREIGN_PLATFORM_KEYS.includes(name))
continue;
if (!name.startsWith('IDENTITY_')) {
throw new IdentityConfigError(`${name} looks like an identity setting in the wrong case. Settings are upper-case ` +
`(${name.toUpperCase()}); a lower-case one would be silently ignored, so it is refused.`, name);
}
const raw = env[name];
if (raw === undefined || raw.trim().length === 0)
continue;
const key = KEYS_IN_THIS_RELEASE.find((k) => k.env === name);
if (key === undefined)
refuseUnread(name);
else
config[key.field] = readValue(key, raw.trim());
}
return config;
}
/**
* `IDENTITY_*` names another platform injects into every process it hosts, and
* which therefore are not this library's settings: Azure App Service, Functions
* and Container Apps set `IDENTITY_ENDPOINT` and `IDENTITY_HEADER` (the managed
* identity endpoint and its secret header) and may set `IDENTITY_API_VERSION`;
* Service Fabric sets `IDENTITY_SERVER_THUMBPRINT`. They are skipped — never
* read, never printed in a refusal or a banner — so
* `identityConfigFromEnv(process.env)` works on those hosts.
*/
export const FOREIGN_PLATFORM_KEYS = [
'IDENTITY_ENDPOINT',
'IDENTITY_HEADER',
'IDENTITY_API_VERSION',
'IDENTITY_SERVER_THUMBPRINT',
];
function refuseUnread(name) {
const later = KEYS_IN_A_LATER_RELEASE[name];
if (later !== undefined) {
throw new IdentityConfigError(`${name} belongs to ${later}, which is not in this release. Remove it: a setting that ` +
`does nothing would let this deployment believe it is doing something.`, name);
}
throw new IdentityConfigError(`${name} is not a setting this release reads — a misspelling must not become a default. ` +
`The settings are: ${KEYS_IN_THIS_RELEASE.map((k) => k.env).join(', ')}.`, name);
}
function readValue(key, value) {
switch (key.reading) {
case 'text':
return value;
case 'strategy':
return readStrategy(value);
case 'clients':
return readClients(value);
case 'list':
return value.split(/[\s,]+/).filter((v) => v.length > 0);
case 'claim-path':
return readClaimPath(value);
case 'whole-number':
return readWholeNumber(key.env, value);
}
}
/** Refuse anything but the five names, listing them. */
export function readStrategy(value) {
if (typeof value === 'string' && IDENTITY_STRATEGIES.includes(value)) {
return value;
}
throw new IdentityConfigError(`IDENTITY_STRATEGY is ${JSON.stringify(value)}, which is not a strategy. It is one of: ` +
`${IDENTITY_STRATEGIES.join(', ')}. A typo must not become 'open'.`, 'IDENTITY_STRATEGY');
}
function readClients(value) {
const clients = value.split(/[\s,]+/).filter((c) => c.length > 0);
if (clients.includes('any')) {
if (clients.length === 1)
return 'any';
throw new IdentityConfigError(`IDENTITY_ALLOWED_CLIENTS lists 'any' beside client ids. Say 'any' alone (the client ` +
`check is off) or list the clients.`, 'IDENTITY_ALLOWED_CLIENTS');
}
return clients;
}
function readClaimPath(value) {
if (!value.startsWith('['))
return value;
let parsed;
try {
parsed = JSON.parse(value);
}
catch {
parsed = undefined;
}
if (Array.isArray(parsed) &&
parsed.length > 0 &&
parsed.every((p) => typeof p === 'string' && p.length > 0)) {
return parsed;
}
throw new IdentityConfigError(`IDENTITY_ROLES_CLAIM starts with '[' but is not a JSON array of claim names. Write a ` +
`nested claim as a path, e.g. ["realm_access","roles"]; a plain name is written bare.`, 'IDENTITY_ROLES_CLAIM');
}
function readWholeNumber(name, value) {
if (/^\d{1,6}$/.test(value))
return Number(value);
throw new IdentityConfigError(`${name} is a whole number (got '${value}').`, name);
}
//# sourceMappingURL=config.js.map