Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Agent / project
└─ Helm Bridge inbox ── owner review ──> browser library (IndexedDB)
│ │
└─ exact original bytes ├─ HARC backup / explicit folder sync
└─ explicit immutable intranet share
└─ reviewed Channel + immutable Revisions
```

The important boundary is deliberate: submitting to the Bridge is a handoff, not permission to modify the browser library.
Expand Down Expand Up @@ -82,10 +82,12 @@ Helm has no package-install step. Run the same checks used by CI:

```bash
python3 -m unittest discover -s tests -p 'test_*.py'
python3 helm_share_server.py --host 127.0.0.1 --port 4173
python3 -m http.server 4183 --bind 127.0.0.1
```

The browser smoke pages are available at [`/tests/contract-smoke.html`](tests/contract-smoke.html) and [`/tests/repair-smoke.html`](tests/repair-smoke.html) while the local server is running. Both should show `passed`.
Open the browser smoke pages at `http://127.0.0.1:4183/tests/contract-smoke.html`, `channel-store-smoke.html`, and `repair-smoke.html`. The production share server deliberately blocks `/tests`; use this isolated static development port for browser checks.

For a reviewed remote intranet installation, use [`scripts/deploy-remote`](scripts/deploy-remote). It deploys only the committed tree, tests it before activation, keeps runtime shares outside the release, and rolls back a failed health check. See [`docs/INTRANET-SHARING.md`](docs/INTRANET-SHARING.md).

## Scope and boundaries

Expand Down
125 changes: 106 additions & 19 deletions app.js

Large diffs are not rendered by default.

43 changes: 42 additions & 1 deletion channel-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
const REVISION_STORE = 'revisions';
const SETTINGS_STORE = 'settings';
const MIGRATION_KEY = 'channelsMigrationV1';
const LEGACY_SHARE_NORMALISATION_KEY = 'legacyShareNormalisationV1';
const STATUSES = new Set(['draft', 'in-review', 'published', 'archived']);
const CATALOG_FIELDS = new Set(['title', 'type', 'tags', 'summary', 'source', 'project']);
const ARTIFACT_FIELDS = new Set([
Expand Down Expand Up @@ -92,6 +93,27 @@
return (Array.isArray(value) ? value : []).filter((tag) => typeof tag === 'string').map((tag) => tag.trim()).filter(Boolean);
}

function normaliseMigratedShare(value) {
if (!isPlainObject(value)) return null;
if (value.kind === 'legacy') return cloneJson(value, 'share');
if (safeString(value.stableUrl)) return cloneJson(value, 'share');
const legacyUrl = safeString(value.legacyUrl, safeString(value.url));
let legacyPath = safeString(value.legacyPath, safeString(value.path));
if (!legacyPath && legacyUrl) {
try { legacyPath = new URL(legacyUrl, 'http://helm.local').pathname; }
catch (_error) { /* Leave malformed historical metadata visible but non-actionable. */ }
}
if (!legacyUrl && !legacyPath) return cloneJson(value, 'share');
return {
kind: 'legacy',
legacyUrl: legacyUrl || legacyPath,
legacyPath: legacyPath || null,
sha256: safeString(value.sha256) || null,
publishedAt: timestamp(value.publishedAt, null),
revokedAt: timestamp(value.revokedAt, null)
};
}

function extraFields(input, knownFields) {
const extensions = isPlainObject(input.extensions) ? cloneJson(input.extensions, 'extensions') : {};
for (const [key, value] of Object.entries(input)) {
Expand Down Expand Up @@ -277,7 +299,7 @@
status: document.share ? 'published' : 'draft',
publishedRevisionId
});
const revision = revisionFromInput(document.id, document, hash, { parent: null, now: timestamp(document.updatedAt, new Date().toISOString()), share: document.share });
const revision = revisionFromInput(document.id, document, hash, { parent: null, now: timestamp(document.updatedAt, new Date().toISOString()), share: normaliseMigratedShare(document.share) });
prepared.push({ artifact, revision });
}
if (invalidLegacyRecords.length) {
Expand Down Expand Up @@ -309,9 +331,28 @@
return value;
}

