From e94bae24576fd5afde1fd313deb040b1706cf781 Mon Sep 17 00:00:00 2001 From: waple0820 Date: Tue, 14 Jul 2026 16:29:08 +0800 Subject: [PATCH] Fix Helm migration and artifact workflows --- README.md | 8 +- app.js | 125 ++++++++++++++++++++++---- channel-store.js | 43 ++++++++- docs/HTML-DOCUMENT-SPEC.md | 9 +- docs/INTRANET-SHARING.md | 24 ++++- docs/REPORT-DESIGN-STANDARD.md | 2 + helm_bridge.py | 48 +++++++++- helm_share_server.py | 38 +++++++- index.html | 17 ++-- scripts/deploy-remote | 153 ++++++++++++++++++++++++++++++++ styles.css | 16 +++- tests/channel-store-smoke.html | 3 +- tests/contract-smoke.html | 13 ++- tests/test_helm_bridge.py | 56 +++++++++++- tests/test_helm_share_server.py | 31 ++++++- validator.js | 7 +- 16 files changed, 547 insertions(+), 46 deletions(-) create mode 100755 scripts/deploy-remote 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

${nodes.map((item, index) => node(item, index * 250)).join('')}
Agents follow the repository contract; owners inspect the retained Revision, mark it reviewed, and explicitly advance its Channel when it is ready to share.
  1. Guide: read the repository contract.
  2. Create: deliver one final HDOC/1.0 file.
  3. 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 `
${String(index + 1).padStart(2, '0')}

${esc(heading)}

${esc(text)}

`; }).join(''); const styles = `:root{color-scheme:light;--paper:#f7f6f1;--ink:#1b232c;--muted:#65717a;--line:#d7dad5;--panel:#ecefe9;--accent:#c9543b}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}main{width:min(980px,calc(100% - 48px));margin:0 auto;padding:34px 0 96px}.topline{display:flex;justify-content:space-between;gap:20px;padding-bottom:18px;border-bottom:1px solid var(--line);color:var(--muted);font:11px ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.08em;text-transform:uppercase}.hero{padding:64px 0 38px;border-bottom:1px solid var(--line)}.eyebrow,.section-index{margin:0 0 13px;color:var(--muted);font:11px ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.1em;text-transform:uppercase}.eyebrow{color:var(--accent)}h1,h2{font-family:Georgia,"Times New Roman",serif;font-weight:500;letter-spacing:-.04em}h1{max-width:760px;margin:0;font-size:clamp(42px,7vw,72px);line-height:.98}h2{margin:0;font-size:29px;line-height:1.1}.summary{max-width:700px;margin:22px 0 0;color:#46535d;font-size:20px;line-height:1.55}.meta{display:flex;gap:7px;flex-wrap:wrap;margin-top:23px}.meta span{padding:5px 7px;border:1px solid var(--line);color:var(--muted);font:10px ui-monospace,SFMono-Regular,Menlo,monospace}.visual-figure{margin:0;padding:30px 0;border-bottom:1px solid var(--line)}.visual-heading{display:flex;justify-content:space-between;gap:28px;align-items:start;margin-bottom:18px}.visual-heading h2{max-width:580px;font-size:24px}.visual-heading>p,.visual-kicker{margin:0;color:var(--muted);font:10px ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.08em;text-transform:uppercase}.visual-kicker{margin-bottom:7px;color:var(--accent)}.visual-figure svg{display:block;width:100%;height:auto;border:1px solid var(--line);background:#fbfbf8}.visual-figure rect{fill:var(--paper);stroke:#9eaaa8}.visual-figure .visual-link{fill:none;stroke:var(--accent);stroke-width:2}.visual-figure marker path{fill:var(--accent)}.visual-number{fill:var(--accent);font:11px ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.08em}.visual-label{fill:var(--ink);font:600 17px ui-sans-serif,system-ui,sans-serif}.visual-detail{fill:var(--muted);font:12px ui-monospace,SFMono-Regular,Menlo,monospace}.visual-figure figcaption{max-width:700px;margin:12px 0 0;color:#536168;font-size:13px;line-height:1.55}.visual-fallback{display:none}.reader-path{display:grid;grid-template-columns:160px minmax(0,1fr);gap:28px;margin:30px 0 0;padding:22px 0;border-bottom:1px solid var(--line)}.reader-path p{margin:0;font-family:Georgia,"Times New Roman",serif;font-size:22px;line-height:1.32;letter-spacing:-.02em}.reader-path .section-index{color:var(--accent)}.report-section{display:grid;grid-template-columns:160px minmax(0,1fr);gap:28px;padding:46px 0;border-bottom:1px solid var(--line)}.report-section>div:last-child{max-width:700px}.report-section p{margin:15px 0 0;color:#46535d;font-size:16px;line-height:1.72}.report-section.evidence{background:linear-gradient(90deg,transparent 0,transparent 160px,var(--panel) 160px,var(--panel) 100%);padding-left:18px;padding-right:24px}.report-section.action>div:last-child{padding-left:20px;border-left:3px solid var(--accent)}.source-note{margin:34px 0 0;color:var(--muted);font-size:13px;line-height:1.6}@media(max-width:700px){main{width:min(100% - 32px,980px)}.topline,.visual-heading,.reader-path,.report-section{display:block}.topline span:last-child{display:none}.visual-heading>p{margin-top:10px}.visual-figure svg{display:none}.visual-fallback{display:grid;gap:7px;margin:14px 0 0;padding-left:20px;color:#46535d;font-size:14px;line-height:1.55}.reader-path .section-index,.report-section .section-index{margin-bottom:12px}.report-section.evidence{margin-left:-16px;margin-right:-16px;padding-left:16px;background:var(--panel)}}`; - return `${esc(artifact.title)}
${esc(artifact.type)} · HDOC/1.0Updated ${esc(artifact.updatedAt.slice(0, 10))}

Evidence original · ${esc(artifact.type)}

${esc(artifact.title)}

${esc(artifact.summary || 'A personal HTML artifact.')}

${artifact.tags.map((tag) => `${esc(tag)}`).join('')}
${visualModule(artifact.type)}${body}

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)}
${esc(artifact.type)} · HDOC/1.0Updated ${esc(artifact.updatedAt.slice(0, 10))}

Evidence original · ${esc(artifact.type)}

${esc(artifact.title)}

${esc(artifact.summary || 'A personal HTML artifact.')}

${artifact.tags.map((tag) => `${esc(tag)}`).join('')}
${visualModule(artifact)}${body}

${esc(sourceNote)}

`; } 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 `
${esc(artifact.type.toUpperCase())} ${state.toUpperCase()}

PROJECT / ${esc(project.name)}

${esc(artifact.title)}

${esc(artifact.summary || 'No summary provided.')}

${artifact.tags.slice(0, 2).map((tag) => `${esc(tag)}`).join('')}${esc(published)}
${dateLabel(artifact.catalogUpdatedAt || artifact.updatedAt)}
`; }).join(''); $('#emptyState').hidden = Boolean(filtered.length); @@ -400,6 +425,16 @@ function stableShare(artifact) { return share && typeof share.stableUrl === 'string' ? share : null; } +function legacyShareRecord(artifact) { + const share = publicationShare(artifact); + return share?.kind === 'legacy' ? share : null; +} + +function activeLegacyShare(artifact) { + const share = legacyShareRecord(artifact); + return share && !share.revokedAt && typeof share.legacyUrl === 'string' ? share : null; +} + function hasChannelIdentity(artifact) { const manifestId = artifact?.validation?.manifest?.id || inspectHtml(artifact?.html || '').manifest?.id; return typeof manifestId === 'string' && manifestId === artifact?.id; @@ -423,10 +458,16 @@ function renderInspector() { $('#selectedWorkflowStatus').textContent = state.toUpperCase(); $('#selectedRevisionLabel').textContent = revisionLabel(artifact); const share = stableShare(artifact); + const legacyShare = activeLegacyShare(artifact); + const legacyRecord = legacyShareRecord(artifact); const revisionShare = publicationShare(artifact); const publishedNumber = artifact.publishedRevisionId ? revisionNumber(artifact, artifact.publishedRevisionId) : null; const ahead = Boolean(artifact.publishedRevisionId && artifact.publishedRevisionId !== artifact.currentRevisionId); - $('#selectedPublishedState').textContent = publishedNumber ? (!share ? `Revision ${String(publishedNumber).padStart(2, '0')} was last published; stable address revoked.` : ahead ? `Current draft is ahead of published revision ${String(publishedNumber).padStart(2, '0')}.` : `Published revision ${String(publishedNumber).padStart(2, '0')} is current.`) : 'Not published'; + $('#selectedPublishedState').textContent = legacyShare + ? `Legacy immutable share for Revision ${String(publishedNumber || 1).padStart(2, '0')} is still public.` + : legacyRecord?.revokedAt + ? `Legacy share for Revision ${String(publishedNumber || 1).padStart(2, '0')} was retired.` + : publishedNumber ? (!share ? `Revision ${String(publishedNumber).padStart(2, '0')} was last published; stable address revoked.` : ahead ? `Current draft is ahead of published revision ${String(publishedNumber).padStart(2, '0')}.` : `Published revision ${String(publishedNumber).padStart(2, '0')} is current.`) : 'Not published'; $('#selectedPublishedState').classList.toggle('is-behind', ahead); $('#reviewButton').hidden = state === 'published'; $('#reviewButton').disabled = state === 'reviewed'; @@ -449,16 +490,24 @@ function renderInspector() { $('#healthHint').textContent = health.valid ? (warnings.length ? `Contract passed · ${warnings.length} catalog or portability warning${warnings.length === 1 ? '' : 's'}.` : 'No contract errors detected.') : `${errors.length} error${errors.length === 1 ? '' : 's'} · ${warnings.length} warning${warnings.length === 1 ? '' : 's'}`; $('#healthIssues').innerHTML = health.issues.slice(0, 3).map((issue) => `
  • ${esc(issue.message)}
  • `).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.

    Open full specification →
    Open report design standard →
    Open sharing boundary →
    <script type="application/json" data-helm-manifest>
     {
    @@ -155,9 +155,10 @@ 

    PROJECT
    CATALOG UPDATED
    SOURCE
    IDENTITY
    FORMAT
    SIZE
    - + +
    @@ -229,7 +230,7 @@

    ARTIFACT HISTORY

    Artifact title

    -
    STABLE ADDRESSNot published
    +
    STABLE ADDRESSNot published
    @@ -238,14 +239,14 @@

    -

    +
    -

    VISUAL COMPARE

    Rendered revision diff
    +

    VISUAL COMPARE

    Rendered revision diff
    -

    Both revisions render at the same viewport. The original HTML remains sandboxed and unchanged.

    +

    Both revisions render at the same viewport. The original HTML remains sandboxed and unchanged.

    Previous revision
    Current revision
    @@ -272,6 +273,6 @@

    - + diff --git a/scripts/deploy-remote b/scripts/deploy-remote new file mode 100755 index 0000000..194e176 --- /dev/null +++ b/scripts/deploy-remote @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# Atomically deploy the committed Helm tree to a remote single-user host. +set -euo pipefail + +remote_host="" +remote_dir="~/apps/html-displayer" +public_base_url="" +port="4173" +tmux_session="helm-html-archive" + +usage() { + cat <<'EOF' +Usage: scripts/deploy-remote --host USER@HOST --public-base-url URL [options] + +Options: + --remote-dir PATH Remote application directory (default: ~/apps/html-displayer) + --port PORT Share server port (default: 4173) + --tmux-session NAME tmux session name (default: helm-html-archive) + +The deploy contains only the committed Git tree. Runtime shares remain in +~/.helm-shares and are never copied or replaced. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --host) remote_host="${2:-}"; shift 2 ;; + --remote-dir) remote_dir="${2:-}"; shift 2 ;; + --public-base-url) public_base_url="${2:-}"; shift 2 ;; + --port) port="${2:-}"; shift 2 ;; + --tmux-session) tmux_session="${2:-}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac +done + +[[ -n "$remote_host" ]] || { echo "--host is required" >&2; exit 2; } +[[ -n "$public_base_url" ]] || { echo "--public-base-url is required" >&2; exit 2; } +[[ "$port" =~ ^[0-9]+$ ]] && (( port > 0 && port < 65536 )) || { echo "--port must be between 1 and 65535" >&2; exit 2; } +[[ "$tmux_session" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "--tmux-session contains unsupported characters" >&2; exit 2; } + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_dir" +git diff --quiet && git diff --cached --quiet || { + echo "Refusing to deploy uncommitted tracked changes. Commit and review first." >&2 + exit 1 +} + +revision="$(git rev-parse --short=12 HEAD)" +archive="$(mktemp "${TMPDIR:-/tmp}/helm-deploy.XXXXXX.tar")" +remote_archive="/tmp/helm-deploy-${revision}-$$.tar" +trap 'rm -f "$archive"' EXIT +git archive --format=tar HEAD >"$archive" +scp -q "$archive" "$remote_host:$remote_archive" + +quote_remote() { printf '%q' "$1"; } +remote_command="bash -s -- $(quote_remote "$remote_dir") $(quote_remote "$remote_archive") $(quote_remote "$port") $(quote_remote "$tmux_session") $(quote_remote "$public_base_url") $(quote_remote "$revision")" + +ssh "$remote_host" "$remote_command" <<'REMOTE' +set -euo pipefail +remote_dir="$1" +archive="$2" +port="$3" +session="$4" +public_url="$5" +revision="$6" + +remote_dir="${remote_dir/#\~/$HOME}" +staging="${remote_dir}.next-${revision}" +previous="${remote_dir}.previous" +share_dir="$HOME/.helm-shares" + +cleanup() { rm -f "$archive"; } +trap cleanup EXIT +rm -rf "$staging" +mkdir -p "$staging" +tar -xf "$archive" -C "$staging" +( + cd "$staging" + python3 -m unittest discover -s tests -p 'test_*.py' +) + +start_server() { + local app_dir="$1" + local launch + printf -v launch 'cd %q && exec env HELM_PUBLIC_BASE_URL=%q HELM_SHARE_DIR=%q python3 helm_share_server.py --host 0.0.0.0 --port %q' \ + "$app_dir" "$public_url" "$share_dir" "$port" + tmux kill-session -t "$session" 2>/dev/null || true + tmux new-session -d -s "$session" "$launch" +} + +rollback_activation() { + # This function is called only after the previous application directory may + # have moved. Keep rollback best-effort even when the original activation + # failure occurred under `set -e`. + set +e + tmux kill-session -t "$session" 2>/dev/null + rm -rf "$remote_dir" + if [[ -e "$previous" || -L "$previous" ]]; then + mv "$previous" "$remote_dir" + start_server "$remote_dir" + rollback_status=$? + else + rollback_status=0 + fi + set -e + return "$rollback_status" +} + +rm -rf "$previous" +if [[ -e "$remote_dir" || -L "$remote_dir" ]]; then + mv "$remote_dir" "$previous" +fi +if ! mv "$staging" "$remote_dir"; then + echo "Helm directory activation failed; rolling back." >&2 + rollback_activation || echo "Rollback also failed; manual recovery is required at $remote_dir." >&2 + exit 1 +fi +if ! start_server "$remote_dir"; then + echo "Helm service start failed; rolling back." >&2 + rollback_activation || echo "Rollback also failed; manual recovery is required at $remote_dir." >&2 + exit 1 +fi + +healthy=false +for _ in $(seq 1 40); do + if python3 - "$port" <<'PY' +import json +import sys +from urllib.request import urlopen + +try: + with urlopen(f"http://127.0.0.1:{sys.argv[1]}/api/share/health", timeout=1) as response: + payload = json.load(response) + raise SystemExit(0 if payload.get("channel_api_version") == "HCHANNEL/1.0" else 1) +except Exception: + raise SystemExit(1) +PY + then + healthy=true + break + fi + sleep 0.25 +done + +if [[ "$healthy" != true ]]; then + echo "New Helm service failed its health check; rolling back." >&2 + rollback_activation || echo "Rollback also failed; manual recovery is required at $remote_dir." >&2 + exit 1 +fi + +echo "Helm ${revision} is healthy at ${public_url}." +REMOTE diff --git a/styles.css b/styles.css index ee3ad0b..1a6309b 100644 --- a/styles.css +++ b/styles.css @@ -1,5 +1,5 @@ :root { --ink:#11151a; --muted:#6f767d; --paper:#f3f2ef; --card:#fcfcfb; --line:#dedfdd; --accent:#ed5a3c; --lime:#c4e878; --navy:#202936; --mono:"DM Mono", monospace; --sans:"Manrope", sans-serif; --serif:"Newsreader", Georgia, serif; } -* { box-sizing:border-box; } [hidden] { display:none !important; } body { margin:0; min-width:1120px; color:var(--ink); background:var(--paper); font-family:var(--sans); font-size:13px; } button,input,textarea,select { font:inherit; } button { cursor:pointer; } .app-shell { display:grid; grid-template-columns:232px minmax(640px,1fr) 286px; min-height:100vh; } +* { box-sizing:border-box; } [hidden] { display:none !important; } body { margin:0; min-width:1120px; color:var(--ink); background:var(--paper); font-family:var(--sans); font-size:13px; } button,input,textarea,select { font:inherit; } button { cursor:pointer; } .app-shell { display:grid; grid-template-columns:232px minmax(640px,1fr) 286px; min-height:100vh; }.app-shell.without-inspector { grid-template-columns:232px minmax(660px,1fr); }.app-shell.without-inspector .inspector { display:none; } .sidebar { display:flex; flex-direction:column; padding:26px 18px 18px; color:#d9e1e8; background:var(--navy); } .brand { display:flex; gap:10px; align-items:flex-start; color:#fff; text-decoration:none; font-size:18px; font-weight:800; letter-spacing:.17em; } .brand small { display:block; margin-top:3px; color:#9da8b4; font-family:var(--mono); font-size:8px; font-weight:500; letter-spacing:.22em; } .brand-icon { display:block; box-sizing:border-box; width:32px; height:32px; flex:0 0 32px; padding:3px; border-radius:8px; background:#f4f3ef; object-fit:contain; } .navigation { display:grid; gap:4px; margin:48px 0 36px; }.nav-item { display:grid; grid-template-columns:22px 1fr auto; align-items:center; width:100%; padding:11px 10px; border:0; border-radius:5px; color:#adb8c3; background:transparent; text-align:left; font-weight:600; }.nav-item:hover,.nav-item.active { color:#fff; background:rgba(255,255,255,.08); }.nav-item b { color:#a8b5c0; font-family:var(--mono); font-size:10px; font-weight:500; }.nav-glyph { color:var(--lime); font-size:16px; }.side-label { margin:0 10px 10px; color:#758394; font-family:var(--mono); font-size:9px; letter-spacing:.13em; }.collections { display:grid; gap:3px; }.collection { display:flex; align-items:center; gap:8px; width:100%; padding:8px 10px; border:0; color:#b1bcc7; background:transparent; text-align:left; }.collection:hover { color:#fff; }.collection i { width:7px; height:7px; border-radius:50%; background:var(--collection-color); }.collection b { margin-left:auto; color:#718092; font-family:var(--mono); font-size:10px; font-weight:400; }.sidebar-bottom { margin-top:auto; }.local-status { display:flex; gap:9px; align-items:center; padding:13px 10px; border-top:1px solid #384352; }.local-status>span { width:7px; height:7px; border-radius:50%; background:var(--lime); box-shadow:0 0 0 4px rgba(196,232,120,.1); }.local-status b { display:block; font-family:var(--mono); font-size:9px; letter-spacing:.1em; }.local-status small { display:block; margin-top:2px; color:#8190a0; font-size:10px; }.text-button { display:flex; justify-content:space-between; width:100%; padding:10px; border:0; color:#8795a5; background:transparent; font-size:11px; text-align:left; }.text-button span { font-family:var(--mono); } .workspace { overflow:hidden; padding-bottom:64px; }.topbar { display:flex; justify-content:space-between; align-items:center; height:82px; padding:0 38px; border-bottom:1px solid var(--line); background:rgba(252,252,251,.54); }.crumb { display:flex; gap:10px; align-items:center; color:#7c8287; font-family:var(--mono); font-size:10px; letter-spacing:.08em; }.crumb i { width:3px; height:3px; border-radius:50%; background:#b9bcb9; }.crumb b { color:#242b31; font-weight:500; }.top-actions { display:flex; gap:8px; align-items:center; }.search-box { display:flex; align-items:center; width:228px; height:35px; padding:0 8px; border:1px solid var(--line); border-radius:4px; color:#788087; background:#fff; }.search-box>span { margin-right:6px; font-family:var(--serif); font-size:24px; line-height:0; transform:rotate(-15deg); }.search-box input { width:100%; border:0; outline:0; color:var(--ink); background:transparent; font-size:11px; }.search-box input::placeholder { color:#a1a6aa; }.search-box kbd { padding:2px 4px; border:1px solid #e1e1df; border-radius:3px; color:#969c9f; background:#f8f8f7; font-family:var(--mono); font-size:8px; white-space:nowrap; }.button { display:inline-flex; align-items:center; justify-content:center; gap:7px; min-height:35px; padding:0 12px; border:1px solid transparent; border-radius:4px; font-size:11px; font-weight:700; }.button-primary { color:#fff; background:var(--ink); }.button-primary:hover { background:#313940; }.button-quiet { border-color:var(--line); color:#3c454d; background:#fff; }.button-quiet:hover { border-color:#aab0b2; } @@ -55,6 +55,12 @@ .lineage-record { margin:0 0 8px; padding:10px; border:1px solid #d8deda; background:#f7f8f5; } .lineage-record p { margin:6px 0 8px; color:#667178; font-size:9px; line-height:1.45; } .lineage-record a { color:#315d4b; } +.fork-handoff { margin:0 0 8px; padding:10px; border:1px solid #d8d3c4; background:#faf7ee; } +.fork-handoff>span { color:#8b744d; font:8px var(--mono); letter-spacing:.08em; } +.fork-handoff p { margin:6px 0 9px; color:#6d6659; font-size:9px; line-height:1.5; } +.fork-handoff code { color:#5b482a; font:8px var(--mono); overflow-wrap:anywhere; } +.fork-handoff .button { width:100%; min-height:31px; } +.button:disabled { border-color:#d9dcda; color:#92999c; background:#eef0ed; box-shadow:none; cursor:not-allowed; opacity:.82; transform:none; } .fork-button { width:100%; margin:0 0 8px; } .reader-identity { display:flex; min-width:0; align-items:center; } .reader-identity b { max-width:min(38vw,520px); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } @@ -68,6 +74,7 @@ .artifact-workspace-address { min-width:0; padding-left:17px; border-left:1px solid var(--line); } .artifact-workspace-address span,.revision-rail-heading span { display:block; color:#899296; font:8px var(--mono); letter-spacing:.08em; } .artifact-workspace-address a { display:block; margin-top:4px; overflow:hidden; color:#3c6655; font:9px var(--mono); text-overflow:ellipsis; white-space:nowrap; } +.artifact-workspace-address a[aria-disabled="true"] { color:#8c9496; cursor:default; text-decoration:none; } .artifact-workspace { display:grid; grid-template-columns:245px minmax(0,1fr); min-height:0; } .revision-rail { display:flex; min-height:0; flex-direction:column; padding:20px 16px 16px; border-right:1px solid var(--line); background:#f1f3f0; } .revision-rail-heading { display:flex; justify-content:space-between; align-items:center; padding:0 3px 12px; border-bottom:1px solid #d7dcd7; } @@ -97,6 +104,9 @@ .visual-diff section { display:grid; grid-template-rows:32px minmax(0,1fr); min-width:0; overflow:hidden; border:1px solid #d5dad6; background:#fff; } .visual-diff section>header { display:flex; align-items:center; padding:0 10px; border-bottom:1px solid #dde1de; color:#657178; background:#f4f6f3; font:8px var(--mono); letter-spacing:.05em; text-transform:uppercase; } .visual-diff iframe { width:100%; height:100%; border:0; background:#fff; } +.compare-workspace.is-single-revision .compare-selectors { display:none; } +.compare-workspace.is-single-revision .visual-diff { grid-template-columns:minmax(0,1fr); } +.compare-workspace.is-single-revision .visual-diff section:first-child { display:none; } @media (max-width:1250px) { .app-shell { grid-template-columns:206px minmax(570px,1fr) 252px; }.sidebar { padding-left:14px; padding-right:14px; }.topbar,.view { padding-left:27px; padding-right:27px; }.top-actions .button-quiet { display:none; }.protocol-card { grid-template-columns:34px 1fr; }.outline-button { grid-column:2; justify-self:start; padding-left:0; border-left:0; }.template-grid { grid-template-columns:repeat(2,1fr); }.template-card:last-child { grid-column:span 2; }.inspector { padding-left:16px; padding-right:16px; } } /* A gallery-style workbench layer: semantic controls, visible state, and calm document previews. */ @@ -181,6 +191,9 @@ html[data-theme="dark"] .template-sheet { box-shadow:inset 0 0 0 6px rgba(255,25 html[data-theme="dark"] .card-stage { border-color:rgba(219,232,235,.12); filter:saturate(.76) brightness(.78); } html[data-theme="dark"] .clear-filter-button { color:#aab6ba; } html[data-theme="dark"] .reader-loading { color:#dce3e5; background:#182023; } html[data-theme="dark"] .reader-loading small { color:#98a5aa; } html[data-theme="dark"] .revision-state,html[data-theme="dark"] .lineage-record,html[data-theme="dark"] .revision-rail { border-color:#3b474a; background:#20292c; } +html[data-theme="dark"] .fork-handoff { border-color:#5e543d; background:#29261f; } +html[data-theme="dark"] .fork-handoff p { color:#b8ad98; } +html[data-theme="dark"] .fork-handoff code,html[data-theme="dark"] .fork-handoff>span { color:#dcc28d; } html[data-theme="dark"] .revision-state-heading strong,html[data-theme="dark"] .compare-toolbar strong,html[data-theme="dark"] .artifact-workspace-header h2,html[data-theme="dark"] .revision-timeline b { color:#e1e8e9; } html[data-theme="dark"] .revision-state>p,html[data-theme="dark"] .lineage-record p,html[data-theme="dark"] .compare-note,html[data-theme="dark"] .revision-timeline small { color:#9eaaae; } html[data-theme="dark"] .revision-state-actions,html[data-theme="dark"] .revision-rail-heading { border-color:#3b474a; } @@ -195,6 +208,7 @@ html[data-theme="dark"] .visual-diff section>header { border-color:#3b474a; colo .reader-modal.has-error .reader-spinner { border-color:#d7cbc8; border-top-color:#a95340; animation:none; } @media (max-width:1250px) { .appearance-control { display:none; } .protocol-card { grid-template-columns:34px 1fr; } .protocol-rail { grid-column:2; display:grid; grid-template-columns:1fr auto; align-items:end; min-height:auto; gap:15px; } .protocol-stats { max-width:236px; } .protocol-rail .outline-button { border-left:1px solid #53606d; padding:9px 0 9px 17px; } } +@media (min-width:1071px) and (max-width:1250px) { .app-shell.without-inspector { grid-template-columns:206px minmax(570px,1fr); } } @media (max-width:1070px) { .app-shell { grid-template-columns:206px minmax(0,1fr); } .inspector { display:none; } .topbar,.view { padding-right:27px; } } @media (max-width:900px) { .artifact-workspace { grid-template-columns:196px minmax(0,1fr); } .artifact-workspace-header { grid-template-columns:minmax(190px,1fr) minmax(180px,300px) auto; gap:15px; } .compare-workspace { padding-left:14px; padding-right:14px; } } @media (max-width:760px) { .app-shell { display:block; } .sidebar { display:grid; grid-template-columns:auto 1fr; gap:13px; min-height:0; padding:14px 17px; } .brand { align-self:center; font-size:15px; } .brand-icon { width:28px; height:28px; flex-basis:28px; } .navigation { display:flex; justify-content:flex-end; gap:2px; margin:0; } .nav-item { display:flex; width:auto; padding:8px; font-size:0; } .nav-glyph { font-size:17px; } .nav-item b,.side-label,.collections,.sidebar-bottom { display:none; } .topbar { flex-wrap:wrap; gap:11px; height:auto; min-height:62px; padding:13px 18px; } .top-actions { width:100%; } .search-box { flex:1; width:auto; } .top-actions .button-quiet { display:none; } .view { padding:34px 18px 0; } .view-heading { display:block; margin-bottom:28px; } .view h1 { font-size:38px; } .storage-stat { margin-top:25px; } .protocol-card { grid-template-columns:1fr; gap:15px; padding:19px; } .protocol-index { display:none; } .protocol-rail { grid-column:auto; grid-template-columns:1fr; gap:14px; } .protocol-stats { max-width:none; } .protocol-rail .outline-button { justify-content:space-between; padding:9px 0 0; border-top:1px solid #53606d; border-left:0; } .library-toolbar { display:block; } .toolbar-actions { justify-content:space-between; margin-top:15px; } .filter-tabs { flex-wrap:nowrap; padding-bottom:2px; overflow-x:auto; } .filter { flex:0 0 auto; } .document-grid,.template-grid { grid-template-columns:1fr; } .template-card:last-child { grid-column:auto; } .contract-intro,.manifest-example { grid-template-columns:1fr; } .contract-intro p { grid-column:auto; } .contract-grid { grid-template-columns:1fr; } .contract-grid section:nth-child(odd) { border-right:0; } .reader-modal { width:calc(100vw - 20px); height:calc(100vh - 20px); } .reader-identity .eyebrow,.reader-toolbar #readerExport { display:none; } .reader-identity b { max-width:34vw; } .reader-revision-state { margin-left:6px; } .artifact-workspace-modal { width:calc(100vw - 16px); height:calc(100vh - 16px); } .artifact-workspace-shell { grid-template-rows:58px minmax(0,1fr); } .artifact-workspace-header { grid-template-columns:minmax(0,1fr) auto; padding-left:15px; } .artifact-workspace-header h2 { font-size:18px; } .artifact-workspace-address { display:none; } .artifact-workspace { grid-template-columns:1fr; grid-template-rows:162px minmax(0,1fr); } .revision-rail { padding:12px 13px; border-right:0; border-bottom:1px solid var(--line); } .revision-rail-heading { padding-bottom:7px; } .revision-timeline { display:flex; gap:9px; padding:8px 0; overflow-x:auto; } .revision-timeline li { min-width:142px; margin:0; padding:6px 9px; border:1px solid #cbd2ce; } .revision-timeline li::before { display:none; } .revision-rail>.button { align-self:flex-end; width:auto; min-height:29px; } .compare-workspace { padding:12px; overflow:auto; } .compare-toolbar { align-items:flex-start; } .compare-toolbar>div:first-child { display:none; } .compare-selectors { width:100%; justify-content:flex-end; } .compare-selectors label { flex:1; } .compare-selectors select { width:100%; min-width:0; } .compare-note { margin-top:8px; } .visual-diff { grid-template-columns:repeat(2,minmax(390px,1fr)); height:calc(100% - 62px); overflow-x:auto; } } diff --git a/tests/channel-store-smoke.html b/tests/channel-store-smoke.html index 0511a7e..b115132 100644 --- a/tests/channel-store-smoke.html +++ b/tests/channel-store-smoke.html @@ -48,10 +48,11 @@

    Helm Channels store smoke test

    let artifacts = await store.listArtifacts({ includeArchived: true }); assert(artifacts.length === 1, 'legacy document migrates once'); - assert(artifacts[0].status === 'published', 'legacy share becomes published status'); + assert(artifacts[0].status === 'published', 'legacy share remains visibly published'); let revisions = await store.listRevisions('artifact-one'); assert(revisions.length === 1 && revisions[0].html === originalHtml, 'migration preserves original HTML bytes'); assert(artifacts[0].publishedRevisionId === revisions[0].id, 'published revision is explicit'); + assert(revisions[0].share.kind === 'legacy' && revisions[0].share.legacyUrl === '/share/original.html' && !revisions[0].share.stableUrl, 'legacy one-shot share is explicit and never impersonates a stable Channel'); await store.migrateLegacyDocuments(); revisions = await store.listRevisions('artifact-one'); diff --git a/tests/contract-smoke.html b/tests/contract-smoke.html index f0ec5c0..180fbb5 100644 --- a/tests/contract-smoke.html +++ b/tests/contract-smoke.html @@ -12,8 +12,17 @@ return { path, validation: window.HelmValidator.validate(html) }; })); const failed = results.filter(({ validation }) => !validation.valid); - document.body.dataset.status = failed.length ? 'failed' : 'passed'; - document.body.textContent = failed.length ? JSON.stringify(failed) : 'passed'; + const relativeSource = (await (await fetch('../templates/reference-note.html')).text()) + .replace('', '

    Stage two report

    Chart'); + const relativeValidation = window.HelmValidator.validate(relativeSource); + const issueCodes = new Set(relativeValidation.issues.map(({ code }) => code)); + const portabilityPassed = relativeValidation.score < 100 + && issueCodes.has('link-resource-local') + && issueCodes.has('media-resource-local'); + document.body.dataset.status = failed.length || !portabilityPassed ? 'failed' : 'passed'; + document.body.textContent = failed.length || !portabilityPassed + ? JSON.stringify({ failed, relativeValidation }) + : 'passed'; })().catch((error) => { document.body.dataset.status = 'failed'; document.body.textContent = String(error); diff --git a/tests/test_helm_bridge.py b/tests/test_helm_bridge.py index 3cc24c1..4e134b3 100644 --- a/tests/test_helm_bridge.py +++ b/tests/test_helm_bridge.py @@ -1,13 +1,15 @@ #!/usr/bin/env python3 import sys import tempfile +import threading import unittest from stat import S_IMODE from pathlib import Path +from urllib.request import Request, urlopen sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from helm_bridge import BridgeCatalog, ContractError, validate_hdoc +from helm_bridge import BridgeCatalog, BridgeHTTPServer, ContractError, is_allowed_browser_origin, validate_hdoc def document(document_id="agent-report", title="Agent report", body="Evidence", project=None): @@ -23,6 +25,58 @@ def test_validates_and_rejects_executable_html(self): with self.assertRaises(ContractError): validate_hdoc(document().replace("", "")) + def test_portability_warnings_cover_relative_and_remote_dependencies(self): + relative = document(body='Stage twoChart') + _, warnings = validate_hdoc(relative) + self.assertTrue(any("relative files or links" in warning for warning in warnings)) + + remote_media = document(body='Chart') + _, warnings = validate_hdoc(remote_media) + self.assertTrue(any("remote resources" in warning for warning in warnings)) + + portable_links = document(body='FindingOwnerDot') + _, warnings = validate_hdoc(portable_links) + self.assertEqual(warnings, []) + + def test_cors_accepts_loopback_on_any_port_but_not_arbitrary_origins(self): + for origin in ( + "http://127.0.0.1:4173", + "http://127.0.0.1:4182", + "http://localhost:9000", + "https://[::1]:4443", + ): + with self.subTest(origin=origin): + self.assertTrue(is_allowed_browser_origin(origin)) + for origin in ( + "https://evil.example", + "http://localhost.evil.example:4173", + "http://user@localhost:4173", + "http://localhost:4173/path", + "null", + ): + with self.subTest(origin=origin): + self.assertFalse(is_allowed_browser_origin(origin)) + self.assertTrue(is_allowed_browser_origin("https://helm.example", {"https://helm.example"})) + + def test_http_cors_reflects_only_allowed_origin(self): + with tempfile.TemporaryDirectory() as directory: + catalog = BridgeCatalog(Path(directory)) + spec_path = Path(directory) / "spec.md" + spec_path.write_text("contract", encoding="utf-8") + server = BridgeHTTPServer(("127.0.0.1", 0), catalog, "token", spec_path, set()) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + endpoint = f"http://127.0.0.1:{server.server_port}/v1/health" + try: + with urlopen(Request(endpoint, headers={"Origin": "http://localhost:4182"})) as response: + self.assertEqual(response.headers.get("Access-Control-Allow-Origin"), "http://localhost:4182") + with urlopen(Request(endpoint, headers={"Origin": "https://evil.example"})) as response: + self.assertIsNone(response.headers.get("Access-Control-Allow-Origin")) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + def test_idempotency_revision_history_and_exact_originals(self): source = document(body="Exact original text.").encode("utf-8") with tempfile.TemporaryDirectory() as directory: diff --git a/tests/test_helm_share_server.py b/tests/test_helm_share_server.py index 1769b36..57a4927 100644 --- a/tests/test_helm_share_server.py +++ b/tests/test_helm_share_server.py @@ -1,4 +1,5 @@ import http.client +import hashlib import json import tempfile import threading @@ -7,7 +8,7 @@ from stat import S_IMODE from helm_bridge import ContractError -from helm_share_server import ChannelConflictError, ShareHTTPServer, ShareStore +from helm_share_server import ChannelConflictError, ChannelNotFoundError, ShareHTTPServer, ShareStore def hdoc(title="Shared report", summary="A report shared on the intranet."): @@ -31,6 +32,20 @@ def test_legacy_publish_is_content_addressed_and_idempotent(self): self.assertNotEqual(first["filename"], revised["filename"]) self.assertEqual(hdoc(), store.resolve(first["filename"]).read_bytes()) + def test_legacy_revoke_requires_the_exact_path_and_digest(self): + with tempfile.TemporaryDirectory() as directory: + store = ShareStore(Path(directory)) + published = store.publish(hdoc()) + public_path = f'/share/{published["filename"]}' + with self.assertRaises(ChannelConflictError): + store.revoke_legacy(public_path, "0" * 64) + self.assertIsNotNone(store.resolve(published["filename"])) + revoked = store.revoke_legacy(public_path, published["sha256"]) + self.assertEqual("revoked", revoked["state"]) + self.assertIsNone(store.resolve(published["filename"])) + with self.assertRaises(ChannelNotFoundError): + store.revoke_legacy("/share/channels.json", published["sha256"]) + def test_channel_publish_updates_pointer_without_mutating_revisions(self): with tempfile.TemporaryDirectory() as directory: store = ShareStore(Path(directory)) @@ -182,6 +197,20 @@ def test_legacy_share_api_and_url_remain_compatible(self): self.assertEqual((200, hdoc()), (get_status, body)) self.assertIn("immutable", headers["Cache-Control"]) + wrong_status, _, _ = self.request("POST", "/api/share/revoke", {"path": published["path"], "sha256": "0" * 64}) + self.assertEqual(409, wrong_status) + self.assertEqual(200, self.request("GET", published["path"])[0]) + revoke_status, _, revoke_body = self.request("POST", "/api/share/revoke", {"path": published["path"], "sha256": published["sha256"]}) + self.assertEqual((200, "revoked"), (revoke_status, json.loads(revoke_body)["state"])) + self.assertEqual(404, self.request("GET", published["path"])[0]) + + def test_legacy_revoke_rejects_arbitrary_share_root_files(self): + channel_file = self.server.store.channel_catalog_path + channel_file.write_text("protected", encoding="utf-8") + status, _, _ = self.request("POST", "/api/share/revoke", {"path": "/share/channels.json", "sha256": hashlib.sha256(b"protected").hexdigest()}) + self.assertEqual(404, status) + self.assertTrue(channel_file.exists()) + def test_owner_catalog_and_path_validation(self): (self.root / ".git").mkdir() (self.root / ".git" / "config").write_text("private", encoding="utf-8") diff --git a/validator.js b/validator.js index 9eed0d7..0638761 100644 --- a/validator.js +++ b/validator.js @@ -260,6 +260,9 @@ if (anchor.getAttribute('target') === '_blank' && !/\bnoopener\b/i.test(anchor.getAttribute('rel') || '')) { issue(issues, 'link-noopener', 'warning', 'Add rel="noopener" to target="_blank" links.', 'a[href]'); } + if (isLikelyRelativeUrl(href)) { + issue(issues, 'link-resource-local', 'warning', `Replace the relative link \`${href}\` with an absolute URL or preserve the referenced content in this artifact; sibling files are not carried with a standalone HTML document.`, 'a[href]'); + } }); rootElement.querySelectorAll('img').forEach((image) => { @@ -298,7 +301,7 @@ if (isLikelyRelativeUrl(url)) { issue(issues, 'css-resource-local', 'warning', `Inline the CSS resource \`${url}\` or use a data URL so the artifact remains portable.`, 'style'); } else if (isRemoteUrl(url)) { - issue(issues, 'css-resource-remote', 'info', `Remote CSS resource \`${url}\` is only safe as a progressive enhancement; keep the document usable without it.`, 'style'); + issue(issues, 'css-resource-remote', 'warning', `Remote CSS resource \`${url}\` is only safe as a progressive enhancement; keep the document usable without it.`, 'style'); } } }); @@ -308,7 +311,7 @@ if (isLikelyRelativeUrl(src)) { issue(issues, 'media-resource-local', 'warning', `Inline the ${node.tagName.toLowerCase()} resource \`${src}\` or make it non-essential; relative files are not part of a standalone HTML artifact.`, node.tagName.toLowerCase()); } else if (isRemoteUrl(src)) { - issue(issues, 'media-resource-remote', 'info', `Remote ${node.tagName.toLowerCase()} resource \`${src}\` should remain a progressive enhancement.`, node.tagName.toLowerCase()); + issue(issues, 'media-resource-remote', 'warning', `Remote ${node.tagName.toLowerCase()} resource \`${src}\` should remain a progressive enhancement.`, node.tagName.toLowerCase()); } });