397 lines
22 KiB
JavaScript
397 lines
22 KiB
JavaScript
/* node:coverage disable */
|
|
(function (root, factory) {
|
|
'use strict';
|
|
const common = typeof module === 'object' && module.exports;
|
|
const foundation = common ? require('../foundation.js') : root.HermesHuxFoundation;
|
|
const shellApi = common ? require('../shell.js') : root.HermesHuxShell;
|
|
const contract = common ? require('./wave_a_contract.js') : root.HermesHuxWaveAContract;
|
|
const api = factory(foundation, shellApi, contract);
|
|
if (common) module.exports = api;
|
|
else root.HermesHuxWaveC = api;
|
|
}(typeof globalThis === 'object' ? globalThis : this, function (foundation, shellApi, contract) {
|
|
/* node:coverage enable */
|
|
'use strict';
|
|
|
|
const CARD_SPECS = Object.freeze({
|
|
multimodal: Object.freeze({card: 'HUX-07', flag: 'hux.multimodal',
|
|
dependencies: ['hux.foundation', 'hux.projects', 'hux.artifacts', 'hux.autonomy'],
|
|
routes: ['/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items',
|
|
'/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items/{item_id}',
|
|
'/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items/{item_id}/transcript-corrections',
|
|
'/hux/v1/projects/{project_id}/conversations/{id}/capture-intents']}),
|
|
onboarding: Object.freeze({card: 'HUX-09', flag: 'hux.onboarding',
|
|
dependencies: ['hux.foundation', 'hux.projects', 'hux.friendly_modes'],
|
|
routes: ['/hux/v1/projects/{project_id}/conversations/{id}/suggestions/evaluate',
|
|
'/hux/v1/projects/{project_id}/conversations/{id}/suggestions/{suggestion_id}/decisions',
|
|
'/hux/v1/projects/{project_id}/conversations/{id}/suggestions/states']}),
|
|
release: Object.freeze({card: 'HUX-12', flag: 'hux.release_followthrough',
|
|
dependencies: ['hux.foundation', 'hux.projects'],
|
|
routes: ['/hux/v1/projects/{project_id}/conversations/{id}/releases',
|
|
'/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}',
|
|
'/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}/transitions']}),
|
|
});
|
|
const WORKLOADS = new Set(['hermes-chat-router', 'hermes-chat-tenant', 'hermes-webui',
|
|
'hermes-agent', 'hermes-switchyard']);
|
|
const CONTEXTS = new Set(['first_session', 'empty_project', 'after_artifact',
|
|
'after_research', 'after_approval', 'idle']);
|
|
const ACTIONS = new Set(['open_mode', 'create_project', 'open_memory', 'open_artifacts',
|
|
'start_workflow', 'none']);
|
|
const SHA = /^sha256:[0-9a-f]{64}$/;
|
|
const COMMIT = /^[0-9a-f]{40}$/;
|
|
const FLUX = /^main@sha1:[0-9a-f]{40}$/;
|
|
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
|
|
|
|
class WaveCContractError extends Error {
|
|
constructor(message) { super(message); this.name = 'WaveCContractError'; }
|
|
}
|
|
|
|
function record(value) {
|
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
}
|
|
|
|
function exact(value, required, optional) {
|
|
const allowed = new Set(required.concat(optional || []));
|
|
return Boolean(record(value) && required.every((key) => Object.hasOwn(value, key)) &&
|
|
Object.keys(value).every((key) => allowed.has(key)));
|
|
}
|
|
|
|
function isUtc(value) {
|
|
return typeof value === 'string' && UTC.test(value) && !Number.isNaN(Date.parse(value));
|
|
}
|
|
|
|
function immutable(value) {
|
|
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
|
|
Object.values(value).forEach(immutable);
|
|
return Object.freeze(value);
|
|
}
|
|
|
|
function element(doc, tag, attributes, text) {
|
|
const node = doc.createElement(tag);
|
|
Object.entries(attributes || {}).forEach(([name, value]) => node.setAttribute(name, value));
|
|
if (text !== undefined) node.textContent = String(text);
|
|
return node;
|
|
}
|
|
|
|
function sameIdentity(raw, expected) {
|
|
return contract.sameCanonicalIdentity(raw, expected);
|
|
}
|
|
|
|
function normalizeCapabilityCards(payload, expected) {
|
|
contract.normalizeCapabilities(payload, expected);
|
|
const cards = {};
|
|
Object.entries(CARD_SPECS).forEach(([name, spec]) => {
|
|
const raw = payload.cards.find((item) => item.card === spec.card);
|
|
if (!raw || raw.flag !== spec.flag || typeof raw.enabled !== 'boolean') {
|
|
throw new WaveCContractError(`${spec.card} capability is invalid`);
|
|
}
|
|
cards[name] = immutable({enabled: raw.enabled, routes: [...raw.routes]});
|
|
});
|
|
return immutable(cards);
|
|
}
|
|
|
|
function capabilityGap(client, cards, name) {
|
|
const spec = CARD_SPECS[name];
|
|
const card = cards[name];
|
|
if (!card || !card.enabled || !client.enabled(spec.flag) ||
|
|
spec.dependencies.some((flag) => !client.enabled(flag))) return 'feature flag or dependency is off';
|
|
const missing = spec.routes.filter((route) => !card.routes.includes(route));
|
|
return missing.length ? 'server route is not available' : null;
|
|
}
|
|
|
|
function gapView(doc, panel, title) {
|
|
const section = element(doc, 'section', {'class': 'hux-wave-c hux-wave-c--gap',
|
|
'role': 'status', 'aria-live': 'polite'});
|
|
section.appendChild(element(doc, 'h3', {}, title));
|
|
section.appendChild(element(doc, 'strong', {}, 'Not available from this Hermes server'));
|
|
section.appendChild(element(doc, 'p', {},
|
|
'The required capability route has not been advertised. Nothing was enabled or changed.'));
|
|
panel.replaceChildren(section);
|
|
}
|
|
|
|
function normalizeMediaPage(raw, expected, projectId, conversationId) {
|
|
if (!exact(raw, ['items', 'next']) || raw.next !== null ||
|
|
!Array.isArray(raw.items) || raw.items.length > 200) {
|
|
throw new WaveCContractError('Multimodal page crossed its scope');
|
|
}
|
|
const media = raw.items.map((item) => {
|
|
if (!exact(item, ['schema', 'id', 'owner', 'project_id', 'conversation_id', 'kind', 'source',
|
|
'filename', 'mime', 'bytes', 'hash', 'approval_id', 'status', 'created_at', 'revision'],
|
|
['lineage', 'latest_correction_id']) || item.schema !== 'hux.multimodal_item.v1' ||
|
|
!contract.isId(item.id, 'mmi') ||
|
|
item.owner !== expected.userRef || item.project_id !== projectId ||
|
|
item.conversation_id !== conversationId || !['image', 'document', 'audio', 'video'].includes(item.kind) ||
|
|
!Number.isSafeInteger(item.bytes) || item.bytes < 1 || item.status !== 'metadata_only' ||
|
|
!Number.isSafeInteger(item.revision) || item.revision < 1 || !isUtc(item.created_at)) {
|
|
throw new WaveCContractError('Multimodal item is invalid');
|
|
}
|
|
const fileName = contract.safeText(item.filename, '', 160);
|
|
if (!fileName || /[/\\]/.test(fileName)) throw new WaveCContractError('Media name is invalid');
|
|
return immutable({id: item.id, kind: item.kind, fileName, bytes: item.bytes});
|
|
});
|
|
return immutable({media});
|
|
}
|
|
|
|
function normalizeTrigger(raw, expectedSurface) {
|
|
if (!record(raw) || Object.keys(raw).length !== 2 || raw.surface !== expectedSurface ||
|
|
!CONTEXTS.has(raw.context)) throw new TypeError('An exact contextual trigger is required');
|
|
return immutable({surface: raw.surface, context: raw.context});
|
|
}
|
|
|
|
function normalizeEvaluation(raw, expected, _sessionId, _conversationId, trigger, privacyEnabled) {
|
|
if (!record(raw) || raw.stored === false) {
|
|
if (exact(raw, ['suggestion', 'reason', 'stored']) && raw.suggestion === null &&
|
|
typeof raw.reason === 'string' && raw.reason.length <= 80) return null;
|
|
throw new WaveCContractError('Suggestion evaluation is invalid');
|
|
}
|
|
if (!exact(raw, ['suggestion', 'state', 'revision', 'stored']) || raw.stored !== true ||
|
|
!Number.isSafeInteger(raw.revision) || raw.revision < 1) throw new WaveCContractError('Suggestion is invalid');
|
|
const item = raw.suggestion;
|
|
const state = raw.state;
|
|
if (!record(item) || item.schema !== 'hux.suggestion.v1' || !contract.isId(item.id, 'sug') ||
|
|
!record(item.trigger) || item.trigger.surface !== trigger.surface || item.trigger.context !== trigger.context ||
|
|
!ACTIONS.has(item.action && item.action.type) || !record(item.suppression) ||
|
|
item.suppression.dismissable !== true || item.suppression.never_again_supported !== true ||
|
|
!Number.isSafeInteger(item.suppression.max_shows) || item.suppression.max_shows < 1 ||
|
|
!record(state) || state.schema !== 'hux.suggestion_state.v1' || state.owner !== expected.userRef ||
|
|
state.suggestion_id !== item.id || !Number.isSafeInteger(state.shows) || state.shows < 1 ||
|
|
state.shows > item.suppression.max_shows || state.never_again !== false) {
|
|
throw new WaveCContractError('Suggestion is invalid or suppressed');
|
|
}
|
|
const title = contract.safeText(item.title, '', 80);
|
|
const body = contract.safeText(item.body, '', 280);
|
|
if (!title || !body || (item.action.type === 'open_memory' && !privacyEnabled)) {
|
|
throw new WaveCContractError('Suggestion is not eligible');
|
|
}
|
|
return immutable({suggestionId: item.id, title, body, action: item.action.type,
|
|
shows: state.shows, revision: raw.revision, trigger});
|
|
}
|
|
|
|
function releaseEvidence(raw) {
|
|
if (!record(raw) || raw.schema !== 'hux.release.v1' || !contract.isId(raw.id, 'rel') ||
|
|
!WORKLOADS.has(raw.workload) || !COMMIT.test(String(raw.commit)) ||
|
|
!record(raw.evidence) || !Array.isArray(raw.transitions)) return null;
|
|
if (raw.state !== 'live_verified') return immutable({id: raw.id, workload: raw.workload,
|
|
commit: raw.commit, live: false});
|
|
const evidence = raw.evidence;
|
|
const health = evidence.health_check;
|
|
const digestsMatch = SHA.test(String(evidence.image_digest)) &&
|
|
evidence.image_digest === evidence.harbor_digest && evidence.image_digest === evidence.pod_digest;
|
|
const final = raw.transitions[raw.transitions.length - 1];
|
|
if (!digestsMatch || !FLUX.test(String(evidence.flux_revision)) || !record(health) ||
|
|
health.status !== 'pass' || !isUtc(health.at) || !record(final) ||
|
|
final.from !== 'converged' || final.to !== 'live_verified' || !isUtc(final.at)) return null;
|
|
return immutable({id: raw.id, workload: raw.workload, commit: raw.commit, live: true,
|
|
verifiedAt: health.at, digest: evidence.image_digest});
|
|
}
|
|
|
|
function normalizeReleasePage(raw, _expected, projectId, conversationId) {
|
|
if (!exact(raw, ['items', 'next']) || raw.next !== null || !Array.isArray(raw.items) || raw.items.length > 100) {
|
|
throw new WaveCContractError('Release evidence crossed its scope');
|
|
}
|
|
const items = raw.items.map((view) => exact(view, ['release', 'scope', 'revision', 'ledger_hash']) &&
|
|
exact(view.scope, ['project_id', 'conversation_id']) && view.scope.project_id === projectId &&
|
|
view.scope.conversation_id === conversationId && Number.isSafeInteger(view.revision) && view.revision >= 1 &&
|
|
SHA.test(String(view.ledger_hash)) ? releaseEvidence(view.release) : null);
|
|
return immutable({items: items.filter(Boolean), withheld: items.filter((item) => item === null).length});
|
|
}
|
|
|
|
function announce(doc, panel, message, alert) {
|
|
panel.replaceChildren(element(doc, 'p', {'class': alert ? 'hux-wave-c__error' : 'hux-wave-c__status',
|
|
'role': alert ? 'alert' : 'status', 'aria-live': alert ? 'assertive' : 'polite'}, message));
|
|
}
|
|
|
|
function multimodalExtension(options) {
|
|
return Object.freeze({id: 'wave-c-multimodal', flag: 'hux.multimodal', label: 'Media', order: 70,
|
|
render(context) {
|
|
const panel = context.container;
|
|
if (capabilityGap(context.client, options.cards(), 'multimodal')) {
|
|
gapView(options.document, panel, 'Multimodal workspace'); return;
|
|
}
|
|
announce(options.document, panel, 'Loading conversation media…', false);
|
|
context.client.request(`/projects/${encodeURIComponent(options.projectId)}/conversations/` +
|
|
`${encodeURIComponent(options.conversationId)}/multimodal/items`).then((response) => {
|
|
const page = normalizeMediaPage(response.body, context.client.identity,
|
|
options.projectId, options.conversationId);
|
|
const section = element(options.document, 'section', {'class': 'hux-wave-c',
|
|
'aria-labelledby': 'hux-wave-c-media-title'});
|
|
section.appendChild(element(options.document, 'h3', {'id': 'hux-wave-c-media-title'},
|
|
'Multimodal workspace'));
|
|
section.appendChild(element(options.document, 'p', {},
|
|
`${page.media.length} scoped metadata items; binary content remains outside HUX.`));
|
|
const list = element(options.document, 'ul', {'class': 'hux-wave-c__list'});
|
|
page.media.forEach((item) => list.appendChild(element(options.document, 'li', {},
|
|
`${item.kind}: ${item.fileName} · ${Math.ceil(item.bytes / 1024)} KB`)));
|
|
section.appendChild(list);
|
|
section.appendChild(element(options.document, 'p', {'class': 'hux-wave-c__note'},
|
|
'Capture and upload remain approval-gated; this view never receives media bytes.'));
|
|
panel.replaceChildren(section);
|
|
}).catch(() => announce(options.document, panel,
|
|
'Conversation media could not be verified. Nothing was shown.', true));
|
|
}});
|
|
}
|
|
|
|
function actionLabel(action) {
|
|
return {open_mode: 'Choose a mode', create_project: 'Add to a project',
|
|
open_memory: 'Review memory', open_artifacts: 'View artifacts',
|
|
start_workflow: 'Use suggestion'}[action] || null;
|
|
}
|
|
|
|
function onboardingExtension(options) {
|
|
let panel = null;
|
|
let client = null;
|
|
let active = null;
|
|
const decided = new Set();
|
|
|
|
async function decide(choice) {
|
|
const evaluation = active;
|
|
if (!evaluation || decided.has(evaluation.suggestionId)) return;
|
|
if (choice === 'acted') {
|
|
if (!actionLabel(evaluation.action) || typeof options.onSuggestionAction !== 'function') {
|
|
announce(options.document, panel, 'This optional action is not connected. Nothing changed.', true); return;
|
|
}
|
|
await options.onSuggestionAction(Object.freeze({type: evaluation.action,
|
|
conversationId: options.conversationId}));
|
|
}
|
|
const key = contract.safeIdempotencyKey(options.idempotencyKey());
|
|
const decision = choice === 'dismiss' ? 'dismissed' : choice;
|
|
const path = `/projects/${encodeURIComponent(options.projectId)}/conversations/` +
|
|
`${encodeURIComponent(options.conversationId)}/suggestions/` +
|
|
`${encodeURIComponent(evaluation.suggestionId)}/decisions`;
|
|
const response = await client.request(path, {method: 'POST', headers: {
|
|
'Idempotency-Key': key, 'If-Match': String(evaluation.revision)},
|
|
body: {decision, clicked: true}});
|
|
const raw = response.body;
|
|
if (!exact(raw, ['state', 'revision', 'decision']) || raw.decision !== decision ||
|
|
!Number.isSafeInteger(raw.revision) || raw.revision <= evaluation.revision ||
|
|
!record(raw.state) || raw.state.owner !== client.identity.userRef ||
|
|
raw.state.suggestion_id !== evaluation.suggestionId) throw new WaveCContractError('Decision crossed its scope');
|
|
decided.add(evaluation.suggestionId); active = null;
|
|
announce(options.document, panel, choice === 'acted' ? 'Suggestion completed.' : 'Suggestion dismissed.', false);
|
|
}
|
|
|
|
function show(evaluation) {
|
|
active = evaluation;
|
|
const aside = element(options.document, 'aside', {'class': 'hux-wave-c hux-wave-c--suggestion',
|
|
'aria-labelledby': `hux-wave-c-${evaluation.suggestionId}`});
|
|
aside.appendChild(element(options.document, 'span', {'class': 'hux-wave-c__kicker'}, 'Optional next step'));
|
|
aside.appendChild(element(options.document, 'h3', {'id': `hux-wave-c-${evaluation.suggestionId}`}, evaluation.title));
|
|
aside.appendChild(element(options.document, 'p', {}, evaluation.body));
|
|
const controls = element(options.document, 'div', {'class': 'hux-wave-c__actions'});
|
|
const label = actionLabel(evaluation.action);
|
|
[['acted', label], ['dismiss', 'Not now'], ['never_again', "Don't suggest this again"]]
|
|
.filter((entry) => entry[1]).forEach(([choice, text]) => {
|
|
const button = element(options.document, 'button', {'type': 'button'}, text);
|
|
button.addEventListener('click', () => decide(choice).catch(() =>
|
|
announce(options.document, panel, 'That choice could not be saved. Nothing else changed.', true)));
|
|
controls.appendChild(button);
|
|
});
|
|
aside.appendChild(controls); panel.replaceChildren(aside);
|
|
}
|
|
|
|
async function present(rawTrigger) {
|
|
if (!panel || !client) return false;
|
|
const trigger = normalizeTrigger(rawTrigger, client.identity.surface);
|
|
if (capabilityGap(client, options.cards(), 'onboarding')) {
|
|
gapView(options.document, panel, 'Contextual suggestions'); return false;
|
|
}
|
|
announce(options.document, panel, 'Checking for an optional next step…', false);
|
|
const response = await client.request(`/projects/${encodeURIComponent(options.projectId)}/conversations/` +
|
|
`${encodeURIComponent(options.conversationId)}/suggestions/evaluate`, {method: 'POST',
|
|
headers: {'Idempotency-Key': contract.safeIdempotencyKey(options.idempotencyKey())},
|
|
body: {context: trigger.context, no_store: false}});
|
|
const evaluation = normalizeEvaluation(response.body, client.identity, options.sessionId,
|
|
options.conversationId, trigger, client.enabled('hux.privacy'));
|
|
if (!evaluation || decided.has(evaluation.suggestionId)) return false;
|
|
show(evaluation); return true;
|
|
}
|
|
|
|
return Object.freeze({present, extension: Object.freeze({id: 'wave-c-onboarding',
|
|
flag: 'hux.onboarding', label: 'Suggestions', order: 80, render(context) {
|
|
panel = context.container; client = context.client;
|
|
if (capabilityGap(client, options.cards(), 'onboarding')) gapView(options.document, panel, 'Contextual suggestions');
|
|
else announce(options.document, panel,
|
|
'Suggestions appear only after an exact app event. Nothing is shown automatically.', false);
|
|
}})});
|
|
}
|
|
|
|
function releaseExtension(options) {
|
|
return Object.freeze({id: 'wave-c-release', flag: 'hux.release_followthrough',
|
|
label: 'Release trust', order: 90, render(context) {
|
|
const panel = context.container;
|
|
if (capabilityGap(context.client, options.cards(), 'release')) {
|
|
gapView(options.document, panel, 'Release evidence'); return;
|
|
}
|
|
announce(options.document, panel, 'Verifying live release evidence…', false);
|
|
context.client.request(`/projects/${encodeURIComponent(options.projectId)}/conversations/` +
|
|
`${encodeURIComponent(options.conversationId)}/releases`).then((response) => {
|
|
const page = normalizeReleasePage(response.body, context.client.identity,
|
|
options.projectId, options.conversationId);
|
|
const section = element(options.document, 'section', {'class': 'hux-wave-c',
|
|
'aria-labelledby': 'hux-wave-c-release-title'});
|
|
section.appendChild(element(options.document, 'h3', {'id': 'hux-wave-c-release-title'}, 'Release evidence'));
|
|
const list = element(options.document, 'ul', {'class': 'hux-wave-c__releases'});
|
|
page.items.forEach((item) => {
|
|
const row = element(options.document, 'li', {'data-live-verified': item.live ? 'true' : 'false'});
|
|
row.appendChild(element(options.document, 'strong', {}, item.workload));
|
|
row.appendChild(element(options.document, 'span', {}, item.live ?
|
|
`Live verified ${item.verifiedAt}` : 'Release evidence is not live verified.'));
|
|
row.appendChild(element(options.document, 'code', {}, item.commit.slice(0, 12)));
|
|
list.appendChild(row);
|
|
});
|
|
section.appendChild(list);
|
|
if (page.withheld) section.appendChild(element(options.document, 'p', {'role': 'status'},
|
|
`${page.withheld} release record was withheld because its evidence was invalid.`));
|
|
panel.replaceChildren(section);
|
|
}).catch(() => announce(options.document, panel,
|
|
'Live release evidence could not be verified. No deployment claim was shown.', true));
|
|
}});
|
|
}
|
|
|
|
function defaultKeyFactory() {
|
|
let sequence = 0;
|
|
return () => `webui:suggestion:${Date.now().toString(36)}:${(++sequence).toString(36)}`;
|
|
}
|
|
|
|
function createWaveCRuntime(options) {
|
|
const settings = options || {};
|
|
const doc = settings.document || (typeof document === 'object' ? document : null);
|
|
const fetcher = settings.fetcher || (typeof fetch === 'function' ? fetch.bind(globalThis) : null);
|
|
if (!doc || !fetcher || !contract.isId(settings.projectId, 'prj') ||
|
|
!contract.isId(settings.conversationId, 'conv') || !contract.isId(settings.sessionId, 'ses')) {
|
|
throw new TypeError('Wave C runtime requires a document, fetcher, and opaque scope ids');
|
|
}
|
|
let cards = immutable({});
|
|
const base = String(settings.baseUrl || '/hux/v1').replace(/\/$/, '');
|
|
const capabilityUrl = contract.safeEndpoint(base, '/capabilities');
|
|
const captureFetcher = async (url, init) => {
|
|
const response = await fetcher(url, init);
|
|
if (url !== capabilityUrl || !response.ok) return response;
|
|
const body = await response.json();
|
|
cards = normalizeCapabilityCards(body, contract.normalizeExpectedIdentity(settings.expectedIdentity));
|
|
return {status: response.status, ok: response.ok, headers: response.headers, json: async () => body};
|
|
};
|
|
const client = contract.createCanonicalClient({...settings, fetcher: captureFetcher});
|
|
const shell = shellApi.createShell({client, document: doc, instanceId: settings.instanceId || 'wave-c'});
|
|
const shared = {document: doc, projectId: settings.projectId, conversationId: settings.conversationId,
|
|
sessionId: settings.sessionId, cards: () => cards,
|
|
idempotencyKey: settings.idempotencyKey || defaultKeyFactory(),
|
|
onSuggestionAction: settings.onSuggestionAction};
|
|
const onboarding = onboardingExtension(shared);
|
|
const removers = [shell.register(multimodalExtension(shared)),
|
|
shell.register(onboarding.extension), shell.register(releaseExtension(shared))];
|
|
let mounted = false;
|
|
return Object.freeze({client, presentSuggestion: onboarding.present,
|
|
async mount(target) {
|
|
if (mounted) throw new Error('Wave C runtime is already mounted');
|
|
mounted = true; shell.mount(target); await client.negotiate();
|
|
},
|
|
destroy() { removers.forEach((remove) => remove()); shell.destroy(); mounted = false; },
|
|
});
|
|
}
|
|
|
|
return Object.freeze({CARD_SPECS, WaveCContractError, actionLabel, capabilityGap,
|
|
createWaveCRuntime, multimodalExtension, normalizeCapabilityCards, normalizeEvaluation,
|
|
normalizeMediaPage, normalizeReleasePage, normalizeTrigger, onboardingExtension,
|
|
releaseEvidence, releaseExtension});
|
|
}));
|