async function normaliseLegacyShares() {
const db = await database();
const marker = await requestResult(db.transaction(SETTINGS_STORE, 'readonly').objectStore(SETTINGS_STORE).get(LEGACY_SHARE_NORMALISATION_KEY));
if (marker?.value?.complete) return marker.value;
const revisions = await getAll(REVISION_STORE);
const updates = revisions
.map((revision) => ({ revision, share: normaliseMigratedShare(revision.share) }))
.filter(({ revision, share }) => JSON.stringify(revision.share) !== JSON.stringify(share));
const completedAt = new Date().toISOString();
const value = { complete: true, normalisedCount: updates.length, completedAt };
const tx = db.transaction([REVISION_STORE, SETTINGS_STORE], 'readwrite');
const revisionStore = tx.objectStore(REVISION_STORE);
for (const { revision, share } of updates) revisionStore.put({ ...revision, share });
tx.objectStore(SETTINGS_STORE).put({ key: LEGACY_SHARE_NORMALISATION_KEY, value });
await transactionDone(tx);
return value;
}

async function open() {
await database();
await migrateLegacyDocuments();
await normaliseLegacyShares();
return repository;
}

Expand Down
9 changes: 8 additions & 1 deletion docs/HTML-DOCUMENT-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Any project, agent, or person that generates an HTML artifact for the Helm libra
4. Use semantic HTML: one `h1`, ordered headings, real lists and tables, descriptive links, and no text encoded only in images.
5. State sources, data dates, assumptions, and confidence wherever factual claims would otherwise become untraceable.
6. Follow [`REPORT-DESIGN-STANDARD.md`](REPORT-DESIGN-STANDARD.md): use a calm, evidence-forward, answer-first report system with generous whitespace, useful hierarchy, quiet neutral surfaces, restrained accent color, and data clarity over dashboard decoration. When a comparison, sequence, hierarchy, magnitude, change, composition, or uncertainty is material, include one or more meaningful visual evidence modules selected from the visual grammar. A reader must be able to find the purpose or short answer, the supporting evidence, and the resulting action or boundary without relying on interaction.
7. Do not depend on a host app for fonts, scripts, navigation, APIs, authentication, or core content. Remote images and fonts may be used only as progressive enhancement; a document must still be meaningful without them.
7. Do not depend on a host app for fonts, scripts, navigation, APIs, authentication, or core content. Remote images and fonts may be used only as progressive enhancement; a document must still be meaningful without them. Relative file references such as `../stage2/report.html` or `./chart.png` are non-portable because sibling files do not travel with one standalone artifact. Embed essential resources and use absolute URLs for external destinations.
8. Treat user-supplied or third-party HTML as untrusted. Helm previews it in a sandbox, and authors should avoid scripts unless there is a clear, documented reason.

## Report presentation standard
Expand Down Expand Up @@ -81,6 +81,13 @@ These duplicate the core manifest fields so a simple file indexer can inspect th
<meta name="helm:tags" content="research, active">
```

## Links and embedded resources

- Fragment links such as `#evidence`, absolute web/source URLs, and purpose-specific schemes such as `mailto:` and `tel:` are valid navigation targets.
- A relative `href` points into the author's original folder layout, which Helm does not import. Replace it with an absolute URL, or preserve the referenced evidence inside the artifact.
- Essential images, audio, video, fonts, and CSS must be embedded, normally with inline markup/CSS or a `data:` URL. A relative `src` or CSS `url(...)` violates the standalone portability expectation and is reported by validation.
- A remote media or CSS resource is a progressive enhancement, not part of the retained evidence original. Helm reports it as a portability warning, so a document that relies on it does not receive a 100-point validation score.

## Reference skeleton

```html
Expand Down
24 changes: 23 additions & 1 deletion docs/INTRANET-SHARING.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,17 @@ Existing one-shot sharing remains fully compatible:

- `POST /api/share` publishes one validated document through loopback.
- `GET /share/<document-id>--<digest-prefix>.html` remains an immutable public address.
- Existing flat share files are not migrated, renamed, redirected, or deleted.
- Existing flat share files are not renamed or redirected during the Channels migration. Helm keeps their original URL visible as a **Legacy immutable share** instead of misreporting it as a Channel.
- The owner may explicitly retire one exact legacy URL with `POST /api/share/revoke`. The request must include both its `/share/...` path and full SHA-256 digest; Helm verifies the path, filename digest prefix, and stored bytes before deleting that one file.

```http
POST /api/share/revoke
Content-Type: application/json

