319 lines
16 KiB
JavaScript

/* node:coverage disable */
(function (root, factory) {
'use strict';
const foundation = typeof module === 'object' && module.exports ? require('../foundation.js') : root.HermesHuxFoundation;
const api = factory(foundation);
if (typeof module === 'object' && module.exports) module.exports = api;
else root.HermesHuxWaveAContract = api;
}(typeof globalThis === 'object' ? globalThis : this, function (foundation) {
/* node:coverage enable */
'use strict';
const ACCEPT = 'application/vnd.hermes.hux+json; version=1';
const ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
const USER = /^usr_[0-9a-f]{16,64}$/;
const SLOT = /^slot-[0-9]{1,3}$/;
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
const CARD_FLAGS = Object.freeze({
'HUX-11': 'hux.foundation',
'HUX-01': 'hux.activity_timeline',
'HUX-02': 'hux.memory_control',
'HUX-03': 'hux.projects',
'HUX-04': 'hux.artifacts',
'HUX-05': 'hux.autonomy',
'HUX-06': 'hux.friendly_modes',
'HUX-07': 'hux.multimodal',
'HUX-08': 'hux.research',
'HUX-09': 'hux.onboarding',
'HUX-10': 'hux.privacy',
'HUX-12': 'hux.release_followthrough',
});
const DEPENDENCIES = Object.freeze({
'hux.activity_timeline': ['hux.foundation'],
'hux.memory_control': ['hux.foundation', 'hux.privacy'],
'hux.projects': ['hux.foundation'],
'hux.artifacts': ['hux.foundation', 'hux.projects'],
'hux.autonomy': ['hux.foundation', 'hux.activity_timeline'],
'hux.friendly_modes': ['hux.foundation'],
'hux.multimodal': ['hux.foundation', 'hux.artifacts', 'hux.autonomy'],
'hux.research': ['hux.foundation', 'hux.friendly_modes'],
'hux.onboarding': ['hux.foundation', 'hux.projects', 'hux.friendly_modes'],
'hux.privacy': ['hux.foundation'],
'hux.foundation': [],
'hux.release_followthrough': [],
});
const EVENT_KINDS = new Set([
'message.user', 'message.assistant', 'decision.route', 'decision.plan', 'tool.call',
'tool.result', 'approval.requested', 'approval.resolved', 'memory.proposed',
'memory.committed', 'memory.forgotten', 'artifact.created', 'artifact.version',
'artifact.promoted', 'citation.attached', 'mode.changed', 'run.started', 'run.cancelled',
'run.completed', 'run.failed', 'privacy.notice', 'suggestion.shown',
'suggestion.dismissed', 'release.transition', 'delegation.started',
'delegation.completed', 'delegation.failed', 'memory.suppressed',
'memory.retrieval_removed', 'budget.exhausted', 'side_effect.blocked',
'side_effect.released',
]);
const EVIDENCE = new Set([
'message', 'tool_call', 'tool_result', 'artifact_version', 'source', 'passage',
'memory', 'approval', 'run', 'url', 'file', 'build', 'flux', 'pod',
]);
const SECRET_PATTERNS = [
[/\b(password|passwd|token|secret|api[-_ ]?key|authorization|cookie)\b\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]'],
[/\bbearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [redacted]'],
[/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/gi, '[redacted]'],
];
class WaveAContractError extends Error {
constructor(message) {
super(message);
this.name = 'WaveAContractError';
}
}
function immutable(value) {
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
Object.values(value).forEach(immutable);
return Object.freeze(value);
}
function record(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
}
function isId(value, prefix) {
return typeof value === 'string' && ID.test(value) && (!prefix || value.startsWith(`${prefix}_`));
}
function isUtc(value) {
return typeof value === 'string' && UTC.test(value) && !Number.isNaN(Date.parse(value));
}
function safeText(value, fallback, limit) {
if (typeof value !== 'string') return fallback;
let clean = value.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim();
SECRET_PATTERNS.forEach(([pattern, replacement]) => { clean = clean.replace(pattern, replacement); });
if (!clean) return fallback;
return clean.length <= limit ? clean : `${clean.slice(0, Math.max(0, limit - 1)).trimEnd()}`;
}
function normalizeExpectedIdentity(value) {
if (!record(value) || !SLOT.test(value.tenantSlot) || !USER.test(value.userRef) ||
!['chat', 'worker', 'telegram', 'voice', 'api'].includes(value.surface) ||
!['router', 'relay', 'worker'].includes(value.trust)) {
throw new WaveAContractError('Canonical HUX identity is invalid');
}
return immutable({tenantSlot: value.tenantSlot, userRef: value.userRef,
surface: value.surface, trust: value.trust});
}
function sameCanonicalIdentity(raw, expected) {
return Boolean(record(raw) && raw.tenant_slot === expected.tenantSlot &&
raw.subject === expected.userRef && raw.surface === expected.surface && raw.trust === expected.trust);
}
function safeBase(value) {
const base = String(value || '/hux/v1').replace(/\/$/, '');
if (!base.startsWith('/') || base.startsWith('//') || /[?#\\]/.test(base) || base.includes('..')) {
throw new TypeError('HUX base URL must be same-origin');
}
return base;
}
function safeEndpoint(base, path) {
const value = String(path || '');
if (!value.startsWith('/') || value.startsWith('//') || value.includes('..') ||
/[\\]|%(?:2e|2f|5c)/i.test(value)) throw new TypeError('HUX endpoint must be a scoped relative path');
return base + value;
}
function safeIdempotencyKey(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9._:-]{8,120}$/.test(value)) {
throw new TypeError('Canonical Idempotency-Key is invalid');
}
return value;
}
function normalizeCapabilities(payload, expected) {
if (!record(payload) || payload.schema !== 'hux.capabilities.v1' ||
!/^1\.\d+\.\d+$/.test(String(payload.contract_version)) ||
!sameCanonicalIdentity(payload.identity, expected) || !Array.isArray(payload.cards) ||
payload.cards.length > foundation.FLAGS.length * 2) {
throw new WaveAContractError('Canonical HUX capabilities could not be verified');
}
const enabled = new Set();
const seen = new Set();
payload.cards.forEach((card) => {
if (!record(card) || typeof card.card !== 'string' || seen.has(card.card) ||
typeof card.flag !== 'string' || typeof card.enabled !== 'boolean' || !Array.isArray(card.routes) ||
card.routes.some((path) => typeof path !== 'string' || !path.startsWith('/hux/v1/')) ||
(CARD_FLAGS[card.card] && CARD_FLAGS[card.card] !== card.flag)) {
throw new WaveAContractError('Canonical HUX capability card is invalid');
}
seen.add(card.card);
if (card.enabled && foundation.FLAGS.includes(card.flag)) enabled.add(card.flag);
});
function dependenciesEnabled(flag, seen) {
if (!enabled.has(flag) || seen.has(flag)) return enabled.has(flag);
return DEPENDENCIES[flag].every((dependency) => dependenciesEnabled(dependency, new Set(seen).add(flag)));
}
const resolved = [...enabled].filter((flag) => dependenciesEnabled(flag, new Set()));
if (!enabled.has('hux.foundation')) resolved.length = 0;
return immutable({schema: payload.schema, contractVersion: payload.contract_version,
identity: expected, flags: resolved});
}
function createCanonicalClient(options) {
const settings = options || {};
const fetcher = Object.prototype.hasOwnProperty.call(settings, 'fetcher') ? settings.fetcher :
(typeof fetch === 'function' ? fetch.bind(globalThis) : null);
if (!fetcher) throw new TypeError('A fetch implementation is required');
const identity = normalizeExpectedIdentity(settings.expectedIdentity);
const base = safeBase(settings.baseUrl);
let state = immutable({phase: 'disabled', capabilities: null, error: null});
let generation = 0;
const listeners = new Set();
function publish(next) {
state = immutable(next);
listeners.forEach((listener) => listener(state));
return state;
}
async function negotiate() {
const current = ++generation;
publish({phase: 'loading', capabilities: null, error: null});
try {
const response = await fetcher(`${base}/capabilities`, {cache: 'no-store',
credentials: 'same-origin', headers: {Accept: ACCEPT}});
if (current !== generation) return state;
if (response.status === 404) return publish({phase: 'disabled', capabilities: null, error: null});
if (!response.ok) throw new Error('capabilities unavailable');
const capabilities = normalizeCapabilities(await response.json(), identity);
return publish(capabilities.flags.includes('hux.foundation') ?
{phase: 'ready', capabilities, error: null} : {phase: 'disabled', capabilities: null, error: null});
} catch (error) {
if (current !== generation) return state;
return publish({phase: 'error', capabilities: null, error: error instanceof WaveAContractError ?
'Hermes workspace identity could not be verified.' : 'Hermes workspace features are temporarily unavailable.'});
}
}
function subscribe(listener) {
if (typeof listener !== 'function') throw new TypeError('Listener must be a function');
listeners.add(listener);
listener(state);
return () => listeners.delete(listener);
}
async function request(path, init) {
if (state.phase !== 'ready') throw new WaveAContractError('HUX capabilities are not ready');
const settings = init || {};
const headers = {Accept: ACCEPT, ...(settings.headers || {})};
const body = Object.prototype.hasOwnProperty.call(settings, 'body') ? JSON.stringify(settings.body) : undefined;
if (body !== undefined) headers['Content-Type'] = 'application/json';
const response = await fetcher(safeEndpoint(base, path), {cache: 'no-store', credentials: 'same-origin',
method: settings.method || 'GET', headers, ...(body === undefined ? {} : {body})});
if (!response.ok) {
const error = new WaveAContractError(response.status === 409 ?
'This item changed. Refresh before trying again.' : `Hermes workspace request failed (${response.status}).`);
error.status = response.status;
throw error;
}
return Object.freeze({body: await response.json(), etag: response.headers &&
typeof response.headers.get === 'function' ? response.headers.get('ETag') : null});
}
return Object.freeze({apiVersion: foundation.API_VERSION, identity, getState: () => state, negotiate,
subscribe, request, enabled: (flag) => state.phase === 'ready' && foundation.FLAGS.includes(flag) &&
state.capabilities.flags.includes(flag), endpoint: (path) => safeEndpoint(base, path)});
}
function assertRecordIdentity(raw, expected) {
if (!sameCanonicalIdentity(raw && raw.identity, expected)) {
throw new WaveAContractError('HUX record crossed its tenant or user boundary');
}
}
function normalizeEvent(raw, expected, conversationId) {
assertRecordIdentity(raw, expected);
if (!record(raw) || raw.schema !== 'hux.event.v1' || !isId(raw.id, 'evt') ||
!Number.isSafeInteger(raw.seq) || raw.seq < 0 || !isUtc(raw.ts) ||
raw.conversation_id !== conversationId || !EVENT_KINDS.has(raw.kind) ||
!['public', 'personal', 'sensitive', 'restricted'].includes(raw.sensitivity) ||
!record(raw.redaction) || !['none', 'partial', 'full'].includes(raw.redaction.level)) {
throw new WaveAContractError('Activity event is invalid or out of scope');
}
const hidden = raw.redaction.level === 'full';
const evidence = Array.isArray(raw.evidence) ? raw.evidence.slice(0, 64).map((item) => ({
kind: String(item && item.kind || ''), id: safeText(item && item.id, '', 200),
})).filter((item) => EVIDENCE.has(item.kind) && item.id) : [];
return immutable({id: raw.id, seq: raw.seq, timestamp: raw.ts, kind: raw.kind,
summary: hidden ? 'Details hidden by privacy controls.' : safeText(raw.summary, 'Activity details unavailable.', 280),
sensitivity: raw.sensitivity, redacted: hidden, evidence});
}
function normalizeEventPage(raw, expected, conversationId) {
if (!record(raw) || !Array.isArray(raw.items) || raw.items.length > 200 ||
!(raw.next === null || Number.isSafeInteger(raw.next))) throw new WaveAContractError('Activity page is invalid');
const items = raw.items.map((item) => normalizeEvent(item, expected, conversationId));
if (items.some((item, index) => index > 0 && item.seq <= items[index - 1].seq)) {
throw new WaveAContractError('Activity sequence is not monotonic');
}
return immutable({items, next: raw.next});
}
function normalizeScope(raw, conversationId) {
if (!record(raw) || !['global', 'project', 'conversation'].includes(raw.level) ||
(raw.level !== 'global' && !isId(raw.scope_id)) ||
(raw.level === 'conversation' && raw.scope_id !== conversationId)) {
throw new WaveAContractError('Memory scope crossed this conversation');
}
return raw.level === 'global' ? immutable({level: 'global', scopeId: null}) :
immutable({level: raw.level, scopeId: raw.scope_id});
}
function normalizeMemory(raw, expected, conversationId) {
assertRecordIdentity(raw, expected);
if (!record(raw) || raw.schema !== 'hux.memory.v1' || !isId(raw.id, 'mem') || raw.owner !== expected.userRef ||
!Number.isSafeInteger(raw.revision) || raw.revision < 1 ||
!['preference', 'fact', 'instruction', 'context'].includes(raw.kind) ||
!['proposed', 'active', 'rejected', 'expired', 'forgotten', 'no_store'].includes(raw.status) ||
!['automatic', 'ask', 'no_store'].includes(raw.approval_mode) ||
!['public', 'personal', 'sensitive', 'restricted'].includes(raw.sensitivity) ||
typeof raw.retrievable !== 'boolean' || !isUtc(raw.created_at) || !isUtc(raw.updated_at) ||
raw.updated_at < raw.created_at || typeof raw.content !== 'string' || raw.content.length > 2000 ||
typeof raw.reason !== 'string' || !raw.reason || raw.reason.length > 280) {
throw new WaveAContractError('Memory record is invalid or out of scope');
}
const scope = normalizeScope(raw.scope, conversationId);
const hidden = ['rejected', 'expired', 'forgotten', 'no_store'].includes(raw.status) || raw.sensitivity === 'restricted';
if (hidden && raw.content) throw new WaveAContractError('Terminal memory retained forbidden content');
const ttl = record(raw.ttl);
if (!ttl || !['never', 'expires_at', 'decay'].includes(ttl.policy) ||
(ttl.policy === 'expires_at' && !isUtc(ttl.expires_at)) ||
(ttl.policy === 'decay' && (!Number.isSafeInteger(ttl.decay_days) || ttl.decay_days < 1 || ttl.decay_days > 3650))) {
throw new WaveAContractError('Memory retention policy is invalid');
}
return immutable({id: raw.id, revision: raw.revision, kind: raw.kind, status: raw.status,
approvalMode: raw.approval_mode, sensitivity: raw.sensitivity, scope,
content: hidden ? 'Content removed by privacy controls.' : safeText(raw.content, 'Content unavailable.', 2000),
reason: safeText(raw.reason, 'No reason supplied.', 280), retrievable: raw.retrievable,
createdAt: raw.created_at, updatedAt: raw.updated_at,
retention: ttl.policy === 'decay' ? `decays after ${ttl.decay_days} days` :
ttl.policy === 'expires_at' ? `expires ${ttl.expires_at}` : 'does not expire'});
}
function normalizeMemoryPage(raw, expected, conversationId) {
if (!record(raw) || !Array.isArray(raw.items) || raw.items.length > 500 || raw.next !== null) {
throw new WaveAContractError('Memory page is invalid');
}
const items = raw.items.map((item) => normalizeMemory(item, expected, conversationId));
if (new Set(items.map((item) => item.id)).size !== items.length) throw new WaveAContractError('Memory page has duplicate entries');
return immutable({items});
}
return Object.freeze({ACCEPT, WaveAContractError, createCanonicalClient, isId,
normalizeCapabilities, normalizeEventPage, normalizeExpectedIdentity, normalizeMemoryPage,
safeEndpoint, safeIdempotencyKey, safeText, sameCanonicalIdentity});
}));