diff --git a/README.md b/README.md
index 8e3c4d1..1a5a25b 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@ Agent / project
└─ Helm Bridge inbox ── owner review ──> browser library (IndexedDB)
│ │
└─ exact original bytes ├─ HARC backup / explicit folder sync
- └─ explicit immutable intranet share
+ └─ reviewed Channel + immutable Revisions
```
The important boundary is deliberate: submitting to the Bridge is a handoff, not permission to modify the browser library.
@@ -82,10 +82,12 @@ Helm has no package-install step. Run the same checks used by CI:
```bash
python3 -m unittest discover -s tests -p 'test_*.py'
-python3 helm_share_server.py --host 127.0.0.1 --port 4173
+python3 -m http.server 4183 --bind 127.0.0.1
```
-The browser smoke pages are available at [`/tests/contract-smoke.html`](tests/contract-smoke.html) and [`/tests/repair-smoke.html`](tests/repair-smoke.html) while the local server is running. Both should show `passed`.
+Open the browser smoke pages at `http://127.0.0.1:4183/tests/contract-smoke.html`, `channel-store-smoke.html`, and `repair-smoke.html`. The production share server deliberately blocks `/tests`; use this isolated static development port for browser checks.
+
+For a reviewed remote intranet installation, use [`scripts/deploy-remote`](scripts/deploy-remote). It deploys only the committed tree, tests it before activation, keeps runtime shares outside the release, and rolls back a failed health check. See [`docs/INTRANET-SHARING.md`](docs/INTRANET-SHARING.md).
## Scope and boundaries
diff --git a/app.js b/app.js
index 461d7ef..3bb7817 100644
--- a/app.js
+++ b/app.js
@@ -6,6 +6,7 @@ const MAX_IMPORT_BYTES = 5 * 1024 * 1024;
const MAX_SEARCH_TEXT = 250000;
const AGENT_BRIDGE_URL = 'http://127.0.0.1:4175';
const CHANNEL_API_URL = '/api/channels';
+const BUILTIN_CONTENT_VERSION = 2;
const channelRepository = globalThis.HelmChannelStore?.defaultRepository;
const templates = [
@@ -163,7 +164,13 @@ function manifestFor(artifact) {
return manifest;
}
-function visualModule(type) {
+function visualModule(artifact) {
+ const type = artifact.type;
+ if (artifact.id === 'welcome-to-helm') {
+ const nodes = [['01', 'Guide', 'read the contract'], ['02', 'Create', 'final HDOC/1.0'], ['03', 'Retain', 'review in Helm']];
+ const node = ([index, label, detail], x) => `${index}${label}${detail}`;
+ return `
Artifact route
From an agent task to a durable document
HELM / ONBOARDING
Agents follow the repository contract; owners inspect the retained Revision, mark it reviewed, and explicitly advance its Channel when it is ready to share.
Guide: read the repository contract.
Create: deliver one final HDOC/1.0 file.
Retain: inspect, review, and publish in Helm.
`;
+ }
const copy = type === 'brief'
? { kind: 'Decision map', title: 'Make the chosen option and the reason for it visible', nodes: [['A', 'Option', 'upside'], ['B', 'Recommended', 'evidence'], ['C', 'Alternative', 'trade-off']], note: 'Replace the option names and labels with the actual criteria, evidence date, and reversal condition.' }
: type === 'reference'
@@ -186,12 +193,15 @@ function articleHtml(artifact, sections = []) {
return `
Before handoff, replace the template visual with actual evidence and add sources, dates, assumptions, and confidence wherever they qualify a factual claim.
`;
+ const sourceNote = artifact.id === 'welcome-to-helm'
+ ? 'Helm keeps original HTML immutable by Revision. Catalog metadata may evolve independently; publication always remains an explicit owner action.'
+ : 'Before handoff, replace the template visual with actual evidence and add sources, dates, assumptions, and confidence wherever they qualify a factual claim.';
+ return `${esc(artifact.title)}
`;
}
function seedHtml(artifact) {
const sections = artifact.id === 'welcome-to-helm'
- ? [['What belongs here', 'Save the HTML outputs you want to find again: reports, project briefs, notes, dashboards and finished research. The library stores the original file rather than translating it into a database-only format.'], ['How to begin', 'Import an existing HTML file, or use a template to create a compliant starting point. Select any artifact to open it in the safe reader or export the exact source.']]
+ ? [['What belongs here', 'Retain finished HTML outputs that should remain findable and reviewable: reports, project briefs, notes, dashboards, and research. Helm preserves the exact source as an immutable Revision instead of flattening it into app-only content.'], ['Agent handoff', 'Give an agent the repository guide, keep the same manifest ID when revising one logical artifact, and submit the final HDOC/1.0 file once. The owner accepts the incoming Revision before it becomes current.'], ['Review and publish', 'Open the artifact in the safe reader, inspect its contract health, mark the current Revision reviewed, then publish only when its stable Channel should advance. Every published Revision retains a separate immutable address.']]
: [['Why a contract', 'HTML is a superb final format for AI-assisted work, but a loose file often loses its purpose and provenance. A tiny manifest makes it searchable, auditable and portable.'], ['Use it elsewhere', 'Give another project the Helm document contract before asking it to generate HTML. The result can be imported here without losing its record.']];
return articleHtml(artifact, sections);
}
@@ -267,6 +277,15 @@ async function initialise() {
} else {
documents = stored;
}
+ const builtinVersion = Number(await getSetting('builtinContentVersion') || 0);
+ const welcome = documents.find((artifact) => artifact.id === 'welcome-to-helm');
+ if (builtinVersion < BUILTIN_CONTENT_VERSION && welcome?.source === 'Helm' && welcome.html.includes('Template visual · replace before handoff')) {
+ const definition = seedDocuments.find((artifact) => artifact.id === 'welcome-to-helm');
+ const upgraded = enrichArtifact({ ...definition, html: seedHtml(definition) }, { preserveId: true, takenIds: new Set(documents.map((artifact) => artifact.id)) });
+ const saved = await saveDocument(upgraded);
+ documents = documents.map((artifact) => artifact.id === saved.id ? saved : artifact);
+ }
+ await setSetting('builtinContentVersion', BUILTIN_CONTENT_VERSION);
await loadRequiredLineageSources();
await setSetting('libraryInitialized', true);
archiveFolderHandle = await getSetting('archiveFolderHandle');
@@ -348,7 +367,13 @@ function renderLibrary() {
$('#documentGrid').innerHTML = filtered.map((artifact, index) => {
const project = projectFor(artifact);
const state = workflowState(artifact);
- const published = stableShare(artifact) ? `Published v${revisionNumber(artifact, artifact.publishedRevisionId)}` : artifact.publishedRevisionId ? `Last published v${revisionNumber(artifact, artifact.publishedRevisionId)} · revoked` : 'Not published';
+ const published = stableShare(artifact)
+ ? `Published v${revisionNumber(artifact, artifact.publishedRevisionId)}`
+ : activeLegacyShare(artifact)
+ ? 'Legacy share · live'
+ : legacyShareRecord(artifact)?.revokedAt
+ ? 'Legacy share · retired'
+ : artifact.publishedRevisionId ? `Last published v${revisionNumber(artifact, artifact.publishedRevisionId)} · revoked` : 'Not published';
return `
`).join('');
$('#repairButton').hidden = health.valid;
- $('#shareButton').disabled = !health.valid || !hasChannelIdentity(artifact);
- $('#shareButton').title = hasChannelIdentity(artifact) ? '' : 'A Channel requires the logical Artifact ID to match the embedded HDOC manifest ID.';
- $('#shareButton').textContent = state === 'published' && share ? 'Copy stable link' : artifact.publishedRevisionId ? 'Publish current revision' : 'Publish stable link';
- $('#shareRecord').hidden = !share;
- $('#revokeShareButton').hidden = !share;
- $('#selectedShare').textContent = share?.stableUrl || '';
- $('#selectedShare').href = share?.stableUrl || '#';
- $('#selectedRevisionShare').textContent = revisionShare?.revisionUrl ? `Immutable snapshot: ${revisionShare.revisionUrl}` : 'Published revisions remain available at immutable addresses.';
+ const identityReady = hasChannelIdentity(artifact);
+ $('#shareButton').disabled = !health.valid || !identityReady || state === 'draft' || Boolean(legacyShare);
+ $('#shareButton').title = legacyShare ? 'Retire the legacy one-shot share before publishing this Artifact as a Channel.' : !identityReady ? 'A Channel requires the logical Artifact ID to match the embedded HDOC manifest ID.' : state === 'draft' ? 'Mark this Revision reviewed before publishing.' : !health.valid ? 'Repair the HDOC contract before publishing.' : '';
+ $('#shareButton').textContent = legacyShare ? 'Retire legacy link first' : state === 'draft' ? 'Review before publishing' : state === 'published' && share ? 'Copy stable link' : artifact.publishedRevisionId ? 'Publish current revision' : 'Publish stable link';
+ const visibleShare = share?.stableUrl || legacyShare?.legacyUrl || '';
+ $('#shareRecord').hidden = !visibleShare;
+ $('#shareRecordLabel').textContent = legacyShare ? 'LEGACY IMMUTABLE SHARE' : 'STABLE ADDRESS';
+ $('#revokeShareButton').hidden = !share && !legacyShare;
+ $('#revokeShareButton').textContent = legacyShare ? 'Retire legacy link' : 'Revoke stable address';
+ $('#selectedShare').textContent = visibleShare;
+ if (visibleShare) $('#selectedShare').href = visibleShare;
+ else $('#selectedShare').removeAttribute('href');
+ $('#selectedRevisionShare').textContent = legacyShare ? 'This pre-Channel one-shot file remains public until the owner explicitly retires it.' : revisionShare?.revisionUrl ? `Immutable snapshot: ${revisionShare.revisionUrl}` : 'Published revisions remain available at immutable addresses.';
$('#lineageRecord').hidden = !artifact.forkedFrom;
$('#selectedLineage').textContent = artifact.forkedFrom ? `${artifact.forkedFrom.artifactId} · ${artifact.forkedFrom.revisionId.slice(0, 18)}…` : '';
+ const forkNeedsRevision = Boolean(artifact.forkedFrom && !identityReady);
+ $('#forkHandoff').hidden = !forkNeedsRevision;
+ $('#forkArtifactId').textContent = forkNeedsRevision ? artifact.id : '';
}
function render() { renderCollections(); renderProjects(); renderLibrary(); renderTemplates(); renderInspector(); $('#archiveDocumentCount').textContent = String(documents.length).padStart(2, '0'); renderFolderStatus(); }
@@ -468,6 +517,7 @@ function showView(view) {
$$('.view').forEach((element) => element.classList.toggle('active-view', element.id === `${view}View`));
$$('.nav-item').forEach((element) => element.classList.toggle('active', element.dataset.view === view));
$('#viewTitle').textContent = view === 'library' ? 'Library' : view === 'templates' ? 'Templates' : 'Document contract';
+ $('.app-shell').classList.toggle('without-inspector', view !== 'library');
}
function showToast(message) {
@@ -953,6 +1003,8 @@ function openReader(id = selectedId) {
$('#readerTitle').textContent = artifact.title;
$('#readerRevisionState').dataset.status = workflowState(artifact);
$('#readerRevisionState').textContent = `v${revisionNumber(artifact)} · ${workflowLabel(artifact)}`;
+ $('#readerShare').disabled = Boolean(activeLegacyShare(artifact)) || workflowState(artifact) === 'draft' || !hasChannelIdentity(artifact) || !(artifact.validation || inspectHtml(artifact.html)).valid;
+ $('#readerShare').title = activeLegacyShare(artifact) ? 'Retire the legacy one-shot share before publishing a Channel.' : workflowState(artifact) === 'draft' ? 'Mark this Revision reviewed before publishing.' : !hasChannelIdentity(artifact) ? 'Create a Revision whose manifest ID matches this Artifact before publishing.' : '';
$('#readerLoadingTitle').textContent = 'Opening document';
$('#readerLoadingHint').textContent = 'Preparing preview…';
loading.hidden = false;
@@ -1035,6 +1087,7 @@ async function markReviewed() {
async function publishDocument(artifact = selectedDocument()) {
if (!artifact) return;
+ if (activeLegacyShare(artifact)) { showToast('Retire the legacy one-shot link before publishing a stable Channel.'); return; }
if (!hasChannelIdentity(artifact)) { showToast('Create a new HDOC revision whose manifest ID matches this Artifact before publishing it as a Channel.'); return; }
if (workflowState(artifact) === 'draft') { showToast('Mark the current revision reviewed before publishing.'); return; }
const health = artifact.validation || inspectHtml(artifact.html);
@@ -1048,7 +1101,7 @@ async function publishDocument(artifact = selectedDocument()) {
const buttons = $$('[data-share-action]');
buttons.forEach((button) => { button.disabled = true; });
try {
- const baseRevision = artifact.publishedRevisionId?.replace(/^sha256:/, '') || undefined;
+ const baseRevision = stableShare(artifact) ? artifact.publishedRevisionId?.replace(/^sha256:/, '') : undefined;
const response = await fetch(`${CHANNEL_API_URL}/publish`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
@@ -1077,8 +1130,24 @@ async function publishDocument(artifact = selectedDocument()) {
async function revokePublication() {
const artifact = selectedDocument();
const share = stableShare(artifact);
- if (!artifact || !share) return;
+ const legacyShare = activeLegacyShare(artifact);
+ if (!artifact || (!share && !legacyShare)) return;
try {
+ if (legacyShare) {
+ if (!legacyShare.legacyPath || !legacyShare.sha256) throw new Error('Legacy share metadata lacks the exact path and digest required for safe retirement.');
+ const response = await fetch('/api/share/revoke', {
+ method: 'POST',
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
+ body: JSON.stringify({ path: legacyShare.legacyPath, sha256: legacyShare.sha256 })
+ });
+ const payload = await response.json().catch(() => ({}));
+ if (!response.ok) throw new Error(payload.message || `Share service returned ${response.status}.`);
+ await channelRepository.revokePublication(artifact.id, artifact.publishedRevisionId, { ...legacyShare, revokedAt: payload.revoked_at || new Date().toISOString() });
+ await refreshArtifact(artifact.id);
+ render();
+ showToast('Legacy one-shot link retired. The library Revision remains unchanged.');
+ return;
+ }
const base = artifact.publishedRevisionId.replace(/^sha256:/, '');
const response = await fetch(`${CHANNEL_API_URL}/artifacts/${encodeURIComponent(share.artifactId || artifact.id)}/revoke`, {
method: 'POST',
@@ -1127,15 +1196,19 @@ function openHistoryDialog(artifact = selectedDocument(), selectedRevisionId = n
if (!artifact) return;
const revisions = revisionsFor(artifact);
const share = stableShare(artifact);
+ const legacyShare = activeLegacyShare(artifact);
const dialog = $('#historyDialog');
dialog.dataset.artifactId = artifact.id;
dialog.dataset.selectedRevisionId = selectedRevisionId || artifact.currentRevisionId;
$('#historyArtifactIdentity').textContent = `${artifact.id} · ${workflowLabel(artifact).toUpperCase()}`;
$('#historyArtifactTitle').textContent = artifact.title;
- $('#historyStableLink').textContent = share?.stableUrl || 'Not published';
- $('#historyStableLink').href = share?.stableUrl || '#';
+ const visibleShare = share?.stableUrl || legacyShare?.legacyUrl || '';
+ $('#historyAddressLabel').textContent = legacyShare ? 'LEGACY IMMUTABLE SHARE' : 'STABLE ADDRESS';
+ $('#historyStableLink').textContent = visibleShare || (legacyShareRecord(artifact)?.revokedAt ? 'Legacy share retired' : 'Not published');
+ if (visibleShare) $('#historyStableLink').href = visibleShare;
+ else $('#historyStableLink').removeAttribute('href');
$('#historyStableLink').removeAttribute('aria-disabled');
- if (!share) $('#historyStableLink').setAttribute('aria-disabled', 'true');
+ if (!visibleShare) $('#historyStableLink').setAttribute('aria-disabled', 'true');
$('#historyRevisionCount').textContent = String(revisions.length).padStart(2, '0');
$('#revisionTimeline').innerHTML = revisions.slice().reverse().map((revision) => {
const current = revision.id === artifact.currentRevisionId;
@@ -1148,6 +1221,11 @@ function openHistoryDialog(artifact = selectedDocument(), selectedRevisionId = n
const selectedIndex = Math.max(0, revisions.findIndex((revision) => revision.id === dialog.dataset.selectedRevisionId));
$('#compareTo').value = revisions[selectedIndex]?.id || artifact.currentRevisionId;
$('#compareFrom').value = revisions[Math.max(0, selectedIndex - 1)]?.id || $('#compareTo').value;
+ const isSingleRevision = revisions.length === 1;
+ $('#compareWorkspace').classList.toggle('is-single-revision', isSingleRevision);
+ $('#compareEyebrow').textContent = isSingleRevision ? 'REVISION PREVIEW' : 'VISUAL COMPARE';
+ $('#compareHeading').textContent = isSingleRevision ? 'First retained Revision' : 'Rendered revision diff';
+ $('#compareNote').textContent = isSingleRevision ? 'There is no earlier Revision to compare yet. This original remains sandboxed and unchanged.' : 'Both revisions render at the same viewport. The original HTML remains sandboxed and unchanged.';
$$('#revisionTimeline [data-compare-revision]').forEach((button) => button.addEventListener('click', () => {
dialog.dataset.selectedRevisionId = button.dataset.compareRevision;
$('#compareTo').value = button.dataset.compareRevision;
@@ -1188,7 +1266,15 @@ async function forkArtifact() {
selectedId = fork.id;
if ($('#historyDialog').open) $('#historyDialog').close();
render();
- showToast(`Forked ${revisionLabel(source, revisionId)} as a new artifact.`);
+ showToast(`Forked ${revisionLabel(source, revisionId)}. Copy the agent handoff to create its first aligned Revision.`);
+}
+
+async function copyForkHandoff() {
+ const artifact = selectedDocument();
+ if (!artifact?.forkedFrom || hasChannelIdentity(artifact)) return;
+ const prompt = `Continue the Helm fork "${artifact.title}" as Artifact ID "${artifact.id}". Read AI-GUIDE.md, docs/REPORT-DESIGN-STANDARD.md, and docs/HTML-DOCUMENT-SPEC.md. Use the forked Revision as source evidence, produce one finished self-contained HDOC/1.0 HTML file, and set the embedded manifest id exactly to "${artifact.id}". Keep the same ID for later revisions of this logical artifact.`;
+ const copied = await copyText(prompt);
+ showToast(copied ? 'Agent handoff copied.' : 'Could not copy the agent handoff.');
}
async function openLineage() {
@@ -1356,6 +1442,7 @@ function wireEvents() {
$('#readerHistory').addEventListener('click', () => openHistoryDialog(readerDocument()));
$('#forkButton').addEventListener('click', forkArtifact);
$('#forkArtifactButton').addEventListener('click', forkArtifact);
+ $('#copyForkHandoffButton').addEventListener('click', copyForkHandoff);
$('#openLineageButton').addEventListener('click', openLineage);
$('#compareFrom').addEventListener('change', renderVisualDiff);
$('#compareTo').addEventListener('change', () => { $('#historyDialog').dataset.selectedRevisionId = $('#compareTo').value; renderVisualDiff(); });
diff --git a/channel-store.js b/channel-store.js
index c27f570..ff219d9 100644
--- a/channel-store.js
+++ b/channel-store.js
@@ -14,6 +14,7 @@
const REVISION_STORE = 'revisions';
const SETTINGS_STORE = 'settings';
const MIGRATION_KEY = 'channelsMigrationV1';
+ const LEGACY_SHARE_NORMALISATION_KEY = 'legacyShareNormalisationV1';
const STATUSES = new Set(['draft', 'in-review', 'published', 'archived']);
const CATALOG_FIELDS = new Set(['title', 'type', 'tags', 'summary', 'source', 'project']);
const ARTIFACT_FIELDS = new Set([
@@ -92,6 +93,27 @@
return (Array.isArray(value) ? value : []).filter((tag) => typeof tag === 'string').map((tag) => tag.trim()).filter(Boolean);
}
+ function normaliseMigratedShare(value) {
+ if (!isPlainObject(value)) return null;
+ if (value.kind === 'legacy') return cloneJson(value, 'share');
+ if (safeString(value.stableUrl)) return cloneJson(value, 'share');
+ const legacyUrl = safeString(value.legacyUrl, safeString(value.url));
+ let legacyPath = safeString(value.legacyPath, safeString(value.path));
+ if (!legacyPath && legacyUrl) {
+ try { legacyPath = new URL(legacyUrl, 'http://helm.local').pathname; }
+ catch (_error) { /* Leave malformed historical metadata visible but non-actionable. */ }
+ }
+ if (!legacyUrl && !legacyPath) return cloneJson(value, 'share');
+ return {
+ kind: 'legacy',
+ legacyUrl: legacyUrl || legacyPath,
+ legacyPath: legacyPath || null,
+ sha256: safeString(value.sha256) || null,
+ publishedAt: timestamp(value.publishedAt, null),
+ revokedAt: timestamp(value.revokedAt, null)
+ };
+ }
+
function extraFields(input, knownFields) {
const extensions = isPlainObject(input.extensions) ? cloneJson(input.extensions, 'extensions') : {};
for (const [key, value] of Object.entries(input)) {
@@ -277,7 +299,7 @@
status: document.share ? 'published' : 'draft',
publishedRevisionId
});
- const revision = revisionFromInput(document.id, document, hash, { parent: null, now: timestamp(document.updatedAt, new Date().toISOString()), share: document.share });
+ const revision = revisionFromInput(document.id, document, hash, { parent: null, now: timestamp(document.updatedAt, new Date().toISOString()), share: normaliseMigratedShare(document.share) });
prepared.push({ artifact, revision });
}
if (invalidLegacyRecords.length) {
@@ -309,9 +331,28 @@
return value;
}
+ async function normaliseLegacyShares() {
+ const db = await database();
+ const marker = await requestResult(db.transaction(SETTINGS_STORE, 'readonly').objectStore(SETTINGS_STORE).get(LEGACY_SHARE_NORMALISATION_KEY));
+ if (marker?.value?.complete) return marker.value;
+ const revisions = await getAll(REVISION_STORE);
+ const updates = revisions
+ .map((revision) => ({ revision, share: normaliseMigratedShare(revision.share) }))
+ .filter(({ revision, share }) => JSON.stringify(revision.share) !== JSON.stringify(share));
+ const completedAt = new Date().toISOString();
+ const value = { complete: true, normalisedCount: updates.length, completedAt };
+ const tx = db.transaction([REVISION_STORE, SETTINGS_STORE], 'readwrite');
+ const revisionStore = tx.objectStore(REVISION_STORE);
+ for (const { revision, share } of updates) revisionStore.put({ ...revision, share });
+ tx.objectStore(SETTINGS_STORE).put({ key: LEGACY_SHARE_NORMALISATION_KEY, value });
+ await transactionDone(tx);
+ return value;
+ }
+
async function open() {
await database();
await migrateLegacyDocuments();
+ await normaliseLegacyShares();
return repository;
}
diff --git a/docs/HTML-DOCUMENT-SPEC.md b/docs/HTML-DOCUMENT-SPEC.md
index 41e98e9..7eb4946 100644
--- a/docs/HTML-DOCUMENT-SPEC.md
+++ b/docs/HTML-DOCUMENT-SPEC.md
@@ -14,7 +14,7 @@ Any project, agent, or person that generates an HTML artifact for the Helm libra
4. Use semantic HTML: one `h1`, ordered headings, real lists and tables, descriptive links, and no text encoded only in images.
5. State sources, data dates, assumptions, and confidence wherever factual claims would otherwise become untraceable.
6. Follow [`REPORT-DESIGN-STANDARD.md`](REPORT-DESIGN-STANDARD.md): use a calm, evidence-forward, answer-first report system with generous whitespace, useful hierarchy, quiet neutral surfaces, restrained accent color, and data clarity over dashboard decoration. When a comparison, sequence, hierarchy, magnitude, change, composition, or uncertainty is material, include one or more meaningful visual evidence modules selected from the visual grammar. A reader must be able to find the purpose or short answer, the supporting evidence, and the resulting action or boundary without relying on interaction.
-7. Do not depend on a host app for fonts, scripts, navigation, APIs, authentication, or core content. Remote images and fonts may be used only as progressive enhancement; a document must still be meaningful without them.
+7. Do not depend on a host app for fonts, scripts, navigation, APIs, authentication, or core content. Remote images and fonts may be used only as progressive enhancement; a document must still be meaningful without them. Relative file references such as `../stage2/report.html` or `./chart.png` are non-portable because sibling files do not travel with one standalone artifact. Embed essential resources and use absolute URLs for external destinations.
8. Treat user-supplied or third-party HTML as untrusted. Helm previews it in a sandbox, and authors should avoid scripts unless there is a clear, documented reason.
## Report presentation standard
@@ -81,6 +81,13 @@ These duplicate the core manifest fields so a simple file indexer can inspect th
```
+## Links and embedded resources
+
+- Fragment links such as `#evidence`, absolute web/source URLs, and purpose-specific schemes such as `mailto:` and `tel:` are valid navigation targets.
+- A relative `href` points into the author's original folder layout, which Helm does not import. Replace it with an absolute URL, or preserve the referenced evidence inside the artifact.
+- Essential images, audio, video, fonts, and CSS must be embedded, normally with inline markup/CSS or a `data:` URL. A relative `src` or CSS `url(...)` violates the standalone portability expectation and is reported by validation.
+- A remote media or CSS resource is a progressive enhancement, not part of the retained evidence original. Helm reports it as a portability warning, so a document that relies on it does not receive a 100-point validation score.
+
## Reference skeleton
```html
diff --git a/docs/INTRANET-SHARING.md b/docs/INTRANET-SHARING.md
index 4dafd21..441863c 100644
--- a/docs/INTRANET-SHARING.md
+++ b/docs/INTRANET-SHARING.md
@@ -65,7 +65,17 @@ Existing one-shot sharing remains fully compatible:
- `POST /api/share` publishes one validated document through loopback.
- `GET /share/--.html` remains an immutable public address.
-- Existing flat share files are not migrated, renamed, redirected, or deleted.
+- Existing flat share files are not renamed or redirected during the Channels migration. Helm keeps their original URL visible as a **Legacy immutable share** instead of misreporting it as a Channel.
+- The owner may explicitly retire one exact legacy URL with `POST /api/share/revoke`. The request must include both its `/share/...` path and full SHA-256 digest; Helm verifies the path, filename digest prefix, and stored bytes before deleting that one file.
+
+```http
+POST /api/share/revoke
+Content-Type: application/json
+
+{"path":"/share/--.html", "sha256":""}
+```
+
+After this explicit action the legacy URL returns `404 Not Found`. Unlike a Channel revoke, there is no retained content-addressed Revision behind a legacy one-shot share.
The legacy endpoint never advances a Channel because it has no base Revision for conflict detection. Publish through `/api/channels/publish` when one stable address should evolve.
@@ -76,3 +86,15 @@ The legacy endpoint never advances a Channel because it has no base Revision for
- Restrict the listening port to the intended private network at the host firewall.
- Never publish secrets, credentials, private source material, or machine-specific access data.
- Deleting a browser catalog entry does not delete an already published URL.
+
+## Repeatable remote deployment
+
+Deploy only a reviewed, committed tree. Runtime shares live outside the application directory and survive an atomic upgrade:
+
+```bash
+scripts/deploy-remote \
+ --host USER@INTRANET_HOST \
+ --public-base-url http://INTRANET_HOST:4173
+```
+
+The command archives `HEAD`, runs the full test suite in a staging directory on the target, swaps the application directory, restarts the configured tmux service, and rolls back if the Channel health check fails. It never copies browser data, Bridge tokens, or `~/.helm-shares`.
diff --git a/docs/REPORT-DESIGN-STANDARD.md b/docs/REPORT-DESIGN-STANDARD.md
index 9191380..4426ea5 100644
--- a/docs/REPORT-DESIGN-STANDARD.md
+++ b/docs/REPORT-DESIGN-STANDARD.md
@@ -65,6 +65,8 @@ Each visual module must make one real relationship easier to understand. Before
Use inline SVG or semantic HTML/CSS for diagrams and simple charts. They keep the artifact self-contained, printable, searchable, and legible at narrow widths. Do not require a runtime CDN, a canvas-only rendering, a remote image, or interaction to learn the core result.
+Keep evidence navigation portable as well as visual. Do not link to sibling files with relative paths such as `../stage2/report.html`: Helm retains one HTML evidence original, not the source directory tree. Use an absolute, durable source URL, or bring the relevant finding and provenance into the artifact. Embed essential visual resources rather than referencing relative image, font, media, or stylesheet files.
+
## Visual grammar library
Use these patterns consistently rather than inventing a new decorative shape for every report:
diff --git a/helm_bridge.py b/helm_bridge.py
index e72548c..3c1afbe 100644
--- a/helm_bridge.py
+++ b/helm_bridge.py
@@ -11,6 +11,7 @@
import argparse
import hashlib
+import ipaddress
import json
import os
import re
@@ -30,7 +31,7 @@
HDOC_VERSION = "HDOC/1.0"
MAX_DOCUMENT_BYTES = 5 * 1024 * 1024
DOCUMENT_TYPES = {"report", "brief", "reference", "dashboard", "note"}
-DEFAULT_CORS_ORIGINS = {"http://127.0.0.1:4173", "http://localhost:4173"}
+DEFAULT_CORS_ORIGINS: set[str] = set()
ID_PATTERN = re.compile(r"^[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?$")
@@ -54,6 +55,7 @@ def __init__(self) -> None:
self.unsafe_scripts = 0
self.event_handlers: list[str] = []
self.external_dependencies: list[str] = []
+ self.relative_dependencies: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
attributes = {name.lower(): value or "" for name, value in attrs}
@@ -72,8 +74,16 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None
for name, value in attributes.items():
if name.startswith("on"):
self.event_handlers.append(name)
- if name in {"src", "href"} and re.match(r"^https?://", value, re.IGNORECASE):
- self.external_dependencies.append(value)
+ if name not in {"src", "href"} or not value.strip():
+ continue
+ reference = value.strip()
+ if re.match(r"^(?:https?:)?//", reference, re.IGNORECASE):
+ # An ordinary absolute anchor is provenance/navigation, not a
+ # runtime dependency. Remote src and non-anchor href values are.
+ if name == "src" or tag != "a":
+ self.external_dependencies.append(reference)
+ elif not re.match(r"^(?:#|data:|blob:|mailto:|tel:|[a-z][a-z0-9+.-]*:)", reference, re.IGNORECASE):
+ self.relative_dependencies.append(reference)
def handle_data(self, data: str) -> None:
if self._manifest_parts is not None:
@@ -177,6 +187,8 @@ def validate_hdoc(html: str) -> tuple[dict[str, Any], list[str]]:
errors.append(f"{name} must exactly match the manifest.")
if parser.external_dependencies:
warnings.append("The artifact references remote resources; it should remain meaningful without them.")
+ if parser.relative_dependencies:
+ warnings.append("The artifact references relative files or links that will not travel with a standalone HTML document; use absolute links or embed essential resources.")
if errors:
raise ContractError(errors, warnings)
return manifest, warnings
@@ -342,6 +354,34 @@ def read_document(self, document_id: str) -> dict[str, Any]:
return {**record, "html": html}
+def is_allowed_browser_origin(origin: str, explicit_origins: set[str] | None = None) -> bool:
+ """Accept an explicitly configured origin or a syntactically exact loopback origin.
+
+ Helm's UI is commonly served on an ephemeral development port. Restricting
+ by loopback host preserves that workflow without reflecting arbitrary web
+ origins into this owner-local API.
+ """
+ if origin in (explicit_origins or set()):
+ return True
+ try:
+ parsed = urlparse(origin)
+ # Accessing port deliberately rejects malformed values such as :abc.
+ parsed.port
+ except ValueError:
+ return False
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
+ return False
+ if parsed.username or parsed.password or parsed.path or parsed.params or parsed.query or parsed.fragment:
+ return False
+ hostname = (parsed.hostname or "").rstrip(".").lower()
+ if hostname == "localhost":
+ return True
+ try:
+ return ipaddress.ip_address(hostname).is_loopback
+ except ValueError:
+ return False
+
+
class BridgeRequestHandler(BaseHTTPRequestHandler):
server: "BridgeHTTPServer"
protocol_version = "HTTP/1.1"
@@ -352,7 +392,7 @@ def log_message(self, format: str, *args: Any) -> None:
def _origin_allowed(self) -> str | None:
origin = self.headers.get("Origin")
- return origin if origin and origin in self.server.cors_origins else None
+ return origin if origin and is_allowed_browser_origin(origin, self.server.cors_origins) else None
def _send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None:
encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8")
diff --git a/helm_share_server.py b/helm_share_server.py
index 662f2d1..916de4b 100644
--- a/helm_share_server.py
+++ b/helm_share_server.py
@@ -25,6 +25,7 @@
MAX_REQUEST_BYTES = MAX_DOCUMENT_BYTES + 64 * 1024
ARTIFACT_ID_PATTERN = re.compile(r"^[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?$")
REVISION_FILENAME_PATTERN = re.compile(r"^([0-9a-f]{64})\.html$")
+LEGACY_SHARE_FILENAME_PATTERN = re.compile(r"^.+--([0-9a-f]{12})\.html$")
def utc_now() -> str:
@@ -121,6 +122,32 @@ def resolve(self, filename: str) -> Path | None:
return None
return candidate
+ def revoke_legacy(self, public_path: str, expected_sha256: str) -> dict[str, Any]:
+ """Remove one exact legacy flat share without accepting arbitrary root files."""
+ parsed = urlparse(public_path)
+ if parsed.query or parsed.fragment or not parsed.path.startswith("/share/"):
+ raise ChannelNotFoundError(public_path)
+ filename = unquote(parsed.path.removeprefix("/share/"))
+ match = LEGACY_SHARE_FILENAME_PATTERN.fullmatch(filename)
+ if not match or not re.fullmatch(r"[0-9a-f]{64}", expected_sha256 or ""):
+ raise ChannelNotFoundError(public_path)
+ if match.group(1) != expected_sha256[:12]:
+ raise ChannelConflictError(filename, None)
+ with self.lock:
+ candidate = self.resolve(filename)
+ if not candidate:
+ raise ChannelNotFoundError(public_path)
+ actual_sha256 = hashlib.sha256(candidate.read_bytes()).hexdigest()
+ if actual_sha256 != expected_sha256:
+ raise ChannelConflictError(filename, actual_sha256)
+ candidate.unlink()
+ return {
+ "state": "revoked",
+ "path": f"/share/{quote(filename)}",
+ "sha256": expected_sha256,
+ "revoked_at": utc_now(),
+ }
+
def publish_channel(self, html_bytes: bytes, base_revision_sha256: str | None = None) -> dict[str, Any]:
manifest, warnings = self._validate_source(html_bytes)
artifact_id = manifest["id"]
@@ -388,9 +415,10 @@ def do_HEAD(self) -> None: # noqa: N802
def do_POST(self) -> None: # noqa: N802
path = urlparse(self.path).path
legacy = path == "/api/share"
+ legacy_revoke = path == "/api/share/revoke"
channel_publish = path == "/api/channels/publish"
revoke_match = re.fullmatch(r"/api/channels/artifacts/([^/]+)/revoke", path)
- if not legacy and not channel_publish and not revoke_match:
+ if not legacy and not legacy_revoke and not channel_publish and not revoke_match:
self._send_json(HTTPStatus.NOT_FOUND, {"error": "not_found"})
return
if not self._owner_request():
@@ -399,6 +427,14 @@ def do_POST(self) -> None: # noqa: N802
if payload is None:
return
try:
+ if legacy_revoke:
+ public_path = payload.get("path") if isinstance(payload, dict) else None
+ expected_sha256 = payload.get("sha256") if isinstance(payload, dict) else None
+ if not isinstance(public_path, str) or not isinstance(expected_sha256, str):
+ raise ContractError(["Request JSON must contain the legacy share path and SHA-256 digest."])
+ result = self.server.store.revoke_legacy(public_path, expected_sha256)
+ self._send_json(HTTPStatus.OK, {**result, "ok": True})
+ return
if revoke_match:
base = payload.get("base_revision_sha256") if isinstance(payload, dict) else None
result = self.server.store.revoke_channel(unquote(revoke_match.group(1)), base)
diff --git a/index.html b/index.html
index c6e0a35..4febf10 100644
--- a/index.html
+++ b/index.html
@@ -8,7 +8,7 @@
Helm — HTML Archive
-
+
@@ -121,7 +121,7 @@
Documents, not loose files.
03
Answer before detail
Show the question or decision, short answer, evidence and next action in a visible reading path. Do not make readers reconstruct the point from background.
04
Evidence stays inspectable
Put sources, assumptions, data dates and confidence beside claims. Keep a semantic root, real headings and tables so the report survives beyond its visual skin.
05
Visuals explain
Use a labelled figure for a real comparison, sequence, hierarchy, change or uncertainty. Give it a conclusion, evidence state, scope note and text fallback.
-
06
Sharing is explicit
Publishing creates an immutable, content-addressed intranet copy. It never exposes IndexedDB or silently replaces an existing shared page.
+
06
Publish a Channel
Review the current Revision before publishing. A stable Channel address advances only by an explicit publish, while every published Revision keeps its own immutable, content-addressed URL. Forks start a new identity and preserve their source lineage.
REQUIRED MANIFEST SHAPE
The full, copyable contract, visual report standard, and intranet sharing boundary are versioned in this repository.