diff --git a/AGENTS.md b/AGENTS.md
index 4e5ae77..5a0af5a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,4 +16,4 @@ Then submit the final file exactly once after local validation:
scripts/helm-submit output.html --source "your-agent-name"
```
-Run the submission from the target Codex/project root. Helm records that workspace as the artifact's catalog project when the source manifest does not already declare `project`; if needed, pass `--project-id` and `--project-name` explicitly. The command only hands the artifact to the owner's inbox. It does not grant direct access to the browser library. On `409`, keep the prior artifact unchanged and choose a genuinely new stable identity for a new document; on `422`, correct the HDOC contract violation before resubmitting.
+Run the submission from the target Codex/project root. Helm records that workspace as the artifact's catalog project when the source manifest does not already declare `project`; if needed, pass `--project-id` and `--project-name` explicitly. The command only hands the artifact revision to the owner's inbox. It does not grant direct access to the browser library. Keep the same manifest ID when revising the same logical artifact; Helm preserves distinct bytes as immutable revisions and asks the owner before advancing the current version. Use a new ID for a different document or explicit fork. On `422`, correct the HDOC contract violation before resubmitting.
diff --git a/AI-GUIDE.md b/AI-GUIDE.md
index db4d6ae..029c345 100644
--- a/AI-GUIDE.md
+++ b/AI-GUIDE.md
@@ -25,4 +25,4 @@ The shipped templates are the default starting point. Use `research-dossier.html
For a fresh local clone, run [`scripts/helm-agent-bootstrap`](scripts/helm-agent-bootstrap) once. Then write the final `.html` file first and submit that exact file once with [`scripts/helm-submit`](scripts/helm-submit). Run the command from the target project root so the Bridge can attach that workspace to legacy files that do not yet declare `manifest.project`; use `--project-id` and `--project-name` when the working directory is not the target project. Do not send partial drafts. A successful Bridge response means the artifact is ready in the owner's **Agent inbox** for explicit review and import; it does not mean the browser library was changed.
-Never expose the token in a generated artifact, repository, task log, or prompt. On `409 Conflict`, do not overwrite or silently alter the existing document ID. On `422`, fix the reported HDOC contract issue and resubmit the same intended artifact.
+Never expose the token in a generated artifact, repository, task log, or prompt. Reuse the manifest ID only for a revision of the same logical artifact; Helm appends distinct bytes as an immutable Revision and never overwrites the prior source. Use a new ID for a different document or explicit fork. On `422`, fix the reported HDOC contract issue and resubmit the same intended artifact.
diff --git a/README.md b/README.md
index 1111c40..8e3c4d1 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,7 @@ Helm keeps finished HTML reports, briefs, dashboards, and research as durable pr
- **Originals remain evidence.** Helm indexes, exports, backs up, and shares the exact HTML bytes; it never silently rewrites the source artifact.
- **Agents have a contract.** `HDOC/1.0`, report templates, and the checked-in agent guide make artifacts portable across projects and agents.
- **The owner keeps control.** Agent output arrives in a reviewable inbox. Browser storage, imports, and intranet publication remain deliberate human actions.
+- **Artifacts have history.** Immutable revisions, Draft / Reviewed / Published state, visual comparison, Fork lineage, and a stable Channel address keep change legible without rewriting evidence.
## Start in one minute
@@ -64,7 +65,7 @@ The final artifact appears in **Agent inbox** for review and explicit import. Re
| Path | Purpose |
| --- | --- |
-| [`index.html`](index.html), [`app.js`](app.js), [`styles.css`](styles.css) | Static browser library, local catalog, safe reader, and interface. |
+| [`index.html`](index.html), [`app.js`](app.js), [`channel-store.js`](channel-store.js), [`styles.css`](styles.css) | Static browser library, Artifact / Revision store, safe reader, and interface. |
| [`validator.js`](validator.js) | Browser-side `HDOC/1.0` inspection and portability warnings. |
| [`helm_bridge.py`](helm_bridge.py) | Loopback-only agent ingress and immutable inbox records. |
| [`helm_share_server.py`](helm_share_server.py) | Static app plus owner-only publication of immutable read-only share links. |
@@ -99,6 +100,7 @@ Helm is intentionally a single-person, local-first archive. It does not provide
- [`docs/AGENT-BRIDGE.md`](docs/AGENT-BRIDGE.md) — Bridge API, security model, and conflict semantics.
- [`docs/LOCAL-ARCHIVE-LAYOUT.md`](docs/LOCAL-ARCHIVE-LAYOUT.md) — portable `HARC/1.0` archive format.
- [`docs/INTRANET-SHARING.md`](docs/INTRANET-SHARING.md) — owner-controlled immutable sharing model.
+- [`docs/CHANNELS.md`](docs/CHANNELS.md) — Artifact, Revision, publication, comparison, and Fork semantics.
## Contributing and security
diff --git a/app.js b/app.js
index e542d3e..461d7ef 100644
--- a/app.js
+++ b/app.js
@@ -1,7 +1,3 @@
-const DB_NAME = 'helm-html-archive';
-const DB_VERSION = 3;
-const STORE = 'documents';
-const SETTINGS = 'settings';
const SCHEMA = 'HDOC/1.0';
const DOCUMENT_TYPES = new Set(['report', 'brief', 'reference', 'dashboard', 'note']);
const PROJECT_ID_PATTERN = /^[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?$/;
@@ -9,7 +5,8 @@ const UNASSIGNED_PROJECT = Object.freeze({ id: 'unassigned', name: 'Needs projec
const MAX_IMPORT_BYTES = 5 * 1024 * 1024;
const MAX_SEARCH_TEXT = 250000;
const AGENT_BRIDGE_URL = 'http://127.0.0.1:4175';
-const SHARE_API_URL = '/api/share';
+const CHANNEL_API_URL = '/api/channels';
+const channelRepository = globalThis.HelmChannelStore?.defaultRepository;
const templates = [
{ id: 'research-report', title: 'Research dossier', type: 'report', tags: ['research', 'evidence'], summary: 'Question → answer → evidence → recommendation.', accent: '#e7e6df' },
@@ -38,6 +35,7 @@ let appearanceMode = 'system';
let readerArtifactId = null;
let readerLoadToken = 0;
let readerSlowTimer = null;
+const lineageSources = new Map();
const APPEARANCE_MODES = new Set(['light', 'dark', 'system']);
@@ -92,62 +90,48 @@ function knownProjects() {
});
}
-function openDatabase() {
- return new Promise((resolve, reject) => {
- const request = indexedDB.open(DB_NAME, DB_VERSION);
- request.onupgradeneeded = () => {
- const db = request.result;
- if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE, { keyPath: 'id' });
- if (!db.objectStoreNames.contains(SETTINGS)) db.createObjectStore(SETTINGS, { keyPath: 'key' });
- };
- request.onsuccess = () => resolve(request.result);
- request.onerror = () => reject(request.error);
- });
+async function getAll() {
+ if (!channelRepository) throw new Error('Helm Channels repository is unavailable.');
+ const records = await channelRepository.listDocuments();
+ return Promise.all(records.filter(Boolean).map(async (record) => ({ ...record, revisions: await channelRepository.listRevisions(record.id) })));
}
-async function getAll() {
- const db = await openDatabase();
- return new Promise((resolve, reject) => {
- const request = db.transaction(STORE, 'readonly').objectStore(STORE).getAll();
- request.onsuccess = () => resolve(request.result);
- request.onerror = () => reject(request.error);
- });
+async function loadLineageSource(id, seen = new Set()) {
+ if (!id || seen.has(id) || documents.some((artifact) => artifact.id === id) || lineageSources.has(id)) return;
+ seen.add(id);
+ const document = await channelRepository.getDocument(id);
+ if (!document) return;
+ const source = { ...document, revisions: await channelRepository.listRevisions(id) };
+ lineageSources.set(id, source);
+ if (source.forkedFrom) await loadLineageSource(source.forkedFrom.artifactId, seen);
+}
+
+async function loadRequiredLineageSources() {
+ for (const artifact of documents) {
+ if (artifact.forkedFrom) await loadLineageSource(artifact.forkedFrom.artifactId);
+ }
}
async function saveDocument(artifact) {
- const db = await openDatabase();
- return new Promise((resolve, reject) => {
- const request = db.transaction(STORE, 'readwrite').objectStore(STORE).put(artifact);
- request.onsuccess = () => resolve();
- request.onerror = () => reject(request.error);
+ const existing = await channelRepository.getArtifact(artifact.id);
+ const result = await channelRepository.createOrRevise(artifact, {
+ artifactId: artifact.id,
+ expectedCurrentRevisionId: existing?.currentRevisionId,
+ updateCatalog: Boolean(existing)
});
+ return { ...result.document, revisions: await channelRepository.listRevisions(artifact.id) };
}
-async function removeDocument(id) {
- const db = await openDatabase();
- return new Promise((resolve, reject) => {
- const request = db.transaction(STORE, 'readwrite').objectStore(STORE).delete(id);
- request.onsuccess = () => resolve();
- request.onerror = () => reject(request.error);
- });
+async function removeDocument(id, { hard = true } = {}) {
+ return channelRepository.deleteArtifact(id, { hard });
}
async function getSetting(key) {
- const db = await openDatabase();
- return new Promise((resolve, reject) => {
- const request = db.transaction(SETTINGS, 'readonly').objectStore(SETTINGS).get(key);
- request.onsuccess = () => resolve(request.result?.value);
- request.onerror = () => reject(request.error);
- });
+ return channelRepository.getSetting(key);
}
async function setSetting(key, value) {
- const db = await openDatabase();
- return new Promise((resolve, reject) => {
- const request = db.transaction(SETTINGS, 'readwrite').objectStore(SETTINGS).put({ key, value });
- request.onsuccess = () => resolve();
- request.onerror = () => reject(request.error);
- });
+ return channelRepository.setSetting(key, value);
}
function setAppearance(mode, persist = false) {
@@ -272,18 +256,18 @@ function enrichArtifact(record, options = {}) {
async function initialise() {
try {
+ if (!channelRepository) throw new Error('Helm Channels repository is unavailable.');
+ await channelRepository.open();
setAppearance(await getSetting('appearanceMode'));
const stored = await getAll();
const initialized = await getSetting('libraryInitialized');
if (!initialized && !stored.length) {
const seeded = seedDocuments.map((artifact) => ({ ...artifact, html: seedHtml(artifact) }));
- documents = seeded.map((artifact) => enrichArtifact(artifact, { takenIds: new Set() }));
- await Promise.all(documents.map(saveDocument));
+ documents = await Promise.all(seeded.map((artifact) => saveDocument(enrichArtifact(artifact, { takenIds: new Set() }))));
} else {
- const takenIds = new Set();
- documents = stored.map((artifact) => enrichArtifact(artifact.html ? artifact : { ...artifact, html: seedHtml(artifact) }, { takenIds, preserveId: true }));
- await Promise.all(documents.map(saveDocument));
+ documents = stored;
}
+ await loadRequiredLineageSources();
await setSetting('libraryInitialized', true);
archiveFolderHandle = await getSetting('archiveFolderHandle');
selectedId = documents[0]?.id || null;
@@ -361,7 +345,12 @@ function renderLibrary() {
$('#clearLibraryFilters').hidden = !hasActiveLens;
$('#contractReadyCount').textContent = String(readyCount).padStart(2, '0');
$('#librarySourceCount').textContent = String(projectCount).padStart(2, '0');
- $('#documentGrid').innerHTML = filtered.map((artifact, index) => { const project = projectFor(artifact); return `${esc(artifact.type.toUpperCase())} ↗
${esc(artifact.type)} / ${String(index + 1).padStart(2, '0')} ${artifact.validation?.valid ? '✓' : '·'}
PROJECT / ${esc(project.name)}
${esc(artifact.title)} ${esc(artifact.summary || 'No summary provided.')}
${artifact.tags.slice(0, 3).map((tag) => `${esc(tag)} `).join('')}
${dateLabel(artifact.catalogUpdatedAt || artifact.updatedAt)} `; }).join('');
+ $('#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';
+ return `${esc(artifact.type.toUpperCase())} ${state.toUpperCase()} ↗
REV ${String(revisionNumber(artifact)).padStart(2, '0')} / ${esc(artifact.type)} ${artifact.validation?.valid ? '✓' : '·'}
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);
$$('.document-card').forEach((card) => {
card.addEventListener('click', (event) => { if (!event.target.closest('[data-open]')) selectDocument(card.dataset.id); });
@@ -380,6 +369,46 @@ function renderTemplates() {
$$('[data-template]').forEach((button) => button.addEventListener('click', () => openCreateDialog(templates.find((template) => template.id === button.dataset.template))));
}
+function revisionsFor(artifact) {
+ return [...(artifact?.revisions || [])].sort((left, right) => new Date(left.createdAt) - new Date(right.createdAt));
+}
+
+function revisionNumber(artifact, revisionId = artifact?.currentRevisionId) {
+ const index = revisionsFor(artifact).findIndex((revision) => revision.id === revisionId);
+ return index < 0 ? 1 : index + 1;
+}
+
+function workflowState(artifact) {
+ return artifact?.status === 'in-review' ? 'reviewed' : artifact?.status === 'published' ? 'published' : 'draft';
+}
+
+function workflowLabel(artifact) {
+ return workflowState(artifact).replace(/^./, (letter) => letter.toUpperCase());
+}
+
+function publishedRevision(artifact) {
+ return revisionsFor(artifact).find((revision) => revision.id === artifact?.publishedRevisionId) || null;
+}
+
+function publicationShare(artifact) {
+ const share = publishedRevision(artifact)?.share;
+ return share && typeof share === 'object' ? share : null;
+}
+
+function stableShare(artifact) {
+ const share = publicationShare(artifact);
+ return share && typeof share.stableUrl === 'string' ? share : null;
+}
+
+function hasChannelIdentity(artifact) {
+ const manifestId = artifact?.validation?.manifest?.id || inspectHtml(artifact?.html || '').manifest?.id;
+ return typeof manifestId === 'string' && manifestId === artifact?.id;
+}
+
+function revisionLabel(artifact, revisionId = artifact?.currentRevisionId) {
+ return `Revision ${String(revisionNumber(artifact, revisionId)).padStart(2, '0')}`;
+}
+
function renderInspector() {
const artifact = documents.find((item) => item.id === selectedId);
$('#inspectorEmpty').hidden = Boolean(artifact);
@@ -389,6 +418,19 @@ function renderInspector() {
const errors = health.issues.filter((issue) => issue.severity === 'error');
const warnings = health.issues.filter((issue) => issue.severity === 'warning');
$('#selectedType').textContent = artifact.type.toUpperCase();
+ const state = workflowState(artifact);
+ $('#selectedWorkflowStatus').dataset.status = state;
+ $('#selectedWorkflowStatus').textContent = state.toUpperCase();
+ $('#selectedRevisionLabel').textContent = revisionLabel(artifact);
+ const share = stableShare(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').classList.toggle('is-behind', ahead);
+ $('#reviewButton').hidden = state === 'published';
+ $('#reviewButton').disabled = state === 'reviewed';
+ $('#reviewButton').textContent = state === 'draft' ? 'Mark reviewed' : 'Reviewed · ready to publish';
$('#selectedTitle').textContent = artifact.title;
$('#selectedSummary').textContent = artifact.summary || 'No summary provided.';
$('#selectedTags').innerHTML = artifact.tags.map((tag) => `${esc(tag)} `).join('');
@@ -396,7 +438,7 @@ function renderInspector() {
$('#selectedSource').textContent = artifact.source || 'Imported file';
$('#selectedProject').textContent = projectFor(artifact).name;
const identity = $('#selectedIdentity');
- identity.textContent = artifact.identityState === 'catalog-copy' ? `Copy: ${artifact.id}` : artifact.sourceDocumentId ? `Aligned: ${artifact.id}` : `Library: ${artifact.id}`;
+ identity.textContent = artifact.forkedFrom ? `Fork: ${artifact.id}` : artifact.identityState === 'catalog-copy' ? `Copy: ${artifact.id}` : artifact.sourceDocumentId ? `Aligned: ${artifact.id}` : `Library: ${artifact.id}`;
identity.title = `Library ID: ${artifact.id}${artifact.sourceDocumentId ? ` · source manifest ID: ${artifact.sourceDocumentId}` : ''}`;
$('#selectedFormat').textContent = health.hasManifest || health.manifest ? SCHEMA : 'Plain HTML';
$('#selectedSize').textContent = byteLabel(new Blob([artifact.html]).size);
@@ -407,12 +449,16 @@ 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;
- const share = artifact.share && typeof artifact.share.url === 'string' ? artifact.share : null;
- $('#shareButton').disabled = !health.valid;
- $('#shareButton').textContent = share ? 'Copy intranet link' : 'Publish intranet link';
+ $('#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;
- $('#selectedShare').textContent = share?.url || '';
- $('#selectedShare').href = share?.url || '#';
+ $('#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.';
+ $('#lineageRecord').hidden = !artifact.forkedFrom;
+ $('#selectedLineage').textContent = artifact.forkedFrom ? `${artifact.forkedFrom.artifactId} · ${artifact.forkedFrom.revisionId.slice(0, 18)}…` : '';
}
function render() { renderCollections(); renderProjects(); renderLibrary(); renderTemplates(); renderInspector(); $('#archiveDocumentCount').textContent = String(documents.length).padStart(2, '0'); renderFolderStatus(); }
@@ -433,8 +479,8 @@ function showToast(message) {
}
function pendingAgentInboxDocuments() {
- const libraryIds = new Set(documents.map((artifact) => artifact.id));
- return agentInboxDocuments.filter((artifact) => artifact && typeof artifact.id === 'string' && typeof artifact.html === 'string' && !libraryIds.has(artifact.id));
+ const knownHashes = new Set(documents.flatMap((artifact) => (artifact.revisions || []).map((revision) => revision.contentHash)));
+ return agentInboxDocuments.filter((artifact) => artifact && typeof artifact.id === 'string' && typeof artifact.html === 'string' && !knownHashes.has(artifact.sha256));
}
function updateAgentInboxBadge() {
@@ -478,14 +524,14 @@ function renderAgentInbox({ online = true, error = null } = {}) {
return;
}
const pending = pendingAgentInboxDocuments();
- const pendingIds = new Set(pending.map((artifact) => artifact.id));
+ const pendingIds = new Set(pending.map((artifact) => artifact.sha256 || artifact.id));
selectedAgentInboxIds = new Set([...selectedAgentInboxIds].filter((id) => pendingIds.has(id)));
- const selectedCount = pending.filter((artifact) => selectedAgentInboxIds.has(artifact.id)).length;
+ const selectedCount = pending.filter((artifact) => selectedAgentInboxIds.has(artifact.sha256 || artifact.id)).length;
const existing = agentInboxDocuments.length - pending.length;
status.textContent = pending.length ? `${pending.length} artifact${pending.length === 1 ? '' : 's'} ready for review` : 'Inbox is up to date';
hint.textContent = existing ? `${existing} already present in this browser and will never be overwritten.` : 'Select the artifacts to import; Bridge never writes the browser library directly.';
list.innerHTML = pending.length
- ? pending.map((artifact) => `${esc(artifact.title || artifact.id)} ${esc(projectFor(artifact).name)} · ${esc(artifact.type || 'reference').toUpperCase()} · ${esc(artifact.source || 'unnamed-agent')} · ${esc(artifact.id)} `).join('')
+ ? pending.map((artifact) => `${esc(artifact.title || artifact.id)} ${esc(projectFor(artifact).name)} · ${esc(artifact.type || 'reference').toUpperCase()} · ${esc(artifact.source || 'unnamed-agent')} · revision ${esc((artifact.sha256 || '').slice(0, 10))} `).join('')
: 'No new artifacts When an Agent submits a valid HDOC document, it will appear here for your explicit import. ';
list.querySelectorAll('[data-agent-inbox-id]').forEach((input) => input.addEventListener('change', () => {
const id = input.dataset.agentInboxId;
@@ -530,17 +576,18 @@ function openAgentInbox() {
}
async function importAgentInbox() {
- const pending = pendingAgentInboxDocuments().filter((artifact) => selectedAgentInboxIds.has(artifact.id));
+ const pending = pendingAgentInboxDocuments().filter((artifact) => selectedAgentInboxIds.has(artifact.sha256 || artifact.id));
if (!pending.length) { showToast('Select one or more Agent artifacts first.'); return; }
- const takenIds = new Set(documents.map((artifact) => artifact.id));
- const knownSourceIds = sourceIdentitySet();
const accepted = [];
- const duplicates = [];
+ const importedHeads = new Map();
let rejected = 0;
for (const remote of pending) {
try {
- const artifact = enrichArtifact({
- id: remote.id,
+ const storedArtifact = await channelRepository.getArtifact(remote.id);
+ const storedDocument = storedArtifact ? await channelRepository.getDocument(remote.id) : null;
+ const existing = importedHeads.get(remote.id) || documents.find((item) => item.id === remote.id) || lineageSources.get(remote.id) || (storedDocument ? { ...storedDocument, revisions: await channelRepository.listRevisions(remote.id) } : null);
+ const incoming = enrichArtifact({
+ id: existing?.id || remote.id,
sourceDocumentId: remote.source_document_id || remote.id,
title: remote.title,
type: remote.type,
@@ -552,22 +599,31 @@ async function importAgentInbox() {
updatedAt: remote.updated_at,
html: remote.html,
bridge: { source: remote.source, receivedAt: remote.received_at, sha256: remote.sha256 }
- }, { takenIds, existingSourceIds: knownSourceIds, preserveId: false });
- if (artifact.sourceDocumentId) knownSourceIds.add(artifact.sourceDocumentId);
- (artifact.identityState === 'catalog-copy' ? duplicates : accepted).push(artifact);
+ }, { takenIds: new Set(), preserveId: true });
+ const result = await channelRepository.createOrRevise(incoming, {
+ artifactId: incoming.id,
+ expectedCurrentRevisionId: existing?.currentRevisionId,
+ updateCatalog: false
+ });
+ const stored = { ...result.document, revisions: await channelRepository.listRevisions(incoming.id) };
+ accepted.push(stored);
+ importedHeads.set(remote.id, stored);
} catch (error) {
rejected += 1;
console.warn('A Bridge artifact could not be indexed.', error);
}
}
$('#agentInboxDialog').close();
- pending.forEach((artifact) => selectedAgentInboxIds.delete(artifact.id));
- const afterImport = () => refreshAgentInbox();
- if (duplicates.length) showDuplicateImportResolution(accepted, duplicates, { label: 'Agent artifact', skipped: rejected, afterPersist: afterImport });
- else {
- await persistImportedArtifacts(accepted, { label: 'Agent artifact', skipped: rejected });
- await afterImport();
- }
+ pending.forEach((artifact) => selectedAgentInboxIds.delete(artifact.sha256 || artifact.id));
+ const acceptedById = new Map(accepted.map((saved) => [saved.id, saved]));
+ const latestAccepted = [...acceptedById.values()];
+ latestAccepted.forEach((saved) => lineageSources.delete(saved.id));
+ documents = documents.filter((item) => !acceptedById.has(item.id));
+ documents.push(...latestAccepted);
+ if (latestAccepted.length) selectedId = latestAccepted.at(-1).id;
+ render();
+ showToast(`${accepted.length} Agent revision${accepted.length === 1 ? '' : 's'} accepted${rejected ? ` · ${rejected} rejected` : ''}.`);
+ await refreshAgentInbox();
}
async function renderFolderStatus() {
@@ -713,8 +769,7 @@ async function createFromTemplate() {
: templateToCreate.id === 'decision-brief'
? [['Decision and deadline', 'Describe the call that needs to be made, the owner, and the non-negotiable constraints.'], ['Recommendation', 'State the selected path in one direct sentence before explaining the alternatives.'], ['Options and comparison', 'Compare realistic alternatives on the same benefits, costs, risks, and evidence.'], ['Action and checkpoint', 'Name the next action, accountable owner, and date or condition for review.'], ['Risk and reversal condition', 'Record the assumption, counter-signal, or new evidence that would reopen the decision.']]
: [['Pattern in one line', 'Explain the reusable idea in plain language before adding implementation detail.'], ['When to use it', 'State the preconditions, expected benefit, and the case where a simpler alternative is better.'], ['Smallest reliable workflow', 'Describe the fewest dependable steps and the observable result that confirms success.'], ['Caveats and sources', 'Record version sensitivity, constraints, primary sources, and links worth recovering later.']];
- const artifact = enrichArtifact({ ...base, html: articleHtml(base, sections) }, { takenIds: new Set(documents.map((item) => item.id)) });
- await saveDocument(artifact);
+ const artifact = await saveDocument(enrichArtifact({ ...base, html: articleHtml(base, sections) }, { takenIds: new Set(documents.map((item) => item.id)) }));
documents.push(artifact);
selectedId = artifact.id;
$('#createDialog').close();
@@ -728,8 +783,13 @@ function sourceIdentitySet(records = documents) {
return new Set(records.map((artifact) => artifact.sourceDocumentId).filter(Boolean));
}
-function parseHtmlDocument(html, filename, takenIds, existingSourceIds) {
- return enrichArtifact({ html, source: filename }, { fallbackName: filename.replace(/\.html?$/i, ''), takenIds, existingSourceIds });
+function parseHtmlDocument(html, filename, takenIds) {
+ const candidate = enrichArtifact({ html, source: filename }, { fallbackName: filename.replace(/\.html?$/i, ''), takenIds: new Set() });
+ const existing = documents.find((artifact) => artifact.id === candidate.sourceDocumentId) || lineageSources.get(candidate.sourceDocumentId);
+ if (existing) return { ...candidate, id: existing.id, identityState: existing.identityState };
+ candidate.id = uniqueId(candidate.id, candidate.title, takenIds);
+ takenIds.add(candidate.id);
+ return candidate;
}
async function persistImportedArtifacts(artifacts, { label = 'artifact', skipped = 0 } = {}) {
@@ -737,10 +797,17 @@ async function persistImportedArtifacts(artifacts, { label = 'artifact', skipped
if (skipped) showToast(`${skipped} duplicate artifact${skipped === 1 ? '' : 's'} skipped.`);
return;
}
- const outcomes = await Promise.allSettled(artifacts.map(saveDocument));
- const saved = artifacts.filter((_, index) => outcomes[index].status === 'fulfilled');
+ const outcomes = [];
+ for (const artifact of restoreOrder(artifacts)) {
+ try { outcomes.push({ status: 'fulfilled', value: await restoreImportedArtifact(artifact) }); }
+ catch (reason) { outcomes.push({ status: 'rejected', reason }); }
+ }
+ const savedById = new Map(outcomes.filter((outcome) => outcome.status === 'fulfilled').map((outcome) => [outcome.value.id, outcome.value]));
+ const saved = [...savedById.values()];
const failed = artifacts.length - saved.length;
if (saved.length) {
+ saved.forEach((record) => lineageSources.delete(record.id));
+ documents = documents.filter((item) => !saved.some((record) => record.id === item.id));
documents.push(...saved);
selectedId = saved.at(-1).id;
showView('library');
@@ -750,6 +817,64 @@ async function persistImportedArtifacts(artifacts, { label = 'artifact', skipped
showToast(`${saved.length} ${label}${saved.length === 1 ? '' : 's'} added${skipped ? ` · ${skipped} skipped` : ''}${failed ? ` · ${failed} could not be saved` : ''}.`);
}
+function restoreOrder(artifacts) {
+ const pending = [...artifacts];
+ const incomingIds = new Set(pending.map((artifact) => artifact.id));
+ const resolved = new Set(documents.map((artifact) => artifact.id));
+ const ordered = [];
+ while (pending.length) {
+ const index = pending.findIndex((artifact) => {
+ const firstParent = Array.isArray(artifact.revisions) ? artifact.revisions[0]?.parent?.artifactId : null;
+ const dependency = artifact.forkedFrom?.artifactId || firstParent;
+ return !dependency || !incomingIds.has(dependency) || resolved.has(dependency);
+ });
+ const [next] = pending.splice(index < 0 ? 0 : index, 1);
+ ordered.push(next);
+ resolved.add(next.id);
+ }
+ return ordered;
+}
+
+async function restoreImportedArtifact(artifact) {
+ const history = Array.isArray(artifact.revisions) ? artifact.revisions.filter((revision) => revision && typeof revision.html === 'string') : [];
+ if (!history.length || await channelRepository.getArtifact(artifact.id)) return saveDocument(artifact);
+ const catalog = Object.fromEntries(Object.entries(artifact).filter(([key]) => key !== 'revisions' && key !== 'html' && key !== 'validation' && key !== 'contentText' && key !== 'share'));
+ let expectedCurrentRevisionId;
+ for (const revision of history) {
+ const parent = revision.parent && (await channelRepository.getRevision(revision.parent.artifactId, revision.parent.revisionId)) ? revision.parent : undefined;
+ const result = await channelRepository.createOrRevise({
+ ...catalog,
+ html: revision.html,
+ contentText: revision.contentText || '',
+ validation: revision.validation || null,
+ authoredAt: revision.authoredAt,
+ author: revision.author,
+ updatedAt: revision.authoredAt || revision.createdAt || artifact.updatedAt
+ }, {
+ artifactId: artifact.id,
+ expectedCurrentRevisionId,
+ ...(parent ? { parent } : {}),
+ ...(expectedCurrentRevisionId === undefined && artifact.forkedFrom ? { forkedFrom: artifact.forkedFrom } : {}),
+ revisionShare: revision.share || null,
+ updateCatalog: expectedCurrentRevisionId !== undefined
+ });
+ expectedCurrentRevisionId = result.revision.id;
+ }
+ if (artifact.publishedRevisionId && await channelRepository.getRevision(artifact.id, artifact.publishedRevisionId)) {
+ await channelRepository.setStatus(artifact.id, 'published', { revisionId: artifact.publishedRevisionId });
+ }
+ if (artifact.currentRevisionId && await channelRepository.getRevision(artifact.id, artifact.currentRevisionId)) {
+ await channelRepository.setCurrentRevision(artifact.id, artifact.currentRevisionId);
+ }
+ if (artifact.status !== 'published' && ['draft', 'in-review', 'archived'].includes(artifact.status)) await channelRepository.setStatus(artifact.id, artifact.status);
+ return refreshImportedArtifact(artifact.id);
+}
+
+async function refreshImportedArtifact(id) {
+ const document = await channelRepository.getDocument(id);
+ return { ...document, revisions: await channelRepository.listRevisions(id) };
+}
+
function showDuplicateImportResolution(accepted, duplicates, { label = 'artifact', skipped = 0, afterPersist = null } = {}) {
pendingDuplicateImport = { accepted, duplicates, label, skipped, afterPersist };
$('#duplicateImportList').innerHTML = duplicates.map((artifact) => `${esc(artifact.title)} Source manifest ID: ${esc(artifact.sourceDocumentId || 'unknown')} · proposed library ID: ${esc(artifact.id)} `).join('');
@@ -769,23 +894,21 @@ async function importFiles(files) {
const htmlFiles = [...files].filter((file) => /\.html?$/i.test(file.name) || file.type === 'text/html');
if (!htmlFiles.length) { showToast('Choose one or more .html files.'); return; }
const takenIds = new Set(documents.map((artifact) => artifact.id));
- const knownSourceIds = sourceIdentitySet();
const imported = [];
- const duplicates = [];
const rejected = [];
for (const file of htmlFiles) {
if (file.size > MAX_IMPORT_BYTES) { rejected.push(`${file.name} is larger than 5 MB`); continue; }
try {
- const artifact = parseHtmlDocument(await file.text(), file.name, takenIds, knownSourceIds);
- if (artifact.sourceDocumentId) knownSourceIds.add(artifact.sourceDocumentId);
- (artifact.identityState === 'catalog-copy' ? duplicates : imported).push(artifact);
+ const artifact = parseHtmlDocument(await file.text(), file.name, takenIds);
+ const archived = artifact.sourceDocumentId ? await channelRepository.getArtifact(artifact.sourceDocumentId) : null;
+ if (archived) artifact.id = archived.id;
+ imported.push(artifact);
} catch (error) {
rejected.push(`${file.name} could not be indexed`);
console.warn(error);
}
}
- if (duplicates.length) showDuplicateImportResolution(imported, duplicates, { skipped: rejected.length });
- else if (imported.length) await persistImportedArtifacts(imported, { skipped: rejected.length });
+ if (imported.length) await persistImportedArtifacts(imported, { skipped: rejected.length });
else showToast(rejected[0] || 'No readable HTML artifacts were added.');
}
@@ -828,6 +951,8 @@ function openReader(id = selectedId) {
readerArtifactId = artifact.id;
clearTimeout(readerSlowTimer);
$('#readerTitle').textContent = artifact.title;
+ $('#readerRevisionState').dataset.status = workflowState(artifact);
+ $('#readerRevisionState').textContent = `v${revisionNumber(artifact)} · ${workflowLabel(artifact)}`;
$('#readerLoadingTitle').textContent = 'Opening document';
$('#readerLoadingHint').textContent = 'Preparing preview…';
loading.hidden = false;
@@ -891,26 +1016,56 @@ async function copyText(value) {
}
}
+async function refreshArtifact(id) {
+ const document = await channelRepository.getDocument(id);
+ if (!document) return null;
+ const refreshed = { ...document, revisions: await channelRepository.listRevisions(id) };
+ documents = documents.map((item) => item.id === id ? refreshed : item);
+ return refreshed;
+}
+
+async function markReviewed() {
+ const artifact = selectedDocument();
+ if (!artifact || workflowState(artifact) !== 'draft') return;
+ await channelRepository.setStatus(artifact.id, 'in-review', { revisionId: artifact.currentRevisionId });
+ await refreshArtifact(artifact.id);
+ render();
+ showToast('Current revision marked reviewed.');
+}
+
async function publishDocument(artifact = selectedDocument()) {
if (!artifact) 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);
if (!health.valid) { showToast('Repair the HDOC contract before publishing.'); return; }
+ const existingShare = stableShare(artifact);
+ if (artifact.status === 'published' && artifact.publishedRevisionId === artifact.currentRevisionId && existingShare) {
+ const copied = await copyText(existingShare.stableUrl);
+ showToast(copied ? 'Stable address copied.' : 'Stable address is shown in the inspector.');
+ return;
+ }
const buttons = $$('[data-share-action]');
buttons.forEach((button) => { button.disabled = true; });
try {
- const response = await fetch(SHARE_API_URL, {
+ const baseRevision = artifact.publishedRevisionId?.replace(/^sha256:/, '') || undefined;
+ const response = await fetch(`${CHANNEL_API_URL}/publish`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
- body: JSON.stringify({ html: artifact.html })
+ body: JSON.stringify({ html: artifact.html, ...(baseRevision ? { base_revision_sha256: baseRevision } : {}) })
});
const payload = await response.json().catch(() => ({}));
- if (!response.ok || !payload.url) throw new Error(payload.errors?.[0] || payload.message || `Share service returned ${response.status}.`);
- const published = { ...artifact, share: { url: payload.url, sha256: payload.sha256, publishedAt: new Date().toISOString() } };
- await saveDocument(published);
- documents = documents.map((item) => item.id === published.id ? published : item);
- renderInspector();
- const copied = await copyText(payload.url);
- showToast(copied ? 'Read-only intranet link copied.' : 'Published; the link is shown in the inspector.');
+ if (!response.ok || !payload.stable_url) throw new Error(payload.errors?.[0] || payload.message || (response.status === 409 ? 'The published Channel changed; refresh before publishing again.' : `Share service returned ${response.status}.`));
+ await channelRepository.setRevisionShare(artifact.id, artifact.currentRevisionId, { artifactId: payload.artifact?.id || artifact.id, stableUrl: payload.stable_url, revisionUrl: payload.revision_url, sha256: payload.sha256, publishedAt: new Date().toISOString() });
+ await channelRepository.setStatus(artifact.id, 'published', { revisionId: artifact.currentRevisionId });
+ const refreshed = await refreshArtifact(artifact.id);
+ if (readerArtifactId === artifact.id) {
+ $('#readerRevisionState').dataset.status = workflowState(refreshed);
+ $('#readerRevisionState').textContent = `v${revisionNumber(refreshed)} · ${workflowLabel(refreshed)}`;
+ }
+ render();
+ const copied = await copyText(payload.stable_url);
+ showToast(copied ? 'Stable Channel address copied.' : 'Published; the stable address is shown in the inspector.');
} catch (error) {
console.error(error);
showToast('This page could not be published to the intranet.');
@@ -919,8 +1074,135 @@ async function publishDocument(artifact = selectedDocument()) {
}
}
+async function revokePublication() {
+ const artifact = selectedDocument();
+ const share = stableShare(artifact);
+ if (!artifact || !share) return;
+ try {
+ const base = artifact.publishedRevisionId.replace(/^sha256:/, '');
+ const response = await fetch(`${CHANNEL_API_URL}/artifacts/${encodeURIComponent(share.artifactId || artifact.id)}/revoke`, {
+ method: 'POST',
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
+ body: JSON.stringify({ base_revision_sha256: base })
+ });
+ 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, { ...share, stableUrl: null, revokedAt: new Date().toISOString() });
+ await refreshArtifact(artifact.id);
+ render();
+ showToast('Stable address revoked. Immutable revision links remain available.');
+ } catch (error) {
+ console.error(error);
+ showToast('The stable address could not be revoked.');
+ }
+}
+
+function historyArtifact() {
+ const id = $('#historyDialog').dataset.artifactId;
+ return documents.find((artifact) => artifact.id === id) || lineageSources.get(id) || selectedDocument();
+}
+
+function revisionOptionLabel(artifact, revision) {
+ const flags = [];
+ if (revision.id === artifact.currentRevisionId) flags.push('current');
+ if (revision.id === artifact.publishedRevisionId) flags.push('published');
+ return `${revisionLabel(artifact, revision.id)}${flags.length ? ` · ${flags.join(', ')}` : ''}`;
+}
+
+function renderVisualDiff() {
+ const artifact = historyArtifact();
+ if (!artifact) return;
+ const revisions = revisionsFor(artifact);
+ const before = revisions.find((revision) => revision.id === $('#compareFrom').value) || revisions[0];
+ const after = revisions.find((revision) => revision.id === $('#compareTo').value) || revisions.at(-1);
+ if (!before || !after) return;
+ $('#diffBeforeLabel').textContent = revisionOptionLabel(artifact, before);
+ $('#diffAfterLabel').textContent = revisionOptionLabel(artifact, after);
+ $('#diffBefore').srcdoc = safeReaderSource(before.html);
+ $('#diffAfter').srcdoc = safeReaderSource(after.html);
+ $$('#revisionTimeline li').forEach((item) => item.classList.toggle('is-selected', item.dataset.revisionId === after.id));
+}
+
+function openHistoryDialog(artifact = selectedDocument(), selectedRevisionId = null) {
+ if (!artifact) return;
+ const revisions = revisionsFor(artifact);
+ const share = stableShare(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 || '#';
+ $('#historyStableLink').removeAttribute('aria-disabled');
+ if (!share) $('#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;
+ const published = revision.id === artifact.publishedRevisionId;
+ return `${esc(revisionLabel(artifact, revision.id))}${current ? ' · CURRENT' : ''} ${esc(dateLabel(revision.createdAt))}${published ? ' · PUBLISHED' : ''} ${esc(revision.contentHash.slice(0, 12))} `;
+ }).join('');
+ const options = revisions.map((revision) => `${esc(revisionOptionLabel(artifact, revision))} `).join('');
+ $('#compareFrom').innerHTML = options;
+ $('#compareTo').innerHTML = options;
+ 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;
+ $$('#revisionTimeline [data-compare-revision]').forEach((button) => button.addEventListener('click', () => {
+ dialog.dataset.selectedRevisionId = button.dataset.compareRevision;
+ $('#compareTo').value = button.dataset.compareRevision;
+ const index = revisions.findIndex((revision) => revision.id === button.dataset.compareRevision);
+ $('#compareFrom').value = revisions[Math.max(0, index - 1)]?.id || button.dataset.compareRevision;
+ renderVisualDiff();
+ }));
+ renderVisualDiff();
+ if (!dialog.open) dialog.showModal();
+}
+
+function closeHistoryDialog() {
+ $('#diffBefore').removeAttribute('srcdoc');
+ $('#diffAfter').removeAttribute('srcdoc');
+ $('#diffBefore').src = 'about:blank';
+ $('#diffAfter').src = 'about:blank';
+ $('#historyDialog').removeAttribute('data-artifact-id');
+ $('#historyDialog').removeAttribute('data-selected-revision-id');
+}
+
+async function forkArtifact() {
+ const source = historyArtifact() || selectedDocument();
+ if (!source) return;
+ const revisionId = $('#historyDialog').open ? ($('#historyDialog').dataset.selectedRevisionId || $('#compareTo').value) : source.currentRevisionId;
+ const now = new Date().toISOString();
+ const newId = `${source.id}-fork-${Date.now().toString(36)}`;
+ const result = await channelRepository.fork(source.id, {
+ id: newId,
+ title: `${source.title} — fork`,
+ source: 'Helm fork',
+ identityState: 'fork',
+ project: projectFor(source),
+ createdAt: now,
+ updatedAt: now
+ }, { artifactId: newId, revisionId });
+ const fork = { ...result.document, revisions: await channelRepository.listRevisions(newId) };
+ documents.push(fork);
+ selectedId = fork.id;
+ if ($('#historyDialog').open) $('#historyDialog').close();
+ render();
+ showToast(`Forked ${revisionLabel(source, revisionId)} as a new artifact.`);
+}
+
+async function openLineage() {
+ const artifact = selectedDocument();
+ if (!artifact?.forkedFrom) return;
+ await loadLineageSource(artifact.forkedFrom.artifactId);
+ const source = documents.find((item) => item.id === artifact.forkedFrom.artifactId) || lineageSources.get(artifact.forkedFrom.artifactId);
+ if (!source) { showToast('The source artifact is not present in this library.'); return; }
+ openHistoryDialog(source, artifact.forkedFrom.revisionId);
+}
+
function archiveRecords() {
- return documents.map(({ contentText, validation, ...record }) => record);
+ const records = new Map([...lineageSources.values(), ...documents].map((artifact) => [artifact.id, artifact]));
+ return [...records.values()].map(({ contentText, validation, ...record }) => record);
}
async function exportArchive() {
@@ -984,19 +1266,17 @@ async function saveCatalogMetadata() {
const title = $('#catalogTitle').value.trim();
if (!artifact || !title) return;
const type = $('#catalogType').value;
- const updated = {
- ...artifact,
+ const patch = {
title: title.slice(0, 100),
type: DOCUMENT_TYPES.has(type) ? type : artifact.type,
summary: $('#catalogSummary').value.trim().slice(0, 240),
tags: normaliseTags($('#catalogTags').value),
source: $('#catalogSource').value.trim().slice(0, 120) || 'Personal archive',
- project: normaliseProject($('#catalogProject').value),
- catalogUpdatedAt: new Date().toISOString()
+ project: normaliseProject($('#catalogProject').value)
};
- await saveDocument(updated);
- documents = documents.map((item) => item.id === updated.id ? updated : item);
- selectedId = updated.id;
+ await channelRepository.updateCatalog(artifact.id, patch);
+ await refreshArtifact(artifact.id);
+ selectedId = artifact.id;
editingId = null;
$('#metadataDialog').close();
render();
@@ -1011,9 +1291,9 @@ async function repairSelected() {
const result = repair.createCompliantCopy(artifact, { existingIds: new Set(documents.map((item) => item.id)) });
const repaired = enrichArtifact(result.record, { takenIds: new Set(documents.map((item) => item.id)), preserveId: true });
if (!repaired.validation.valid) throw new Error('The generated compliant copy did not pass validation.');
- await saveDocument(repaired);
- documents.push(repaired);
- selectedId = repaired.id;
+ const stored = await saveDocument(repaired);
+ documents.push(stored);
+ selectedId = stored.id;
render();
showToast('A new compliant copy was created. The original is unchanged.');
} catch (error) {
@@ -1025,14 +1305,16 @@ async function repairSelected() {
async function deleteSelected() {
const artifact = selectedDocument();
if (!artifact) return;
+ const hasForks = documents.some((item) => item.forkedFrom?.artifactId === artifact.id);
const lastFolderSync = await getSetting('lastFolderSyncAt');
const backupHint = lastFolderSync ? ` A folder sync was recorded on ${dateLabel(lastFolderSync)}; export or sync again if this change should be recoverable.` : ' No completed folder sync is recorded for this browser library.';
if (!confirm(`Remove “${artifact.title}” from this browser? The original file will not be touched.${backupHint}`)) return;
- await removeDocument(artifact.id);
+ await removeDocument(artifact.id, { hard: !hasForks });
+ if (hasForks) lineageSources.set(artifact.id, artifact);
documents = documents.filter((item) => item.id !== artifact.id);
selectedId = documents[0]?.id || null;
render();
- showToast('Artifact removed from this browser.');
+ showToast(hasForks ? 'Artifact archived so existing Fork lineage remains verifiable.' : 'Artifact removed from this browser.');
}
function wireEvents() {
@@ -1067,7 +1349,18 @@ function wireEvents() {
$('#previewButton').addEventListener('click', () => openReader());
$('#exportButton').addEventListener('click', () => downloadDocument());
$('#shareButton').addEventListener('click', () => publishDocument());
+ $('#revokeShareButton').addEventListener('click', revokePublication);
$('#readerShare').addEventListener('click', () => publishDocument(readerDocument()));
+ $('#reviewButton').addEventListener('click', markReviewed);
+ $('#historyButton').addEventListener('click', () => openHistoryDialog());
+ $('#readerHistory').addEventListener('click', () => openHistoryDialog(readerDocument()));
+ $('#forkButton').addEventListener('click', forkArtifact);
+ $('#forkArtifactButton').addEventListener('click', forkArtifact);
+ $('#openLineageButton').addEventListener('click', openLineage);
+ $('#compareFrom').addEventListener('change', renderVisualDiff);
+ $('#compareTo').addEventListener('change', () => { $('#historyDialog').dataset.selectedRevisionId = $('#compareTo').value; renderVisualDiff(); });
+ $('#closeHistory').addEventListener('click', () => $('#historyDialog').close());
+ $('#historyDialog').addEventListener('close', closeHistoryDialog);
$('#readerExport').addEventListener('click', () => downloadDocument(readerDocument()));
$('#closeReader').addEventListener('click', () => $('#readerDialog').close());
$('#readerDialog').addEventListener('close', resetReaderFrame);
diff --git a/channel-store.js b/channel-store.js
new file mode 100644
index 0000000..c27f570
--- /dev/null
+++ b/channel-store.js
@@ -0,0 +1,613 @@
+/*
+ * Helm Channels repository.
+ *
+ * Logical artifacts and immutable HTML revisions are stored separately. The
+ * legacy `documents` store is retained as a read-only migration source.
+ */
+(function attachHelmChannelStore(global) {
+ 'use strict';
+
+ const DEFAULT_DB_NAME = 'helm-html-archive';
+ const DB_VERSION = 4;
+ const LEGACY_STORE = 'documents';
+ const ARTIFACT_STORE = 'artifacts';
+ const REVISION_STORE = 'revisions';
+ const SETTINGS_STORE = 'settings';
+ const MIGRATION_KEY = 'channelsMigrationV1';
+ 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([
+ 'id', 'title', 'type', 'tags', 'summary', 'source', 'project', 'createdAt',
+ 'updatedAt', 'catalogUpdatedAt', 'status', 'currentRevisionId',
+ 'publishedRevisionId', 'forkedFrom', 'sourceDocumentId', 'identityState',
+ 'extensions'
+ ]);
+ const REVISION_INPUT_FIELDS = new Set([
+ ...ARTIFACT_FIELDS, 'html', 'contentText', 'validation', 'share', 'revisionId',
+ 'contentHash', 'parent', 'authoredAt', 'author'
+ ]);
+
+ class ChannelStoreError extends Error {
+ constructor(code, message, details = {}) {
+ super(message);
+ this.name = 'ChannelStoreError';
+ this.code = code;
+ Object.assign(this, details);
+ }
+ }
+
+ function requestResult(request) {
+ return new Promise((resolve, reject) => {
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error);
+ });
+ }
+
+ function transactionDone(transaction) {
+ return new Promise((resolve, reject) => {
+ transaction.oncomplete = () => resolve();
+ transaction.onabort = () => reject(transaction.error || new Error('IndexedDB transaction aborted.'));
+ transaction.onerror = () => { /* onabort carries the final error */ };
+ });
+ }
+
+ function isPlainObject(value) {
+ return Boolean(value) && Object.prototype.toString.call(value) === '[object Object]';
+ }
+
+ function cloneJson(value, path = 'value') {
+ if (value === undefined) return undefined;
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
+ if (typeof value === 'number') {
+ if (!Number.isFinite(value)) throw new TypeError(`${path} must contain only JSON values.`);
+ return value;
+ }
+ if (Array.isArray(value)) return value.map((entry, index) => cloneJson(entry, `${path}[${index}]`));
+ if (!isPlainObject(value)) throw new TypeError(`${path} must contain only JSON values.`);
+ const copy = {};
+ for (const [key, entry] of Object.entries(value)) {
+ if (key === '__proto__' || key === 'prototype' || key === 'constructor') throw new TypeError(`${path}.${key} is not allowed.`);
+ copy[key] = cloneJson(entry, `${path}.${key}`);
+ }
+ return copy;
+ }
+
+ function safeString(value, fallback = '') {
+ return typeof value === 'string' && value.trim() ? value.trim() : fallback;
+ }
+
+ function timestamp(value, fallback) {
+ return typeof value === 'string' && !Number.isNaN(Date.parse(value)) ? new Date(value).toISOString() : fallback;
+ }
+
+ function normaliseProject(value) {
+ if (!isPlainObject(value)) return { id: 'unassigned', name: 'Needs project' };
+ return {
+ id: safeString(value.id, 'unassigned'),
+ name: safeString(value.name, safeString(value.id, 'Needs project'))
+ };
+ }
+
+ function normaliseTags(value) {
+ return (Array.isArray(value) ? value : []).filter((tag) => typeof tag === 'string').map((tag) => tag.trim()).filter(Boolean);
+ }
+
+ function extraFields(input, knownFields) {
+ const extensions = isPlainObject(input.extensions) ? cloneJson(input.extensions, 'extensions') : {};
+ for (const [key, value] of Object.entries(input)) {
+ if (!knownFields.has(key) && value !== undefined) extensions[key] = cloneJson(value, key);
+ }
+ return Object.keys(extensions).length ? extensions : undefined;
+ }
+
+ async function sha256(html) {
+ if (!global.crypto?.subtle || typeof global.TextEncoder !== 'function') {
+ throw new ChannelStoreError('webcrypto-unavailable', 'Helm Channels requires WebCrypto SHA-256 support.');
+ }
+ const digest = await global.crypto.subtle.digest('SHA-256', new TextEncoder().encode(html));
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
+ }
+
+ function revisionIdForHash(hash) {
+ return `sha256:${hash}`;
+ }
+
+ function normaliseParent(value) {
+ if (value === null || value === undefined) return null;
+ if (!isPlainObject(value) || !safeString(value.artifactId) || !safeString(value.revisionId)) {
+ throw new TypeError('A revision parent must contain artifactId and revisionId.');
+ }
+ return { artifactId: value.artifactId.trim(), revisionId: value.revisionId.trim() };
+ }
+
+ function artifactFromInput(input, options = {}) {
+ const now = options.now || new Date().toISOString();
+ const id = safeString(options.id || input.id);
+ if (!id) throw new TypeError('An artifact id is required.');
+ const createdAt = timestamp(input.createdAt, now);
+ const status = STATUSES.has(options.status || input.status) ? (options.status || input.status) : 'draft';
+ return {
+ id,
+ title: safeString(input.title, 'Untitled artifact'),
+ type: safeString(input.type, 'reference'),
+ tags: normaliseTags(input.tags),
+ summary: typeof input.summary === 'string' ? input.summary : '',
+ source: typeof input.source === 'string' ? input.source : '',
+ project: normaliseProject(input.project),
+ createdAt,
+ updatedAt: timestamp(input.updatedAt, createdAt),
+ catalogUpdatedAt: timestamp(input.catalogUpdatedAt, timestamp(input.updatedAt, createdAt)),
+ status,
+ currentRevisionId: options.revisionId,
+ publishedRevisionId: options.publishedRevisionId || null,
+ forkedFrom: options.forkedFrom ? normaliseParent(options.forkedFrom) : null,
+ sourceDocumentId: safeString(input.sourceDocumentId) || null,
+ identityState: safeString(input.identityState, input.sourceDocumentId ? 'aligned' : 'unmanaged'),
+ ...(extraFields(input, REVISION_INPUT_FIELDS) ? { extensions: extraFields(input, REVISION_INPUT_FIELDS) } : {})
+ };
+ }
+
+ function revisionFromInput(artifactId, input, hash, options = {}) {
+ const now = options.now || new Date().toISOString();
+ const id = revisionIdForHash(hash);
+ return {
+ artifactId,
+ id,
+ contentHash: hash,
+ parent: normaliseParent(options.parent),
+ createdAt: now,
+ authoredAt: timestamp(input.authoredAt || input.updatedAt, timestamp(input.createdAt, now)),
+ author: safeString(input.author, safeString(input.source)),
+ html: input.html,
+ contentText: typeof input.contentText === 'string' ? input.contentText : '',
+ validation: input.validation === undefined ? null : cloneJson(input.validation, 'validation'),
+ sourceManifestId: safeString(input.sourceDocumentId) || null,
+ share: options.share === undefined || options.share === null ? null : cloneJson(options.share, 'share')
+ };
+ }
+
+ function projectArtifact(artifact, revision) {
+ if (!artifact || !revision) return null;
+ return {
+ ...cloneJson(artifact, 'artifact'),
+ html: revision.html,
+ contentText: revision.contentText || '',
+ validation: revision.validation,
+ share: revision.share,
+ revisionId: revision.id,
+ contentHash: revision.contentHash,
+ revisionCreatedAt: revision.createdAt,
+ parentRevision: revision.parent
+ };
+ }
+
+ function createIndexes(db) {
+ let artifacts;
+ if (!db.objectStoreNames.contains(ARTIFACT_STORE)) artifacts = db.createObjectStore(ARTIFACT_STORE, { keyPath: 'id' });
+ else artifacts = null;
+ if (artifacts) {
+ artifacts.createIndex('by_project', 'project.id', { unique: false });
+ artifacts.createIndex('by_status', 'status', { unique: false });
+ artifacts.createIndex('by_updated_at', 'catalogUpdatedAt', { unique: false });
+ }
+ let revisions;
+ if (!db.objectStoreNames.contains(REVISION_STORE)) revisions = db.createObjectStore(REVISION_STORE, { keyPath: ['artifactId', 'id'] });
+ else revisions = null;
+ if (revisions) {
+ revisions.createIndex('by_artifact', 'artifactId', { unique: false });
+ revisions.createIndex('by_content_hash', 'contentHash', { unique: false });
+ revisions.createIndex('by_created_at', 'createdAt', { unique: false });
+ }
+ if (!db.objectStoreNames.contains(SETTINGS_STORE)) db.createObjectStore(SETTINGS_STORE, { keyPath: 'key' });
+ }
+
+ function openDatabase(dbName) {
+ return new Promise((resolve, reject) => {
+ const request = global.indexedDB.open(dbName, DB_VERSION);
+ request.onupgradeneeded = () => createIndexes(request.result);
+ request.onsuccess = () => {
+ request.result.onversionchange = () => request.result.close();
+ resolve(request.result);
+ };
+ request.onblocked = () => reject(new ChannelStoreError('database-blocked', 'Close other Helm tabs so the Channels database can be upgraded.'));
+ request.onerror = () => reject(request.error);
+ });
+ }
+
+ function makeRepository(options = {}) {
+ const dbName = options.dbName || DEFAULT_DB_NAME;
+ let databasePromise = null;
+ const database = () => (databasePromise ||= openDatabase(dbName));
+
+ async function getAll(storeName) {
+ const db = await database();
+ return requestResult(db.transaction(storeName, 'readonly').objectStore(storeName).getAll());
+ }
+
+ async function getArtifact(id) {
+ const db = await database();
+ return requestResult(db.transaction(ARTIFACT_STORE, 'readonly').objectStore(ARTIFACT_STORE).get(id));
+ }
+
+ async function getRevision(artifactId, revisionId) {
+ const db = await database();
+ return requestResult(db.transaction(REVISION_STORE, 'readonly').objectStore(REVISION_STORE).get([artifactId, revisionId]));
+ }
+
+ async function getSetting(key) {
+ const db = await database();
+ const entry = await requestResult(db.transaction(SETTINGS_STORE, 'readonly').objectStore(SETTINGS_STORE).get(key));
+ return entry?.value;
+ }
+
+ async function setSetting(key, value) {
+ const db = await database();
+ const tx = db.transaction(SETTINGS_STORE, 'readwrite');
+ tx.objectStore(SETTINGS_STORE).put({ key, value });
+ await transactionDone(tx);
+ return value;
+ }
+
+ async function migrateLegacyDocuments() {
+ const db = await database();
+ const marker = await requestResult(db.transaction(SETTINGS_STORE, 'readonly').objectStore(SETTINGS_STORE).get(MIGRATION_KEY));
+ if (marker?.value?.complete) return marker.value;
+ if (!db.objectStoreNames.contains(LEGACY_STORE)) {
+ const value = { complete: true, migratedCount: 0, completedAt: new Date().toISOString() };
+ const tx = db.transaction(SETTINGS_STORE, 'readwrite');
+ tx.objectStore(SETTINGS_STORE).put({ key: MIGRATION_KEY, value });
+ await transactionDone(tx);
+ return value;
+ }
+
+ const legacy = await requestResult(db.transaction(LEGACY_STORE, 'readonly').objectStore(LEGACY_STORE).getAll());
+ const prepared = [];
+ const invalidLegacyRecords = [];
+ for (const [index, document] of legacy.entries()) {
+ if (!document || typeof document.html !== 'string' || !safeString(document.id)) {
+ invalidLegacyRecords.push(safeString(document?.id, `record-${index + 1}`));
+ continue;
+ }
+ const hash = await sha256(document.html);
+ const revisionId = revisionIdForHash(hash);
+ const publishedRevisionId = document.share ? revisionId : null;
+ const artifact = artifactFromInput(document, {
+ id: document.id,
+ revisionId,
+ 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 });
+ prepared.push({ artifact, revision });
+ }
+ if (invalidLegacyRecords.length) {
+ throw new ChannelStoreError('migration-invalid-records', `Legacy migration stopped before hiding ${invalidLegacyRecords.length} invalid record(s).`, { records: invalidLegacyRecords });
+ }
+
+ const existingArtifacts = new Map((await getAll(ARTIFACT_STORE)).map((entry) => [entry.id, entry]));
+ const existingRevisions = new Map((await getAll(REVISION_STORE)).map((entry) => [`${entry.artifactId}\u0000${entry.id}`, entry]));
+ for (const { artifact, revision } of prepared) {
+ const oldRevision = existingRevisions.get(`${revision.artifactId}\u0000${revision.id}`);
+ if (oldRevision && oldRevision.html !== revision.html) throw new ChannelStoreError('revision-integrity', `Revision ${revision.id} has conflicting bytes.`);
+ const oldArtifact = existingArtifacts.get(artifact.id);
+ if (oldArtifact && !oldRevision && oldArtifact.currentRevisionId === revision.id) {
+ throw new ChannelStoreError('migration-incomplete', `Artifact ${artifact.id} points to a missing revision.`);
+ }
+ }
+
+ const completedAt = new Date().toISOString();
+ const value = { complete: true, migratedCount: prepared.length, completedAt };
+ const tx = db.transaction([ARTIFACT_STORE, REVISION_STORE, SETTINGS_STORE], 'readwrite');
+ const artifacts = tx.objectStore(ARTIFACT_STORE);
+ const revisions = tx.objectStore(REVISION_STORE);
+ for (const { artifact, revision } of prepared) {
+ if (!existingRevisions.has(`${revision.artifactId}\u0000${revision.id}`)) revisions.add(revision);
+ if (!existingArtifacts.has(artifact.id)) artifacts.add(artifact);
+ }
+ tx.objectStore(SETTINGS_STORE).put({ key: MIGRATION_KEY, value });
+ await transactionDone(tx);
+ return value;
+ }
+
+ async function open() {
+ await database();
+ await migrateLegacyDocuments();
+ return repository;
+ }
+
+ async function listArtifacts(options = {}) {
+ await open();
+ const artifacts = await getAll(ARTIFACT_STORE);
+ return artifacts.filter((artifact) => options.includeArchived === true || artifact.status !== 'archived');
+ }
+
+ async function listRevisions(artifactId) {
+ await open();
+ const db = await database();
+ const tx = db.transaction(REVISION_STORE, 'readonly');
+ const records = await requestResult(tx.objectStore(REVISION_STORE).index('by_artifact').getAll(artifactId));
+ return records.sort((left, right) => new Date(left.createdAt) - new Date(right.createdAt));
+ }
+
+ async function getDocument(id, revisionId) {
+ await open();
+ const artifact = await getArtifact(id);
+ if (!artifact) return null;
+ const revision = await getRevision(id, revisionId || artifact.currentRevisionId);
+ return projectArtifact(artifact, revision);
+ }
+
+ async function listDocuments(options = {}) {
+ const artifacts = await listArtifacts(options);
+ return Promise.all(artifacts.map((artifact) => getDocument(artifact.id)));
+ }
+
+ async function createOrRevise(input, options = {}) {
+ await open();
+ if (!isPlainObject(input) || typeof input.html !== 'string') throw new TypeError('createOrRevise requires an HTML document record.');
+ const artifactId = safeString(options.artifactId || input.id);
+ if (!artifactId) throw new TypeError('createOrRevise requires an artifact id.');
+ const hash = await sha256(input.html);
+ const revisionId = revisionIdForHash(hash);
+ const db = await database();
+ const tx = db.transaction([ARTIFACT_STORE, REVISION_STORE], 'readwrite');
+ const artifacts = tx.objectStore(ARTIFACT_STORE);
+ const revisions = tx.objectStore(REVISION_STORE);
+ const existingArtifact = await requestResult(artifacts.get(artifactId));
+ const existingRevision = await requestResult(revisions.get([artifactId, revisionId]));
+
+ if (existingRevision) {
+ if (existingRevision.html !== input.html) {
+ tx.abort();
+ throw new ChannelStoreError('revision-integrity', `Revision ${revisionId} has conflicting bytes.`);
+ }
+ if (!existingArtifact) {
+ tx.abort();
+ throw new ChannelStoreError('orphan-revision', `Revision ${artifactId}/${revisionId} has no logical artifact.`);
+ }
+ const currentRevision = existingArtifact.currentRevisionId === revisionId
+ ? existingRevision
+ : await requestResult(revisions.get([artifactId, existingArtifact.currentRevisionId]));
+ if (!currentRevision) {
+ tx.abort();
+ throw new ChannelStoreError('missing-revision', `Artifact ${artifactId} points to a missing current revision.`);
+ }
+ await transactionDone(tx);
+ return { created: false, revised: false, idempotent: true, artifact: existingArtifact, revision: existingRevision, document: projectArtifact(existingArtifact, currentRevision) };
+ }
+
+ if (existingArtifact && options.expectedCurrentRevisionId !== undefined && existingArtifact.currentRevisionId !== options.expectedCurrentRevisionId) {
+ tx.abort();
+ throw new ChannelStoreError('head-conflict', 'The artifact changed after this revision was prepared.', { expected: options.expectedCurrentRevisionId, actual: existingArtifact.currentRevisionId });
+ }
+
+ const parent = options.parent !== undefined
+ ? normaliseParent(options.parent)
+ : existingArtifact ? { artifactId, revisionId: existingArtifact.currentRevisionId } : null;
+ if (parent) {
+ const parentRevision = await requestResult(revisions.get([parent.artifactId, parent.revisionId]));
+ if (!parentRevision) {
+ tx.abort();
+ throw new ChannelStoreError('missing-parent', `Parent revision ${parent.artifactId}/${parent.revisionId} does not exist.`);
+ }
+ }
+ const revisionShare = options.revisionShare !== undefined
+ ? options.revisionShare
+ : (!existingArtifact && !options.forkedFrom ? input.share : null);
+ const revision = revisionFromInput(artifactId, input, hash, { parent, share: revisionShare });
+ revisions.add(revision);
+ let artifact;
+ if (existingArtifact) {
+ artifact = {
+ ...existingArtifact,
+ currentRevisionId: revisionId,
+ status: existingArtifact.publishedRevisionId === revisionId ? 'published' : 'draft',
+ updatedAt: timestamp(input.updatedAt, existingArtifact.updatedAt),
+ ...(options.updateCatalog === true ? {
+ title: safeString(input.title, existingArtifact.title),
+ type: safeString(input.type, existingArtifact.type),
+ tags: input.tags ? normaliseTags(input.tags) : existingArtifact.tags,
+ summary: typeof input.summary === 'string' ? input.summary : existingArtifact.summary,
+ source: typeof input.source === 'string' ? input.source : existingArtifact.source,
+ project: input.project ? normaliseProject(input.project) : existingArtifact.project,
+ catalogUpdatedAt: new Date().toISOString()
+ } : {})
+ };
+ artifacts.put(artifact);
+ } else {
+ artifact = artifactFromInput(input, { id: artifactId, revisionId, status: options.status, forkedFrom: options.forkedFrom || parent });
+ artifacts.add(artifact);
+ }
+ await transactionDone(tx);
+ return { created: !existingArtifact, revised: Boolean(existingArtifact), idempotent: false, artifact, revision, document: projectArtifact(artifact, revision) };
+ }
+
+ async function updateCatalog(id, patch) {
+ await open();
+ if (!isPlainObject(patch)) throw new TypeError('Catalog patch must be an object.');
+ const forbidden = Object.keys(patch).filter((key) => !CATALOG_FIELDS.has(key));
+ if (forbidden.length) throw new ChannelStoreError('invalid-catalog-patch', `Catalog fields cannot update: ${forbidden.join(', ')}.`);
+ const db = await database();
+ const tx = db.transaction(ARTIFACT_STORE, 'readwrite');
+ const store = tx.objectStore(ARTIFACT_STORE);
+ const artifact = await requestResult(store.get(id));
+ if (!artifact) { tx.abort(); throw new ChannelStoreError('not-found', `Artifact ${id} does not exist.`); }
+ const updated = { ...artifact, catalogUpdatedAt: new Date().toISOString() };
+ if (patch.title !== undefined) updated.title = safeString(patch.title, artifact.title);
+ if (patch.type !== undefined) updated.type = safeString(patch.type, artifact.type);
+ if (patch.tags !== undefined) updated.tags = normaliseTags(patch.tags);
+ if (patch.summary !== undefined) updated.summary = typeof patch.summary === 'string' ? patch.summary : artifact.summary;
+ if (patch.source !== undefined) updated.source = typeof patch.source === 'string' ? patch.source : artifact.source;
+ if (patch.project !== undefined) updated.project = normaliseProject(patch.project);
+ store.put(updated);
+ await transactionDone(tx);
+ return getDocument(id);
+ }
+
+ async function setStatus(id, status, options = {}) {
+ await open();
+ if (!STATUSES.has(status)) throw new TypeError(`Unknown artifact status: ${status}.`);
+ const db = await database();
+ const tx = db.transaction([ARTIFACT_STORE, REVISION_STORE], 'readwrite');
+ const artifacts = tx.objectStore(ARTIFACT_STORE);
+ const artifact = await requestResult(artifacts.get(id));
+ if (!artifact) { tx.abort(); throw new ChannelStoreError('not-found', `Artifact ${id} does not exist.`); }
+ let publishedRevisionId = artifact.publishedRevisionId || null;
+ if (status === 'published') {
+ publishedRevisionId = options.revisionId || artifact.currentRevisionId;
+ const revision = await requestResult(tx.objectStore(REVISION_STORE).get([id, publishedRevisionId]));
+ if (!revision) { tx.abort(); throw new ChannelStoreError('missing-revision', `Revision ${publishedRevisionId} does not exist.`); }
+ }
+ const updated = { ...artifact, status, publishedRevisionId, catalogUpdatedAt: new Date().toISOString() };
+ artifacts.put(updated);
+ await transactionDone(tx);
+ return getDocument(id);
+ }
+
+ async function setRevisionShare(artifactId, revisionId, share) {
+ await open();
+ if (share !== null && !isPlainObject(share)) throw new TypeError('Revision share metadata must be an object or null.');
+ const db = await database();
+ const tx = db.transaction(REVISION_STORE, 'readwrite');
+ const revisions = tx.objectStore(REVISION_STORE);
+ const revision = await requestResult(revisions.get([artifactId, revisionId]));
+ if (!revision) { tx.abort(); throw new ChannelStoreError('missing-revision', `Revision ${revisionId} does not exist.`); }
+ const immutable = {
+ html: revision.html,
+ contentHash: revision.contentHash,
+ parent: cloneJson(revision.parent, 'parent')
+ };
+ const updated = { ...revision, share: share === null ? null : cloneJson(share, 'share') };
+ // Publication metadata is mutable, but the evidence original and lineage
+ // are deliberately copied from the stored record without accepting input.
+ updated.html = immutable.html;
+ updated.contentHash = immutable.contentHash;
+ updated.parent = immutable.parent;
+ revisions.put(updated);
+ await transactionDone(tx);
+ return updated;
+ }
+
+ async function revokePublication(artifactId, revisionId, share) {
+ await open();
+ if (!isPlainObject(share)) throw new TypeError('Revoked publication metadata must be an object.');
+ const db = await database();
+ const tx = db.transaction([ARTIFACT_STORE, REVISION_STORE], 'readwrite');
+ const artifacts = tx.objectStore(ARTIFACT_STORE);
+ const revisions = tx.objectStore(REVISION_STORE);
+ const artifact = await requestResult(artifacts.get(artifactId));
+ const revision = await requestResult(revisions.get([artifactId, revisionId]));
+ if (!artifact || !revision) { tx.abort(); throw new ChannelStoreError('not-found', 'The published Artifact or Revision no longer exists.'); }
+ revisions.put({ ...revision, share: cloneJson(share, 'share') });
+ const status = artifact.currentRevisionId === revisionId ? 'in-review' : artifact.status;
+ artifacts.put({ ...artifact, status, catalogUpdatedAt: new Date().toISOString() });
+ await transactionDone(tx);
+ return getDocument(artifactId);
+ }
+
+ async function setCurrentRevision(artifactId, revisionId) {
+ await open();
+ const db = await database();
+ const tx = db.transaction([ARTIFACT_STORE, REVISION_STORE], 'readwrite');
+ const artifacts = tx.objectStore(ARTIFACT_STORE);
+ const artifact = await requestResult(artifacts.get(artifactId));
+ if (!artifact) { tx.abort(); throw new ChannelStoreError('not-found', `Artifact ${artifactId} does not exist.`); }
+ const revision = await requestResult(tx.objectStore(REVISION_STORE).get([artifactId, revisionId]));
+ if (!revision) { tx.abort(); throw new ChannelStoreError('missing-revision', `Revision ${revisionId} does not exist.`); }
+ const updated = {
+ ...artifact,
+ currentRevisionId: revisionId,
+ status: revisionId === artifact.publishedRevisionId ? artifact.status : 'draft',
+ catalogUpdatedAt: new Date().toISOString()
+ };
+ artifacts.put(updated);
+ await transactionDone(tx);
+ return projectArtifact(updated, revision);
+ }
+
+ async function fork(sourceArtifactId, input, options = {}) {
+ await open();
+ const source = await getArtifact(sourceArtifactId);
+ if (!source) throw new ChannelStoreError('not-found', `Artifact ${sourceArtifactId} does not exist.`);
+ const sourceRevisionId = options.revisionId || source.currentRevisionId;
+ const sourceRevision = await getRevision(sourceArtifactId, sourceRevisionId);
+ if (!sourceRevision) throw new ChannelStoreError('missing-revision', `Revision ${sourceRevisionId} does not exist.`);
+ const forkInput = { ...projectArtifact(source, sourceRevision), ...(input || {}), html: input?.html ?? sourceRevision.html };
+ const newId = safeString(options.artifactId || input?.id);
+ if (!newId || newId === sourceArtifactId) throw new TypeError('A fork requires a distinct artifact id.');
+ if (await getArtifact(newId)) throw new ChannelStoreError('artifact-exists', `Artifact ${newId} already exists.`);
+ const parent = { artifactId: sourceArtifactId, revisionId: sourceRevisionId };
+ return createOrRevise({ ...forkInput, id: newId }, { artifactId: newId, parent, forkedFrom: parent, status: options.status || 'draft' });
+ }
+
+ async function deleteArtifact(id, options = {}) {
+ await open();
+ if (options.hard !== true) return setStatus(id, 'archived');
+ const db = await database();
+ const tx = db.transaction([ARTIFACT_STORE, REVISION_STORE], 'readwrite');
+ const artifacts = tx.objectStore(ARTIFACT_STORE);
+ if (!await requestResult(artifacts.get(id))) { tx.abort(); throw new ChannelStoreError('not-found', `Artifact ${id} does not exist.`); }
+ artifacts.delete(id);
+ const index = tx.objectStore(REVISION_STORE).index('by_artifact');
+ await new Promise((resolve, reject) => {
+ const cursor = index.openKeyCursor(global.IDBKeyRange.only(id));
+ cursor.onerror = () => reject(cursor.error);
+ cursor.onsuccess = () => {
+ const result = cursor.result;
+ if (!result) { resolve(); return; }
+ tx.objectStore(REVISION_STORE).delete(result.primaryKey);
+ result.continue();
+ };
+ });
+ await transactionDone(tx);
+ return { deleted: true, id };
+ }
+
+ async function close() {
+ if (!databasePromise) return;
+ const db = await databasePromise;
+ db.close();
+ databasePromise = null;
+ }
+
+ const repository = Object.freeze({
+ open,
+ close,
+ migrateLegacyDocuments,
+ listArtifacts,
+ listRevisions,
+ getArtifact,
+ getRevision,
+ getSetting,
+ setSetting,
+ getDocument,
+ listDocuments,
+ createOrRevise,
+ updateCatalog,
+ setStatus,
+ setRevisionShare,
+ revokePublication,
+ setCurrentRevision,
+ fork,
+ deleteArtifact,
+ projectArtifact
+ });
+ return repository;
+ }
+
+ global.HelmChannelStore = Object.freeze({
+ DB_VERSION,
+ LEGACY_STORE,
+ ARTIFACT_STORE,
+ REVISION_STORE,
+ SETTINGS_STORE,
+ MIGRATION_KEY,
+ STATUSES,
+ ChannelStoreError,
+ sha256,
+ revisionIdForHash,
+ projectArtifact,
+ create: makeRepository,
+ defaultRepository: makeRepository()
+ });
+}(typeof window !== 'undefined' ? window : globalThis));
diff --git a/docs/AGENT-BRIDGE.md b/docs/AGENT-BRIDGE.md
index 1f2b460..cee987e 100644
--- a/docs/AGENT-BRIDGE.md
+++ b/docs/AGENT-BRIDGE.md
@@ -1,6 +1,6 @@
# Helm Bridge — agent ingress
-`HDOC/1.0` describes the portable file. Helm Bridge is the optional local ingress service that lets an AI agent hand a completed file to a person's Helm library without giving the agent access to that browser library.
+`HDOC/1.0` describes the portable file. Helm Bridge is the optional local ingress service that lets an AI agent hand a completed Artifact revision to a person's Helm library without giving the agent access to that browser library.
## Boundary
@@ -34,7 +34,7 @@ The read-only endpoints require no token:
- Rejects unsafe or invalid submissions: executable scripts, inline handlers, missing HDOC metadata, invalid timestamps, duplicate roots or headings, and files larger than 5 MB.
- Preserves the accepted UTF-8 bytes exactly; it does not format, repair, or mutate submitted HTML.
-- Treats the manifest ID as an immutable stable identity. Resending the exact bytes is idempotent. Sending different bytes with an existing ID returns `409 Conflict`, never an overwrite.
+- Treats the manifest ID as the logical Artifact identity. Resending exact bytes is idempotent; different bytes under that ID are appended as another immutable Revision. The Bridge never overwrites an earlier source or advances the browser's current Revision by itself.
- Warns about remote resources, but does not rewrite them. The browser reader keeps its separate no-network sandbox.
## Agent integration
@@ -55,7 +55,7 @@ HELM_BRIDGE_TOKEN=owner-provided-secret
HELM_AGENT_NAME=research-agent
```
-The supplied client treats `409` as an identity conflict and `422` as a contract failure. It must not solve either condition by silently changing the old document’s ID or overwriting it. Generate a genuinely new artifact when the content needs a new stable identity.
+The supplied client treats `422` as a contract failure. A revised version of the same Artifact keeps its manifest ID; a different document or intentional fork receives a new one. No client may overwrite an earlier Revision or silently advance the owner's current version.
## Remote agents
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 55ec896..47b7aaa 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -9,32 +9,32 @@ Helm is deliberately split into small, inspectable planes. A document can remain
│
▼
agent/project ── POST ──> Loopback Bridge ── review ──> Browser library
- original bytes IndexedDB
+ immutable revisions IndexedDB
│ │
└── immutable inbox ├── HARC export
├── explicit folder sync
- └── owner-selected share
+ └── owner-selected Channel
│
▼
- read-only intranet HTML
+ stable address + immutable revision
```
## Four planes
| Plane | Primary code | Responsibility | Trust boundary |
| --- | --- | --- | --- |
-| Browser library | `index.html`, `app.js`, `validator.js`, `archive-backup.js`, `folder-sync.js`, `repair.js` | Catalog, safe reading, search, metadata overlays, recovery, and explicit import. | The browser owns the personal library. |
+| Browser library | `index.html`, `app.js`, `channel-store.js`, `validator.js`, `archive-backup.js`, `folder-sync.js`, `repair.js` | Artifact / Revision history, workflow state, visual compare, catalog, safe reading, recovery, and explicit import. | The browser owns the personal library. |
| Artifact contract | `docs/HTML-DOCUMENT-SPEC.md`, `AI-GUIDE.md`, `templates/` | Portable `HDOC/1.0` HTML with evidence, provenance, and visual reading structure. | Artifact authors must not rely on Helm to make a document intelligible. |
| Agent handoff | `helm_bridge.py`, `scripts/helm-agent-bootstrap`, `scripts/helm-submit` | Authenticated loopback ingress; exact-byte inbox storage; idempotency and revision semantics. | Agents can submit, never import into browser storage. |
-| Intranet sharing | `helm_share_server.py`, `docs/INTRANET-SHARING.md` | Explicit, immutable, read-only publication of one validated artifact. | Visitors can read a selected shared file, never enumerate or alter the library. |
+| Intranet sharing | `helm_share_server.py`, `docs/INTRANET-SHARING.md` | Explicit publication to a stable Channel plus immutable content-addressed Revision addresses. | Visitors can read a selected shared file, never enumerate or alter the library. |
## Data invariants
1. **Original HTML is immutable.** A browser catalog overlay may improve title, project, tag, or source metadata without rewriting the stored file.
-2. **Identity is stable.** The manifest ID identifies the artifact; changed bytes are a revision or a genuinely new artifact, never an overwrite.
+2. **Identity is stable.** The manifest ID identifies the logical Artifact; changed bytes append an immutable Revision. A new ID means a different Artifact or explicit Fork.
3. **The document is portable.** A finished artifact is a standalone `HDOC/1.0` file with a semantic root, embedded essential CSS, manifest, and provenance.
4. **Import is owner-controlled.** The Bridge and an intranet share server cannot mutate IndexedDB.
-5. **Publication is explicit.** Sharing produces a content-addressed read-only copy outside the browser library and outside the Git checkout.
+5. **Publication is explicit.** Sharing produces a content-addressed read-only copy and may atomically advance one stable Channel address outside the browser library and Git checkout.
## Repository layout
diff --git a/docs/CHANNELS.md b/docs/CHANNELS.md
new file mode 100644
index 0000000..de98e67
--- /dev/null
+++ b/docs/CHANNELS.md
@@ -0,0 +1,37 @@
+# Helm Channels
+
+Channels is Helm's first revision model. It separates the thing a reader follows from the immutable HTML evidence that produced each version.
+
+## Model
+
+- **Artifact** — stable logical identity, catalog metadata, project, workflow status, current Revision, published Revision, and optional Fork origin.
+- **Revision** — immutable HTML bytes addressed by SHA-256, with authoring time, validation evidence, parent Revision, and optional publication metadata.
+- **Channel** — the Artifact's stable read-only intranet address. Publishing advances it with compare-and-swap; it never rewrites an immutable Revision address.
+
+The browser database stores Artifacts and Revisions in separate IndexedDB stores. Upgrading from the legacy document store is automatic and non-destructive.
+
+## Workflow
+
+```text
+Draft revision -> Reviewed -> Published stable address
+ | |
+ +-> new revision ---------+-> explicit publish advances Channel
+ +-> Fork creates a new Artifact with recorded origin
+```
+
+A new Revision after publication returns the Artifact to Draft while leaving the last published Revision identifiable. Catalog-only edits do not create Revisions. A Fork begins with the exact source bytes for provenance; before it can own a separate Channel, its author must create a new valid HDOC Revision whose embedded manifest ID matches the Fork Artifact ID. Helm blocks publication while those identities differ.
+
+## Addresses
+
+- `/a/` is the stable Channel address.
+- `/r/.html` is an immutable Revision address.
+
+`POST /api/channels/publish` accepts the exact HDOC HTML plus an optional `base_revision_sha256`. A stale base returns `409` instead of silently replacing a newer publication. `POST /api/channels/artifacts//revoke` removes the stable address; immutable Revision files remain evidence originals.
+
+## Backup compatibility
+
+`HARC/1.0` remains readable. Helm carries Channel fields and the Revision graph through `metadata.extensions`; a clean-library recovery recreates immutable Revisions, their shares, current head, published head, status, and Fork origin. Existing-ID imports remain non-destructive and require an explicit product decision.
+
+## Phase-one boundary
+
+This phase is single-owner and local-first. It does not add collaborative editing, remote identity, automatic sync, merge semantics, or access-control lists. Those need a separate trust and conflict model.
diff --git a/docs/CODEX-MEMORY.md b/docs/CODEX-MEMORY.md
index 01cbdf5..b0099f0 100644
--- a/docs/CODEX-MEMORY.md
+++ b/docs/CODEX-MEMORY.md
@@ -16,7 +16,7 @@ This is the small, durable context a coding agent should load when it first ente
## Memory payload
-> Helm is a local-first personal archive for durable AI-authored HTML, not a generic file explorer. Treat every retained HTML output as an evidence original with stable identity, title, provenance, readable semantic structure, and future retrieval value. The authoritative interchange contract is `HDOC/1.0` in `docs/HTML-DOCUMENT-SPEC.md`; reuse `templates/` and follow `AI-GUIDE.md` rather than inventing a parallel format. Helm's first organization level is the Codex/project workspace: write the optional `manifest.project` as `{ "id": "stable-workspace-id", "name": "Workspace name" }`, using the project identity rather than a tag or local machine path. Use `docs/REPORT-DESIGN-STANDARD.md` as the editorial bar: lead with the question and short answer, make the route from evidence to interpretation to decision or next action visible, and choose one to three visual evidence modules whenever a real comparison, sequence, hierarchy, magnitude, change, composition, or uncertainty needs to be understood. A visual must carry a named conclusion, direct labels and boundaries, evidence state, scope/source or method note, and a nearby text or table fallback; use inline SVG or semantic HTML/CSS, never decorative chart wallpaper or a remote runtime dependency. Keep sources, dates, assumptions, and confidence beside the claims they qualify. A completed artifact must be one standalone UTF-8 HTML file with embedded essential CSS, one ``, one `h1`, duplicate `helm:*` metadata, and exactly one valid `data-helm-manifest`. Do not send Markdown fragments, partial drafts, executable scripts, inline event handlers, secrets, or external application dependencies. If the artifact is intended for Helm, validate it locally, then submit the exact final file once from the target project root with `scripts/helm-submit output.html --source "agent-name"`; the Bridge uses that workspace as a catalog fallback if the source has no project declaration. Helm Bridge validates and preserves the source but only puts it in the owner's inbox; a person explicitly imports it into browser storage. Publishing is a separate, explicit owner action: a validated artifact may be copied byte-for-byte to immutable, content-addressed intranet storage, while network visitors receive read-only access and never gain access to the browser library. Never overwrite a stable manifest ID: exact retries are idempotent, while different bytes under an existing ID require a genuinely new artifact identity. The visual and writing style is calm, evidence-forward, provenance-aware, answer-first, and optimized for later reading rather than landing-page polish.
+> Helm is a local-first personal archive for durable AI-authored HTML, not a generic file explorer. Treat every retained HTML output as an evidence original with stable identity, title, provenance, readable semantic structure, and future retrieval value. The authoritative interchange contract is `HDOC/1.0` in `docs/HTML-DOCUMENT-SPEC.md`; reuse `templates/` and follow `AI-GUIDE.md` rather than inventing a parallel format. Helm's first organization level is the Codex/project workspace: write the optional `manifest.project` as `{ "id": "stable-workspace-id", "name": "Workspace name" }`, using the project identity rather than a tag or local machine path. Use `docs/REPORT-DESIGN-STANDARD.md` as the editorial bar: lead with the question and short answer, make the route from evidence to interpretation to decision or next action visible, and choose one to three visual evidence modules whenever a real comparison, sequence, hierarchy, magnitude, change, composition, or uncertainty needs to be understood. A visual must carry a named conclusion, direct labels and boundaries, evidence state, scope/source or method note, and a nearby text or table fallback; use inline SVG or semantic HTML/CSS, never decorative chart wallpaper or a remote runtime dependency. Keep sources, dates, assumptions, and confidence beside the claims they qualify. A completed artifact must be one standalone UTF-8 HTML file with embedded essential CSS, one ``, one `h1`, duplicate `helm:*` metadata, and exactly one valid `data-helm-manifest`. Do not send Markdown fragments, partial drafts, executable scripts, inline event handlers, secrets, or external application dependencies. If the artifact is intended for Helm, validate it locally, then submit the exact final file once from the target project root with `scripts/helm-submit output.html --source "agent-name"`; the Bridge uses that workspace as a catalog fallback if the source has no project declaration. Helm Bridge validates and preserves the source but only puts it in the owner's inbox; a person explicitly imports it into browser storage. Publishing is a separate, explicit owner action: a validated artifact may be copied byte-for-byte to immutable, content-addressed intranet storage, while network visitors receive read-only access and never gain access to the browser library. Keep a stable manifest ID for revisions of the same logical Artifact: exact retries are idempotent, different bytes append an immutable Revision, and a new ID is reserved for a different document or explicit Fork. The visual and writing style is calm, evidence-forward, provenance-aware, answer-first, and optimized for later reading rather than landing-page polish.
## Clone-to-handoff workflow
diff --git a/docs/INTRANET-SHARING.md b/docs/INTRANET-SHARING.md
index 7f1eb4a..4dafd21 100644
--- a/docs/INTRANET-SHARING.md
+++ b/docs/INTRANET-SHARING.md
@@ -1,17 +1,21 @@
-# Helm intranet sharing
+# Helm Channels and intranet sharing
-Helm keeps the personal library in browser storage. Sharing does not expose that library. It publishes one explicitly selected, validated HDOC document as an immutable read-only file.
+Helm keeps the personal browser library private. Publishing copies only an explicitly selected, validated HDOC document into the share service. Network visitors can read published pages but cannot browse the library or change a Channel.
-## User flow
+The browser publishes only when the logical Artifact ID equals the embedded HDOC manifest ID. Catalog copies and newly created Forks therefore cannot accidentally advance the source Artifact's Channel; a Fork first needs an explicitly authored HDOC Revision with its own matching identity.
-1. Select an artifact that passes `HDOC/1.0` validation.
-2. Choose **Publish intranet link** in the inspector or **Share link** in the reader.
-3. Helm validates and stores the exact HTML bytes, then copies the returned URL.
-4. Anyone who can reach the configured intranet host can open that URL without access to the rest of the browser library.
+## Two kinds of links
-The filename contains a SHA-256 digest prefix. Publishing the same bytes again is idempotent and returns the same address. Changed bytes produce a new address; an existing share is never overwritten or silently redirected.
+Helm Channels separates a logical Artifact from its immutable Revisions:
-## Service boundary
+- `GET /a/` is the stable Artifact address. It serves the current published Revision with `Cache-Control: no-store`, so an update appears at the same address.
+- `GET /r/.html` is one exact, immutable Revision. It is content-addressed and receives a one-year immutable cache policy.
+
+Every update appends a new byte-for-byte Revision and atomically advances the stable Artifact pointer. Existing Revision files are never overwritten. The service records the pointer and publication state in `channels.json`, while exact sources live under `revisions/` in the configured share directory.
+
+Revoking a Channel makes its stable `/a/` address return `410 Gone`. It does not delete immutable Revision addresses that have already been distributed. This is a publication boundary, not a promise that previously shared bytes can be recalled.
+
+## Owner workflow and API
Run the static app and share API together:
@@ -22,9 +26,53 @@ python3 helm_share_server.py \
--public-base-url http://INTRANET_HOST:4173
```
-- `GET /share/--.html` is available to the intranet and returns a sandboxed, read-only document.
-- `POST /api/share` is accepted only when the connection reaches the server through loopback. The owner can use an SSH local forward; direct intranet visitors cannot publish.
-- Shared HTML lives outside the site root by default in `~/.helm-shares` and is not part of IndexedDB, the Agent inbox, or a Git checkout.
-- 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.
+Channel mutations are accepted only through an owner loopback connection. Browser requests must use `Content-Type: application/json` and an allowed local Origin; command-line clients may omit Origin. When the service runs remotely, use an SSH local forward for management.
+
+Create a Channel:
+
+```http
+POST /api/channels/publish
+Content-Type: application/json
+
+{"html":"..."}
+```
+
+The response contains both `stable_url` and `revision_url`. To publish changed bytes, send the Revision currently observed by the editor:
+
+```http
+POST /api/channels/publish
+Content-Type: application/json
+
+{"html":"...", "base_revision_sha256":""}
+```
+
+If another writer has advanced the Artifact, the service returns `409 revision_conflict` and the current digest instead of silently replacing it. Exact retries of the current published bytes are idempotent.
+
+Revoke the stable address with the same compare-and-swap boundary:
+
+```http
+POST /api/channels/artifacts//revoke
+Content-Type: application/json
+
+{"base_revision_sha256":""}
+```
+
+Owner-only `GET /api/channels` and `GET /api/channels/artifacts/` expose publication records for management UI. They are not an intranet directory.
+
+## Legacy one-shot shares
+
+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.
+
+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.
+
+## Security boundary
-This is deliberate publication, not synchronization: deleting a browser catalog entry does not delete an already shared immutable URL.
+- Public HTML receives a sandboxed CSP, `no-referrer`, and `nosniff` headers.
+- The default share directory is `~/.helm-shares`; Channel source files and catalog metadata are owner-only. Legacy flat files retain their original compatibility permissions.
+- 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.
diff --git a/docs/LOCAL-ARCHIVE-LAYOUT.md b/docs/LOCAL-ARCHIVE-LAYOUT.md
index 775790a..8f5358d 100644
--- a/docs/LOCAL-ARCHIVE-LAYOUT.md
+++ b/docs/LOCAL-ARCHIVE-LAYOUT.md
@@ -33,7 +33,7 @@ The original HTML is stored verbatim in `html`; its embedded HDOC manifest remai
Helm deliberately keeps a catalog overlay separate from the immutable HTML source. A local title, type, tags, summary, source label, project workspace, or `catalogUpdatedAt` may therefore differ from the original HDOC manifest after the user organizes the library. `metadata.project` is a first-class optional catalog field; other overlay facts are preserved through `metadata.extensions`. None are written back into `html` during export or recovery.
-`metadata.extensions` is optional. It preserves JSON-only document fields that are not part of Helm's core record — including `sourceDocumentId`, `identityState`, `catalogUpdatedAt`, and copy provenance. Extension field names cannot replace core fields or use prototype-sensitive names.
+`metadata.extensions` is optional. It preserves JSON-only document fields that are not part of Helm's core record — including `sourceDocumentId`, `identityState`, `catalogUpdatedAt`, workflow state, Fork provenance, and the immutable Revision graph. Extension field names cannot replace core fields or use prototype-sensitive names. This keeps `HARC/1.0` backwards compatible while allowing a clean-library recovery to recreate Channel history.
## Safe import contract
@@ -45,7 +45,7 @@ await Promise.all(plan.acceptedDocuments.map(saveDocument));
// Render plan.conflicts for an explicit user decision.
```
-Do not persist `acceptedDocuments` until the caller has shown the result. A future conflict-resolution UI may offer an explicit copy, replacement, or revision workflow, but `HARC/1.0` makes no implicit data-loss decision.
+Do not persist `acceptedDocuments` until the caller has shown the result. Helm restores Revision history for an accepted Artifact. Existing-ID records remain conflicts: `HARC/1.0` makes no implicit overwrite, merge, or head-advance decision.
## Browser helpers
diff --git a/helm_bridge.py b/helm_bridge.py
index 017e814..e72548c 100644
--- a/helm_bridge.py
+++ b/helm_bridge.py
@@ -3,8 +3,8 @@
The browser app deliberately owns no server-side state. This companion process
gives other agents one safe way to hand documents to that local library:
-validate the document, preserve its exact UTF-8 source, and never overwrite a
-different artifact that claims the same stable HDOC ID.
+validate the document, preserve its exact UTF-8 source, and retain every
+revision submitted for the same stable HDOC artifact ID.
"""
from __future__ import annotations
@@ -43,15 +43,6 @@ def __init__(self, errors: list[str], warnings: list[str] | None = None):
self.warnings = warnings or []
-class IdentityConflictError(RuntimeError):
- """A different source already exists for the document's stable ID."""
-
- def __init__(self, document_id: str, existing: dict[str, Any]):
- super().__init__(f"A different artifact already exists for ID {document_id!r}.")
- self.document_id = document_id
- self.existing = existing
-
-
class HDOCParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
@@ -236,7 +227,15 @@ def _load_catalog(self) -> dict[str, dict[str, Any]]:
payload = json.loads(self.catalog_path.read_text(encoding="utf-8"))
documents = payload.get("documents", {})
if isinstance(documents, dict):
- return {key: value for key, value in documents.items() if isinstance(value, dict)}
+ migrated: dict[str, dict[str, Any]] = {}
+ for key, value in documents.items():
+ if not isinstance(value, dict):
+ continue
+ digest = value.get("sha256")
+ document_id = value.get("id") or key
+ revision_key = f"{document_id}:{digest}" if isinstance(digest, str) else key
+ migrated[revision_key] = value
+ return migrated
except (OSError, json.JSONDecodeError):
pass
raise RuntimeError(f"Helm Bridge catalog is unreadable: {self.catalog_path}")
@@ -276,12 +275,14 @@ def ingest(self, html_bytes: bytes, source: str, submitted_project: dict[str, An
project = declared_project or self._catalog_project(submitted_project)
digest = hashlib.sha256(html_bytes).hexdigest()
with self.lock:
- existing = self.documents.get(document_id)
+ revision_key = f"{document_id}:{digest}"
+ existing = self.documents.get(revision_key)
if existing:
- if existing.get("sha256") == digest:
- return "idempotent", {**existing, "warnings": warnings}
- raise IdentityConflictError(document_id, existing)
- relative_path = Path("artifacts") / safe_filename(document_id, digest)
+ if (self.data_dir / existing["artifact_path"]).read_bytes() != html_bytes:
+ raise RuntimeError("Digest-addressed Bridge storage is inconsistent.")
+ return "idempotent", {**existing, "warnings": warnings}
+ prior_revisions = [record for record in self.documents.values() if record.get("id") == document_id]
+ relative_path = Path("artifacts") / f"{document_id}--{digest}.html"
received_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
record = {
"id": document_id,
@@ -297,13 +298,15 @@ def ingest(self, html_bytes: bytes, source: str, submitted_project: dict[str, An
"source": source[:120] or "unnamed-agent",
"received_at": received_at,
"sha256": digest,
+ "revision_id": f"sha256:{digest}",
+ "revision_number": len(prior_revisions) + 1,
"artifact_path": relative_path.as_posix(),
"warnings": warnings,
}
atomic_write(self.data_dir / relative_path, html_bytes)
- self.documents[document_id] = record
+ self.documents[revision_key] = record
self._save_catalog()
- return "created", record
+ return ("revision" if prior_revisions else "created"), record
@staticmethod
def _catalog_project(value: dict[str, Any] | None) -> dict[str, str] | None:
@@ -320,13 +323,16 @@ def _catalog_project(value: dict[str, Any] | None) -> dict[str, str] | None:
def list_documents(self) -> list[dict[str, Any]]:
with self.lock:
records = []
- for document_id in sorted(self.documents):
- records.append(self.read_document(document_id))
+ for revision_key in sorted(self.documents, key=lambda key: self.documents[key].get("received_at", "")):
+ records.append(self.read_document(revision_key))
return records
def read_document(self, document_id: str) -> dict[str, Any]:
with self.lock:
record = self.documents.get(document_id)
+ if not record:
+ matches = [value for value in self.documents.values() if value.get("id") == document_id]
+ record = max(matches, key=lambda value: value.get("received_at", ""), default=None)
if not record:
raise KeyError(document_id)
try:
@@ -444,13 +450,11 @@ def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
return
try:
status, record = self.server.catalog.ingest(payload, source, submitted_project)
- response_status = HTTPStatus.CREATED if status == "created" else HTTPStatus.OK
+ response_status = HTTPStatus.CREATED if status in {"created", "revision"} else HTTPStatus.OK
self._send_json(response_status, {"status": status, "artifact": record})
except ContractError as error:
self._send_json(HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "invalid_hdoc", "errors": error.errors, "warnings": error.warnings})
- except IdentityConflictError as error:
- self._send_json(HTTPStatus.CONFLICT, {"error": "identity_conflict", "id": error.document_id, "existing": {key: error.existing.get(key) for key in ("id", "sha256", "received_at", "source")}})
- except OSError as error:
+ except (OSError, RuntimeError) as error:
self._send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "storage_error", "message": str(error)})
diff --git a/helm_share_server.py b/helm_share_server.py
index e013533..662f2d1 100644
--- a/helm_share_server.py
+++ b/helm_share_server.py
@@ -1,10 +1,5 @@
#!/usr/bin/env python3
-"""Serve Helm and publish immutable, content-addressed intranet shares.
-
-The browser library remains local IndexedDB. Publishing is an explicit action:
-the server validates one HDOC file, stores its exact bytes under a digest-based
-name, and exposes only that immutable copy through a read-only URL.
-"""
+"""Serve Helm and publish immutable intranet shares and versioned Channels."""
from __future__ import annotations
@@ -13,6 +8,9 @@
import ipaddress
import json
import os
+import re
+import threading
+from datetime import datetime, timezone
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
@@ -23,22 +21,77 @@
API_VERSION = "HSHARE/1.0"
+CHANNEL_API_VERSION = "HCHANNEL/1.0"
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$")
+
+
+def utc_now() -> str:
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+
+
+class ChannelConflictError(RuntimeError):
+ def __init__(self, artifact_id: str, current_revision: str | None):
+ super().__init__(f"Artifact {artifact_id!r} has advanced since the requested base revision.")
+ self.artifact_id = artifact_id
+ self.current_revision = current_revision
+
+
+class ChannelNotFoundError(LookupError):
+ pass
class ShareStore:
+ """Keep legacy flat shares and the append-only Helm Channels catalog."""
+
def __init__(self, root: Path):
self.root = root.expanduser().resolve()
self.root.mkdir(parents=True, exist_ok=True, mode=0o755)
+ self.revision_dir = self.root / "revisions"
+ self.revision_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
+ os.chmod(self.revision_dir, 0o700)
+ self.channel_catalog_path = self.root / "channels.json"
+ self.lock = threading.RLock()
+ self.channel_catalog = self._load_channel_catalog()
- def publish(self, html_bytes: bytes) -> dict[str, Any]:
+ @staticmethod
+ def _validate_source(html_bytes: bytes) -> tuple[dict[str, Any], list[str]]:
if len(html_bytes) > MAX_DOCUMENT_BYTES:
raise ContractError([f"Document exceeds the {MAX_DOCUMENT_BYTES // (1024 * 1024)} MB share limit."])
try:
html = html_bytes.decode("utf-8")
except UnicodeDecodeError as error:
raise ContractError([f"Document must be UTF-8 HTML: {error}."]) from error
- manifest, warnings = validate_hdoc(html)
+ return validate_hdoc(html)
+
+ def _load_channel_catalog(self) -> dict[str, Any]:
+ if not self.channel_catalog_path.exists():
+ return {"schema_version": CHANNEL_API_VERSION, "artifacts": {}, "revisions": {}}
+ try:
+ payload = json.loads(self.channel_catalog_path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as error:
+ raise RuntimeError(f"Helm Channels catalog is unreadable: {self.channel_catalog_path}") from error
+ if (
+ not isinstance(payload, dict)
+ or payload.get("schema_version") != CHANNEL_API_VERSION
+ or not isinstance(payload.get("artifacts"), dict)
+ or not isinstance(payload.get("revisions"), dict)
+ ):
+ raise RuntimeError(f"Helm Channels catalog is invalid: {self.channel_catalog_path}")
+ return payload
+
+ def _save_channel_catalog(self) -> None:
+ self.channel_catalog["updated_at"] = utc_now()
+ atomic_write(
+ self.channel_catalog_path,
+ json.dumps(self.channel_catalog, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8"),
+ mode=0o600,
+ )
+
+ def publish(self, html_bytes: bytes) -> dict[str, Any]:
+ """Legacy one-shot publication. Its API and flat-file layout stay stable."""
+ manifest, warnings = self._validate_source(html_bytes)
digest = hashlib.sha256(html_bytes).hexdigest()
filename = safe_filename(manifest["id"], digest)
path = self.root / filename
@@ -59,6 +112,7 @@ def publish(self, html_bytes: bytes) -> dict[str, Any]:
}
def resolve(self, filename: str) -> Path | None:
+ """Resolve an existing legacy /share URL."""
decoded = unquote(filename)
if not decoded or decoded != Path(decoded).name or not decoded.endswith(".html"):
return None
@@ -67,11 +121,118 @@ def resolve(self, filename: str) -> Path | None:
return None
return candidate
+ 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"]
+ digest = hashlib.sha256(html_bytes).hexdigest()
+ now = utc_now()
+ with self.lock:
+ artifact = self.channel_catalog["artifacts"].get(artifact_id)
+ current = artifact.get("current_revision") if artifact else None
+ if artifact and digest == current and artifact.get("status") == "published":
+ return {"state": "idempotent", "artifact": dict(artifact), "sha256": digest, "warnings": warnings}
+ if artifact and base_revision_sha256 != current:
+ raise ChannelConflictError(artifact_id, current)
+ if not artifact and base_revision_sha256 is not None:
+ raise ChannelConflictError(artifact_id, None)
+
+ revision_path = self.revision_dir / f"{digest}.html"
+ revision = self.channel_catalog["revisions"].get(digest)
+ if revision:
+ if revision.get("artifact_id") != artifact_id or not revision_path.is_file() or revision_path.read_bytes() != html_bytes:
+ raise RuntimeError("Revision-addressed Channel storage is inconsistent.")
+ else:
+ atomic_write(revision_path, html_bytes, mode=0o600)
+ self.channel_catalog["revisions"][digest] = {
+ "artifact_id": artifact_id,
+ "sha256": digest,
+ "path": f"revisions/{digest}.html",
+ "published_at": now,
+ "manifest": manifest,
+ }
+
+ revisions = list(artifact.get("revisions", [])) if artifact else []
+ if digest not in revisions:
+ revisions.append(digest)
+ first_published_at = artifact.get("published_at", now) if artifact else now
+ was_revoked = bool(artifact and artifact.get("status") == "revoked")
+ record = {
+ "id": artifact_id,
+ "title": manifest["title"],
+ "type": manifest["type"],
+ "summary": manifest["summary"],
+ "tags": manifest["tags"],
+ **({"project": manifest["project"]} if isinstance(manifest.get("project"), dict) else {}),
+ "status": "published",
+ "current_revision": digest,
+ "published_at": first_published_at,
+ "updated_at": now,
+ "revoked_at": None,
+ "revisions": revisions,
+ }
+ self.channel_catalog["artifacts"][artifact_id] = record
+ self._save_channel_catalog()
+ state = "created" if artifact is None else "republished" if was_revoked else "updated"
+ return {"state": state, "artifact": dict(record), "sha256": digest, "warnings": warnings}
+
+ def revoke_channel(self, artifact_id: str, base_revision_sha256: str | None) -> dict[str, Any]:
+ if not ARTIFACT_ID_PATTERN.fullmatch(artifact_id):
+ raise ChannelNotFoundError(artifact_id)
+ with self.lock:
+ artifact = self.channel_catalog["artifacts"].get(artifact_id)
+ if not artifact:
+ raise ChannelNotFoundError(artifact_id)
+ current = artifact.get("current_revision")
+ if base_revision_sha256 != current:
+ raise ChannelConflictError(artifact_id, current)
+ if artifact.get("status") == "revoked":
+ return {"state": "idempotent", "artifact": dict(artifact)}
+ now = utc_now()
+ artifact = {**artifact, "status": "revoked", "revoked_at": now, "updated_at": now}
+ self.channel_catalog["artifacts"][artifact_id] = artifact
+ self._save_channel_catalog()
+ return {"state": "revoked", "artifact": dict(artifact)}
+
+ def artifact(self, artifact_id: str) -> dict[str, Any] | None:
+ decoded = unquote(artifact_id)
+ if not ARTIFACT_ID_PATTERN.fullmatch(decoded):
+ return None
+ with self.lock:
+ record = self.channel_catalog["artifacts"].get(decoded)
+ return dict(record) if record else None
+
+ def artifacts(self) -> list[dict[str, Any]]:
+ with self.lock:
+ return [dict(record) for record in self.channel_catalog["artifacts"].values()]
+
+ def resolve_artifact(self, artifact_id: str) -> tuple[str, Path] | None:
+ record = self.artifact(artifact_id)
+ if not record:
+ return None
+ if record.get("status") == "revoked":
+ return "revoked", self.revision_dir / f"{record['current_revision']}.html"
+ path = self.revision_dir / f"{record['current_revision']}.html"
+ if not path.is_file():
+ raise RuntimeError("The current Channel revision is missing.")
+ return "published", path
+
+ def resolve_revision(self, filename: str) -> Path | None:
+ decoded = unquote(filename)
+ match = REVISION_FILENAME_PATTERN.fullmatch(decoded)
+ if not match:
+ return None
+ digest = match.group(1)
+ with self.lock:
+ if digest not in self.channel_catalog["revisions"]:
+ return None
+ path = self.revision_dir / decoded
+ return path if path.is_file() else None
+
class ShareRequestHandler(SimpleHTTPRequestHandler):
server: "ShareHTTPServer"
protocol_version = "HTTP/1.1"
- server_version = "HelmShare/1.0"
+ server_version = "HelmShare/1.1"
sys_version = ""
def __init__(self, *args: Any, **kwargs: Any):
@@ -80,14 +241,17 @@ def __init__(self, *args: Any, **kwargs: Any):
def log_message(self, format: str, *args: Any) -> None:
print(f"Helm Share {self.address_string()} {self.command} {self.path} {args[-2] if len(args) >= 2 else ''}")
- def _send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None:
+ def _send_json(self, status: HTTPStatus, payload: dict[str, Any], head: bool = False) -> None:
encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.send_header("Cache-Control", "no-store")
+ if self.close_connection:
+ self.send_header("Connection", "close")
self.end_headers()
- self.wfile.write(encoded)
+ if not head:
+ self.wfile.write(encoded)
def _loopback_writer(self) -> bool:
try:
@@ -95,31 +259,117 @@ def _loopback_writer(self) -> bool:
except ValueError:
return False
- @staticmethod
- def _blocked_site_path(path: str) -> bool:
- suffix = Path(path).suffix.lower()
- return path.startswith(("/.", "/scripts/", "/tests/")) or suffix in {".py", ".pyc", ".sh"}
+ def _owner_request(self) -> bool:
+ if not self._loopback_writer():
+ self.close_connection = True
+ self._send_json(HTTPStatus.FORBIDDEN, {"error": "read_only_network", "message": "Channel management is only accepted through the owner's loopback connection."})
+ return False
+ origin = self.headers.get("Origin")
+ if origin and origin not in self.server.allowed_origins:
+ self.close_connection = True
+ self._send_json(HTTPStatus.FORBIDDEN, {"error": "origin_forbidden"})
+ return False
+ return True
- def do_GET(self) -> None: # noqa: N802
+ def _read_json(self) -> Any | None:
+ content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
+ if content_type != "application/json":
+ self.close_connection = True
+ self._send_json(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, {"error": "content_type_required", "expected": "application/json"})
+ return None
+ try:
+ content_length = int(self.headers.get("Content-Length", "-1"))
+ except ValueError:
+ content_length = -1
+ if content_length < 0 or content_length > MAX_REQUEST_BYTES:
+ self.close_connection = True
+ self._send_json(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": "payload_too_large", "max_bytes": MAX_REQUEST_BYTES})
+ return None
+ try:
+ return json.loads(self.rfile.read(content_length))
+ except json.JSONDecodeError:
+ self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_json"})
+ return None
+
+ def _blocked_site_path(self, path: str) -> bool:
+ decoded = path
+ for _ in range(4):
+ expanded = unquote(decoded)
+ if expanded == decoded:
+ break
+ decoded = expanded
+ candidate = Path(self.translate_path(decoded)).resolve()
+ try:
+ relative = candidate.relative_to(self.server.site_root)
+ except ValueError:
+ return True
+ parts = relative.parts
+ if any(part.startswith(".") for part in parts) or (parts and parts[0] in {"scripts", "tests"}):
+ return True
+ try:
+ candidate.relative_to(self.server.store.root)
+ return True
+ except ValueError:
+ pass
+ return candidate.suffix.lower() in {".py", ".pyc", ".sh"}
+
+ def _send_html(self, path: Path, immutable: bool, head: bool = False) -> None:
+ payload = b"" if head else path.read_bytes()
+ length = path.stat().st_size
+ self.send_response(HTTPStatus.OK)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(length))
+ self.send_header("Cache-Control", "public, max-age=31536000, immutable" if immutable else "no-store")
+ self.send_header("Content-Security-Policy", "sandbox allow-popups; default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:")
+ self.send_header("Referrer-Policy", "no-referrer")
+ self.send_header("X-Content-Type-Options", "nosniff")
+ self.end_headers()
+ if not head:
+ self.wfile.write(payload)
+
+ def _serve_read(self, head: bool = False) -> bool:
path = urlparse(self.path).path
- if path == "/api/share/health":
- self._send_json(HTTPStatus.OK, {"ok": True, "api_version": API_VERSION, "public_base_url": self.server.public_base_url})
- return
if path.startswith("/share/"):
shared = self.server.store.resolve(path.removeprefix("/share/"))
if not shared:
self.send_error(HTTPStatus.NOT_FOUND)
+ else:
+ self._send_html(shared, immutable=True, head=head)
+ return True
+ if path.startswith("/a/"):
+ resolved = self.server.store.resolve_artifact(path.removeprefix("/a/"))
+ if not resolved:
+ self.send_error(HTTPStatus.NOT_FOUND)
+ elif resolved[0] == "revoked":
+ self.send_error(HTTPStatus.GONE, "This Helm Channel has been revoked.")
+ else:
+ self._send_html(resolved[1], immutable=False, head=head)
+ return True
+ if path.startswith("/r/"):
+ revision = self.server.store.resolve_revision(path.removeprefix("/r/"))
+ if not revision:
+ self.send_error(HTTPStatus.NOT_FOUND)
+ else:
+ self._send_html(revision, immutable=True, head=head)
+ return True
+ return False
+
+ def do_GET(self) -> None: # noqa: N802
+ path = urlparse(self.path).path
+ if path == "/api/share/health":
+ self._send_json(HTTPStatus.OK, {"ok": True, "api_version": API_VERSION, "channel_api_version": CHANNEL_API_VERSION, "public_base_url": self.server.public_base_url})
+ return
+ if path == "/api/channels":
+ if self._owner_request():
+ self._send_json(HTTPStatus.OK, {"ok": True, "artifacts": self.server.store.artifacts()})
+ return
+ if path.startswith("/api/channels/artifacts/"):
+ if not self._owner_request():
return
- payload = shared.read_bytes()
- self.send_response(HTTPStatus.OK)
- self.send_header("Content-Type", "text/html; charset=utf-8")
- self.send_header("Content-Length", str(len(payload)))
- self.send_header("Cache-Control", "public, max-age=31536000, immutable")
- self.send_header("Content-Security-Policy", "sandbox allow-popups; default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:")
- self.send_header("Referrer-Policy", "no-referrer")
- self.send_header("X-Content-Type-Options", "nosniff")
- self.end_headers()
- self.wfile.write(payload)
+ artifact = self.server.store.artifact(path.removeprefix("/api/channels/artifacts/"))
+ self._send_json(HTTPStatus.OK, {"ok": True, "artifact": artifact}) if artifact else self._send_json(HTTPStatus.NOT_FOUND, {"error": "not_found"})
+ return
+ if self._serve_read():
return
if self._blocked_site_path(path):
self.send_error(HTTPStatus.NOT_FOUND)
@@ -128,19 +378,7 @@ def do_GET(self) -> None: # noqa: N802
def do_HEAD(self) -> None: # noqa: N802
path = urlparse(self.path).path
- if path.startswith("/share/"):
- shared = self.server.store.resolve(path.removeprefix("/share/"))
- if not shared:
- self.send_error(HTTPStatus.NOT_FOUND)
- return
- self.send_response(HTTPStatus.OK)
- self.send_header("Content-Type", "text/html; charset=utf-8")
- self.send_header("Content-Length", str(shared.stat().st_size))
- self.send_header("Cache-Control", "public, max-age=31536000, immutable")
- self.send_header("Content-Security-Policy", "sandbox allow-popups; default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:")
- self.send_header("Referrer-Policy", "no-referrer")
- self.send_header("X-Content-Type-Options", "nosniff")
- self.end_headers()
+ if self._serve_read(head=True):
return
if self._blocked_site_path(path):
self.send_error(HTTPStatus.NOT_FOUND)
@@ -148,32 +386,44 @@ def do_HEAD(self) -> None: # noqa: N802
super().do_HEAD()
def do_POST(self) -> None: # noqa: N802
- if urlparse(self.path).path != "/api/share":
+ path = urlparse(self.path).path
+ legacy = path == "/api/share"
+ 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:
self._send_json(HTTPStatus.NOT_FOUND, {"error": "not_found"})
return
- if not self._loopback_writer():
- self._send_json(HTTPStatus.FORBIDDEN, {"error": "read_only_network", "message": "Publishing is only accepted through the owner's loopback connection."})
+ if not self._owner_request():
return
- try:
- content_length = int(self.headers.get("Content-Length", "-1"))
- except ValueError:
- content_length = -1
- if content_length < 0 or content_length > MAX_REQUEST_BYTES:
- self._send_json(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": "payload_too_large", "max_bytes": MAX_REQUEST_BYTES})
+ payload = self._read_json()
+ if payload is None:
return
try:
- payload = json.loads(self.rfile.read(content_length))
+ 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)
+ self._send_json(HTTPStatus.OK, {**result, "ok": True})
+ return
html = payload.get("html") if isinstance(payload, dict) else None
if not isinstance(html, str):
raise ContractError(["Request JSON must contain an HTML string."])
- result = self.server.store.publish(html.encode("utf-8"))
- public_path = f"/share/{quote(result['filename'])}"
+ if legacy:
+ result = self.server.store.publish(html.encode("utf-8"))
+ public_path = f"/share/{quote(result['filename'])}"
+ self._send_json(HTTPStatus.CREATED if result["state"] == "created" else HTTPStatus.OK, {**result, "ok": True, "path": public_path, "url": f"{self.server.public_base_url}{public_path}"})
+ return
+ result = self.server.store.publish_channel(html.encode("utf-8"), payload.get("base_revision_sha256"))
+ artifact_id = result["artifact"]["id"]
+ stable_path = f"/a/{quote(artifact_id)}"
+ revision_path = f"/r/{result['sha256']}.html"
self._send_json(
HTTPStatus.CREATED if result["state"] == "created" else HTTPStatus.OK,
- {**result, "ok": True, "path": public_path, "url": f"{self.server.public_base_url}{public_path}"},
+ {**result, "ok": True, "stable_path": stable_path, "stable_url": f"{self.server.public_base_url}{stable_path}", "revision_path": revision_path, "revision_url": f"{self.server.public_base_url}{revision_path}"},
)
- except json.JSONDecodeError:
- self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_json"})
+ except ChannelConflictError as error:
+ self._send_json(HTTPStatus.CONFLICT, {"error": "revision_conflict", "artifact_id": error.artifact_id, "current_revision_sha256": error.current_revision})
+ except ChannelNotFoundError:
+ self._send_json(HTTPStatus.NOT_FOUND, {"error": "not_found"})
except ContractError as error:
self._send_json(HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "contract_invalid", "errors": error.errors, "warnings": error.warnings})
except (OSError, RuntimeError) as error:
@@ -187,11 +437,16 @@ def __init__(self, address: tuple[str, int], site_root: Path, store: ShareStore,
self.site_root = site_root.resolve()
self.store = store
self.public_base_url = public_base_url.rstrip("/")
+ port = address[1]
+ self.allowed_origins = {f"http://127.0.0.1:{port}", f"http://localhost:{port}"}
super().__init__(address, lambda *args, **kwargs: ShareRequestHandler(*args, directory=self.site_root, **kwargs))
+ actual_port = self.server_address[1]
+ if port == 0:
+ self.allowed_origins = {f"http://127.0.0.1:{actual_port}", f"http://localhost:{actual_port}"}
def main() -> None:
- parser = argparse.ArgumentParser(description="Serve Helm with immutable intranet share URLs.")
+ parser = argparse.ArgumentParser(description="Serve Helm with immutable intranet shares and versioned Channels.")
parser.add_argument("--host", default=os.environ.get("HELM_SHARE_HOST", "127.0.0.1"))
parser.add_argument("--port", type=int, default=int(os.environ.get("HELM_SHARE_PORT", "4173")))
parser.add_argument("--site-root", type=Path, default=Path(__file__).resolve().parent)
diff --git a/index.html b/index.html
index 2658e77..c6e0a35 100644
--- a/index.html
+++ b/index.html
@@ -144,13 +144,21 @@ Documents, not loose files.
◌ Select an artifact to inspect its record.
-
REPORT ×
+
+
+ CURRENT REVISION Revision 01
+ Not published
+ Mark reviewed History & compare →
+
PROJECT
CATALOG UPDATED
SOURCE
IDENTITY
FORMAT
SIZE
Open reader ↗ Export .html
Publish intranet link
-
+
STABLE ADDRESS Published revisions remain available at their immutable addresses.
+
Revoke stable address
+
LINEAGE Forked from
Open source revision ↗
+
Fork current revision
Edit catalog metadata
@@ -207,7 +215,7 @@
-
+
+
+
+
+
+
+ REVISIONS 01
+
+ Fork selected revision
+
+
+
+ Both revisions render at the same viewport. The original HTML remains sandboxed and unchanged.
+
+
+
+
+
+
+
+
+
PORTABLE BACKUP
Your library is an archive. ×
-
Export every original HTML artifact and its metadata into one portable archive file. Importing never overwrites: same-ID conflicts are safely skipped until revision history exists.
+
Export every immutable revision, its lineage, and catalog metadata into one portable archive file. Importing never overwrites an artifact already present in this library.
00 ARTIFACTS READY HTML source + metadata + timestamps
Browser library only Connect a local folder for explicit sync and recovery.
Connect folder Sync now Recover from folder
Import archive Export archive ↓
@@ -230,9 +268,10 @@
WORKSPACE
Keyboard shortcuts × ⌘ K Focus search
N New from template
I Import HTML
Esc Close dialogs
+
-
+