{"path":"/share/<document-id>--<digest-prefix>.html", "sha256":"<full sha256>"}
```

After this explicit action the legacy URL returns `404 Not Found`. Unlike a Channel revoke, there is no retained content-addressed Revision behind a legacy one-shot share.

The legacy endpoint never advances a Channel because it has no base Revision for conflict detection. Publish through `/api/channels/publish` when one stable address should evolve.

Expand All @@ -76,3 +86,15 @@ The legacy endpoint never advances a Channel because it has no base Revision for
- Restrict the listening port to the intended private network at the host firewall.
- Never publish secrets, credentials, private source material, or machine-specific access data.
- Deleting a browser catalog entry does not delete an already published URL.

## Repeatable remote deployment

Deploy only a reviewed, committed tree. Runtime shares live outside the application directory and survive an atomic upgrade:

```bash
scripts/deploy-remote \
--host USER@INTRANET_HOST \
--public-base-url http://INTRANET_HOST:4173
```

The command archives `HEAD`, runs the full test suite in a staging directory on the target, swaps the application directory, restarts the configured tmux service, and rolls back if the Channel health check fails. It never copies browser data, Bridge tokens, or `~/.helm-shares`.
2 changes: 2 additions & 0 deletions docs/REPORT-DESIGN-STANDARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ Each visual module must make one real relationship easier to understand. Before

Use inline SVG or semantic HTML/CSS for diagrams and simple charts. They keep the artifact self-contained, printable, searchable, and legible at narrow widths. Do not require a runtime CDN, a canvas-only rendering, a remote image, or interaction to learn the core result.

Keep evidence navigation portable as well as visual. Do not link to sibling files with relative paths such as `../stage2/report.html`: Helm retains one HTML evidence original, not the source directory tree. Use an absolute, durable source URL, or bring the relevant finding and provenance into the artifact. Embed essential visual resources rather than referencing relative image, font, media, or stylesheet files.

## Visual grammar library

Use these patterns consistently rather than inventing a new decorative shape for every report:
Expand Down
48 changes: 44 additions & 4 deletions helm_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import argparse
import hashlib
import ipaddress
import json
import os
import re
Expand All @@ -30,7 +31,7 @@
HDOC_VERSION = "HDOC/1.0"
MAX_DOCUMENT_BYTES = 5 * 1024 * 1024
DOCUMENT_TYPES = {"report", "brief", "reference", "dashboard", "note"}
DEFAULT_CORS_ORIGINS = {"http://127.0.0.1:4173", "http://localhost:4173"}
DEFAULT_CORS_ORIGINS: set[str] = set()
ID_PATTERN = re.compile(r"^[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?$")


Expand All @@ -54,6 +55,7 @@ def __init__(self) -> None:
self.unsafe_scripts = 0
self.event_handlers: list[str] = []
self.external_dependencies: list[str] = []
self.relative_dependencies: list[str] = []

def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
attributes = {name.lower(): value or "" for name, value in attrs}
Expand All @@ -72,8 +74,16 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None
for name, value in attributes.items():
if name.startswith("on"):
self.event_handlers.append(name)
if name in {"src", "href"} and re.match(r"^https?://", value, re.IGNORECASE):
self.external_dependencies.append(value)
if name not in {"src", "href"} or not value.strip():
continue
reference = value.strip()
if re.match(r"^(?:https?:)?//", reference, re.IGNORECASE):
# An ordinary absolute anchor is provenance/navigation, not a
# runtime dependency. Remote src and non-anchor href values are.
if name == "src" or tag != "a":
self.external_dependencies.append(reference)
elif not re.match(r"^(?:#|data:|blob:|mailto:|tel:|[a-z][a-z0-9+.-]*:)", reference, re.IGNORECASE):
self.relative_dependencies.append(reference)

def handle_data(self, data: str) -> None:
if self._manifest_parts is not None:
Expand Down Expand Up @@ -177,6 +187,8 @@ def validate_hdoc(html: str) -> tuple[dict[str, Any], list[str]]:
errors.append(f"{name} must exactly match the manifest.")
if parser.external_dependencies:
warnings.append("The artifact references remote resources; it should remain meaningful without them.")
if parser.relative_dependencies:
warnings.append("The artifact references relative files or links that will not travel with a standalone HTML document; use absolute links or embed essential resources.")
if errors:
raise ContractError(errors, warnings)
return manifest, warnings
Expand Down Expand Up @@ -342,6 +354,34 @@ def read_document(self, document_id: str) -> dict[str, Any]:
return {**record, "html": html}


def is_allowed_browser_origin(origin: str, explicit_origins: set[str] | None = None) -> bool:
"""Accept an explicitly configured origin or a syntactically exact loopback origin.

Helm's UI is commonly served on an ephemeral development port. Restricting
by loopback host preserves that workflow without reflecting arbitrary web
origins into this owner-local API.
"""
if origin in (explicit_origins or set()):
return True
try:
parsed = urlparse(origin)
# Accessing port deliberately rejects malformed values such as :abc.
parsed.port
except ValueError:
return False
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
return False
if parsed.username or parsed.password or parsed.path or parsed.params or parsed.query or parsed.fragment:
return False
hostname = (parsed.hostname or "").rstrip(".").lower()
if hostname == "localhost":
return True
try:
return ipaddress.ip_address(hostname).is_loopback
except ValueError:
return False


class BridgeRequestHandler(BaseHTTPRequestHandler):
server: "BridgeHTTPServer"
protocol_version = "HTTP/1.1"
Expand All @@ -352,7 +392,7 @@ def log_message(self, format: str, *args: Any) -> None:

def _origin_allowed(self) -> str | None:
origin = self.headers.get("Origin")
return origin if origin and origin in self.server.cors_origins else None
return origin if origin and is_allowed_browser_origin(origin, self.server.cors_origins) else None

def _send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None:
encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8")
Expand Down
38 changes: 37 additions & 1 deletion helm_share_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
MAX_REQUEST_BYTES = MAX_DOCUMENT_BYTES + 64 * 1024
ARTIFACT_ID_PATTERN = re.compile(r"^[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?$")
REVISION_FILENAME_PATTERN = re.compile(r"^([0-9a-f]{64})\.html$")
LEGACY_SHARE_FILENAME_PATTERN = re.compile(r"^.+--([0-9a-f]{12})\.html$")


def utc_now() -> str:
Expand Down Expand Up @@ -121,6 +122,32 @@ def resolve(self, filename: str) -> Path | None:
return None
return candidate

def revoke_legacy(self, public_path: str, expected_sha256: str) -> dict[str, Any]:
"""Remove one exact legacy flat share without accepting arbitrary root files."""
parsed = urlparse(public_path)
if parsed.query or parsed.fragment or not parsed.path.startswith("/share/"):
raise ChannelNotFoundError(public_path)
filename = unquote(parsed.path.removeprefix("/share/"))
match = LEGACY_SHARE_FILENAME_PATTERN.fullmatch(filename)
if not match or not re.fullmatch(r"[0-9a-f]{64}", expected_sha256 or ""):
raise ChannelNotFoundError(public_path)
if match.group(1) != expected_sha256[:12]:
raise ChannelConflictError(filename, None)
with self.lock:
candidate = self.resolve(filename)
if not candidate:
raise ChannelNotFoundError(public_path)
actual_sha256 = hashlib.sha256(candidate.read_bytes()).hexdigest()
if actual_sha256 != expected_sha256:
raise ChannelConflictError(filename, actual_sha256)
candidate.unlink()
return {
"state": "revoked",
"path": f"/share/{quote(filename)}",
"sha256": expected_sha256,
"revoked_at": utc_now(),
}

def publish_channel(self, html_bytes: bytes, base_revision_sha256: str | None = None) -> dict[str, Any]:
manifest, warnings = self._validate_source(html_bytes)
artifact_id = manifest["id"]
Expand Down Expand Up @@ -388,9 +415,10 @@ def do_HEAD(self) -> None: # noqa: N802
def do_POST(self) -> None: # noqa: N802
path = urlparse(self.path).path
legacy = path == "/api/share"
legacy_revoke = path == "/api/share/revoke"
channel_publish = path == "/api/channels/publish"
revoke_match = re.fullmatch(r"/api/channels/artifacts/([^/]+)/revoke", path)
if not legacy and not channel_publish and not revoke_match:
if not legacy and not legacy_revoke and not channel_publish and not revoke_match:
self._send_json(HTTPStatus.NOT_FOUND, {"error": "not_found"})
return
if not self._owner_request():
Expand All @@ -399,6 +427,14 @@ def do_POST(self) -> None: # noqa: N802
if payload is None:
return
try:
if legacy_revoke:
public_path = payload.get("path") if isinstance(payload, dict) else None
expected_sha256 = payload.get("sha256") if isinstance(payload, dict) else None
if not isinstance(public_path, str) or not isinstance(expected_sha256, str):
raise ContractError(["Request JSON must contain the legacy share path and SHA-256 digest."])
result = self.server.store.revoke_legacy(public_path, expected_sha256)
self._send_json(HTTPStatus.OK, {**result, "ok": True})
return
if revoke_match:
base = payload.get("base_revision_sha256") if isinstance(payload, dict) else None
result = self.server.store.revoke_channel(unquote(revoke_match.group(1)), base)
Expand Down
Loading
Loading