103 lines
3.4 KiB
TypeScript
103 lines
3.4 KiB
TypeScript
/**
|
|
* Deployment-time rollout controls. These are deliberately separate from the
|
|
* domain capability contracts: a capability may have a complete contract and
|
|
* still remain disabled in a particular release.
|
|
*
|
|
* The allowlist is evaluated on the server for every entry point. Unknown
|
|
* names are rejected instead of becoming an accidental opt-in, and newly
|
|
* added known capabilities are absent from the baseline allowlist until an
|
|
* operator explicitly enables them.
|
|
*/
|
|
export const RELEASE_CAPABILITIES = [
|
|
'asset_search',
|
|
'asset_valuation',
|
|
'asset_sync',
|
|
'assistant_dispatch',
|
|
'session_token',
|
|
'subscriptions',
|
|
'wallet_balance',
|
|
'inventory_export',
|
|
'manufacturing_opportunities',
|
|
] as const;
|
|
|
|
export type ReleaseCapability = (typeof RELEASE_CAPABILITIES)[number];
|
|
|
|
/** Existing first-slice behavior remains enabled unless an operator narrows it. */
|
|
export const BASELINE_RELEASE_CAPABILITIES: readonly ReleaseCapability[] = [
|
|
'asset_search',
|
|
'asset_valuation',
|
|
'asset_sync',
|
|
'assistant_dispatch',
|
|
'session_token',
|
|
];
|
|
|
|
const known = new Set<string>(RELEASE_CAPABILITIES);
|
|
|
|
export class CapabilityConfigurationError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'CapabilityConfigurationError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse APP_CAPABILITY_ALLOWLIST without accepting typos or duplicate policy
|
|
* entries. An explicitly empty value disables every capability; an omitted
|
|
* value preserves the existing private asset/sign-in baseline.
|
|
*/
|
|
export function parseCapabilityAllowlist(raw: string | undefined): readonly ReleaseCapability[] {
|
|
if (raw === undefined) return [...BASELINE_RELEASE_CAPABILITIES];
|
|
const value = raw.trim();
|
|
if (value === '') return [];
|
|
|
|
const result: ReleaseCapability[] = [];
|
|
const seen = new Set<string>();
|
|
for (const token of value.split(',')) {
|
|
const name = token.trim();
|
|
if (!name || !known.has(name)) {
|
|
throw new CapabilityConfigurationError(`Unknown deployment capability: ${name || '<empty>'}`);
|
|
}
|
|
if (!seen.has(name)) {
|
|
seen.add(name);
|
|
result.push(name as ReleaseCapability);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function capabilityEnabled(
|
|
capability: ReleaseCapability,
|
|
allowlist: readonly ReleaseCapability[],
|
|
): boolean {
|
|
return allowlist.includes(capability);
|
|
}
|
|
|
|
export function capabilityDisabledReason(capability: ReleaseCapability): string {
|
|
return `The ${capability} capability is disabled by deployment configuration.`;
|
|
}
|
|
|
|
/** A non-throwing check is useful at HTTP boundaries that should fail closed. */
|
|
export function requireCapability(
|
|
capability: ReleaseCapability,
|
|
allowlist: readonly ReleaseCapability[],
|
|
): void {
|
|
if (!capabilityEnabled(capability, allowlist)) {
|
|
throw new CapabilityConfigurationError(capabilityDisabledReason(capability));
|
|
}
|
|
}
|
|
|
|
/** Release identifiers are metadata, not a source of authorization. */
|
|
export function validReleaseId(value: string): boolean {
|
|
return /^(?!latest$)[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
|
|
}
|
|
|
|
/**
|
|
* Production images must carry a non-placeholder immutable identifier. Local
|
|
* development and fixture runs intentionally use `local`.
|
|
*/
|
|
export function releaseIdIsImmutable(value: string, environment = process.env.NODE_ENV): boolean {
|
|
if (!validReleaseId(value)) return false;
|
|
if (environment !== 'production') return true;
|
|
return value !== 'local' && value !== 'dev' && value !== 'development' && value !== 'unreleased';
|
|
}
|