Phase 5a: availability portal foundation (ADR-0029) - #13
Merged
Conversation
Delivers the read-only half of the public availability portal, behind a default-false portal.enabled. Requests, messaging, and the live layer are P5-b..P5-d; nothing here is exposed. The boundary is structural rather than a filter. internal/portal owns a separate database and does not import the private store, read model, estimator, or domain packages, and a test parses its own imports to keep that true. internal/portalbridge is the single inbound path, narrowing an estimate to windows, a generation time, a horizon, and a status. Confidence labels are withheld on purpose: ADR-0022 measured the buckets inverted on real history (High 0.61 hit rate against Medium 0.81), so a published label would misinform. Every rendered state instead carries the measured uncertainty in plain language, marks itself stale past six hours, and withholds any current-state claim past a day. Unknown, expired, and revoked links share one store-level error, so a handler is never told which occurred. Passcodes are argon2id with a per profile-and-source backoff and no global lockout an attacker could trigger. Source throttling runs before link resolution, which is what keeps the KDF from becoming a memory-exhaustion lever. Driving the page in a real browser found two defects the Go suite could not: an Origin-only CSRF gate refused every genuine login, because Referrer-Policy: no-referrer makes browsers send Origin: null on same-origin form posts, so mutations now gate on Sec-Fetch-Site with Origin as fallback; and the state card's background shorthand reset its own background colour. A CSS-generated "now" badge became real markup so assistive technology announces it. Canary values planted in device labels, observation ids, source record ids, task titles, correction ids, and the private share label are asserted absent from every public response, every header, and the portal database file bytes including its write-ahead log. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three follow-ups from re-reading the slice: A snapshot that fails to decrypt rendered the same empty page as a link with nothing materialized yet, and said nothing to anyone. The visitor still sees the empty page, but a real read failure is now logged, because only the operator can act on it. The profile id is opaque; the link token is still never logged. The session cookie computed MaxAge from time.Now while its expiry came from the injected clock. The two agree in production, but a clock skewed into the past yields a negative MaxAge, which browsers read as "delete this cookie now". Replace a hand-rolled integer formatter with strconv.Itoa. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Delivers slice P5-a of
docs/portal-design.md: the read-only half of the public availability portal. Requests, messaging, and the live layer are P5-b..P5-d.Nothing here is exposed.
portal.enableddefaults to false; when it is false the daemon never opens the portal database, never builds a public handler, and never registers the owner's sharing routes — there is no/p/path to probe. Public exposure stays prohibited until the §12 exposure gate passes, including an independent review that has not happened.The boundary is structural, not a filter
The obvious implementation — a handler on the existing API that filters its output — fails the wrong way: a filtering bug is a health-data disclosure, and the private store handle is one dereference away.
Instead
internal/portalowns a separate database and does not importstore,readmodel,estimation,domain, orapi. A test parses the package's own imports and fails if that ever changes.internal/portalbridgeis the single inbound path, narrowing aPhaseEstimateto{version, windows[startAt,endAt,zoneId], generatedAt, horizonEnd, status}.Honesty budget comes from measured data
Confidence labels are withheld deliberately. ADR-0022 measured the buckets inverted on real history — the High bucket hit 0.61 against Medium's 0.81 — so publishing one would misinform someone making a scheduling decision. Every state instead carries the measured uncertainty in plain language, marks itself stale past six hours, and withholds any current-state claim past a day, because an out-of-date "awake now" is worse than no answer.
Two defects only a browser could find
Driving the page in a real browser caught what the Go suite structurally could not:
Origin-only CSRF gate refused every genuine login.Referrer-Policy: no-referrermakes browsers sendOrigin: nullon same-origin form posts, per the Fetch standard. Unit tests set the header themselves, so they never saw it. Relaxing the referrer policy would have put link tokens intoRefererheaders, so mutations now gate onSec-Fetch-Sitewith an exactOriginmatch as the pre-Fetch-Metadata fallback.backgroundshorthand reset its own background colour, letting the page canvas show through the gradient.A CSS-generated "now" badge also became real markup so assistive technology announces it.
Verification
Canary values planted in device labels, observation ids, source record ids, task titles, correction ids, and the private share label are asserted absent from every public response, every response header, and the portal database file bytes including its write-ahead log. Also covered: the recursive DTO allowlist; byte-identical responses for unknown/expired/revoked links; immediate revocation killing live sessions and deleting the snapshot; cross-link session isolation; a nine-case Fetch-Metadata matrix; passcode backoff with no global lockout; the read limit and its window rollover; IPv6 /64 grouping; audit-key rotation; the hourly retention sweep; and routes-absent-when-disabled at both the daemon mux and the owner API.
gofmt,go vet, Linux and Windows server builds, the full Go suite, contract fixtures,npm run check:web, and the separate-project boundary guard all pass locally.Known limits
🤖 Generated with Claude Code