319 lines
16 KiB
JavaScript
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});
|
|
}));
|