From 4cf54a7da307173f04b8608b155cb3cbb992cc5b Mon Sep 17 00:00:00 2001 From: welli <17185653+wellivea1@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:11:33 -0400 Subject: [PATCH 1/2] Phase 5a: availability portal foundation (ADR-0029) 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 --- apps/server/go.mod | 3 +- apps/server/go.sum | 6 +- apps/server/internal/api/portal.go | 251 ++++++ apps/server/internal/api/portal_test.go | 189 +++++ apps/server/internal/api/server.go | 16 + apps/server/internal/config/config.go | 80 ++ apps/server/internal/config/portal_test.go | 151 ++++ apps/server/internal/daemon/portal_test.go | 50 ++ apps/server/internal/daemon/server.go | 86 +- apps/server/internal/daemon/server_test.go | 2 + .../internal/portal/assets/pages.gohtml | 73 ++ apps/server/internal/portal/assets/portal.css | 255 ++++++ apps/server/internal/portal/handlers.go | 275 +++++++ apps/server/internal/portal/http.go | 288 +++++++ apps/server/internal/portal/portal_test.go | 769 ++++++++++++++++++ apps/server/internal/portal/ratelimit.go | 345 ++++++++ apps/server/internal/portal/store.go | 754 +++++++++++++++++ apps/server/internal/portal/view.go | 210 +++++ apps/server/internal/portal/view_test.go | 211 +++++ .../server/internal/portalbridge/leak_test.go | 427 ++++++++++ .../internal/portalbridge/materialize.go | 164 ++++ apps/server/internal/store/portal_labels.go | 103 +++ apps/server/internal/store/store.go | 9 + .../0029-availability-portal-foundation.md | 171 ++++ docs/install-update-design.md | 6 +- docs/phase-goals.md | 21 +- docs/portal-design.md | 47 +- docs/privacy.md | 42 + docs/roadmap.md | 17 +- docs/self-hosting.md | 48 ++ docs/threat-model.md | 34 + docs/verification.md | 108 +++ go.work.sum | 7 +- 33 files changed, 5196 insertions(+), 22 deletions(-) create mode 100644 apps/server/internal/api/portal.go create mode 100644 apps/server/internal/api/portal_test.go create mode 100644 apps/server/internal/config/portal_test.go create mode 100644 apps/server/internal/daemon/portal_test.go create mode 100644 apps/server/internal/portal/assets/pages.gohtml create mode 100644 apps/server/internal/portal/assets/portal.css create mode 100644 apps/server/internal/portal/handlers.go create mode 100644 apps/server/internal/portal/http.go create mode 100644 apps/server/internal/portal/portal_test.go create mode 100644 apps/server/internal/portal/ratelimit.go create mode 100644 apps/server/internal/portal/store.go create mode 100644 apps/server/internal/portal/view.go create mode 100644 apps/server/internal/portal/view_test.go create mode 100644 apps/server/internal/portalbridge/leak_test.go create mode 100644 apps/server/internal/portalbridge/materialize.go create mode 100644 apps/server/internal/store/portal_labels.go create mode 100644 docs/decisions/0029-availability-portal-foundation.md diff --git a/apps/server/go.mod b/apps/server/go.mod index 589dede..dd8d8ea 100644 --- a/apps/server/go.mod +++ b/apps/server/go.mod @@ -4,6 +4,7 @@ go 1.26.0 require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 + golang.org/x/crypto v0.48.0 golang.org/x/sys v0.42.0 modernc.org/sqlite v1.52.0 non24.app/core v0.0.0 @@ -15,7 +16,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/text v0.34.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/apps/server/go.sum b/apps/server/go.sum index e15296f..ccbb021 100644 --- a/apps/server/go.sum +++ b/apps/server/go.sum @@ -16,6 +16,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= @@ -23,8 +25,8 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= diff --git a/apps/server/internal/api/portal.go b/apps/server/internal/api/portal.go new file mode 100644 index 0000000..a245d03 --- /dev/null +++ b/apps/server/internal/api/portal.go @@ -0,0 +1,251 @@ +package api + +import ( + "errors" + "log" + "net/http" + "strings" + "time" + + "non24.app/server/internal/portal" + "non24.app/server/internal/portalbridge" + syncmodel "non24.app/server/internal/sync" +) + +// portalAdmin is the owner-authenticated half of sharing. It is registered +// only when the portal is configured, so a daemon with the portal off has no +// sharing routes at all. +type portalAdmin struct { + store *portal.Store + origin string + materializer portalbridge.Materializer +} + +// WithPortal enables the share-link administration routes and wires +// materialization. The public /p/ mux is mounted separately by the daemon; +// this option governs only the owner surface. +func WithPortal(portalStore *portal.Store, publicOrigin string, materializer portalbridge.Materializer) Option { + return func(s *Server) { + s.portal = &portalAdmin{ + store: portalStore, + origin: strings.TrimSuffix(publicOrigin, "/"), + materializer: materializer, + } + } +} + +// sharingDisclosure is required by exposure-gate item 7. It states the +// measured uncertainty and the residual risk of live sharing in the same +// breath as the link, so an owner cannot create one without seeing both. +const sharingDisclosure = "Anyone with this link and passcode can see broad windows when you are likely awake, and can watch those windows change over time. " + + "The estimate is often off by about 2 hours and sometimes more. " + + "Screenshots and anything a recipient remembers cannot be revoked." + +type createPortalProfileRequest struct { + Label string `json:"label"` + Passcode string `json:"passcode"` + ExpiresInDays int `json:"expiresInDays"` + Grants portal.Grants `json:"grants"` +} + +type createPortalProfileResponse struct { + SchemaVersion string `json:"schema_version"` + ProfileID string `json:"profileId"` + LinkURL string `json:"linkUrl"` + ExpiresAt time.Time `json:"expiresAt"` + Disclosure string `json:"disclosure"` +} + +type portalProfileDTO struct { + ProfileID string `json:"profileId"` + Label string `json:"label"` + State string `json:"state"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt time.Time `json:"expiresAt"` + Grants portal.Grants `json:"grants"` + Access []portalAccessCountDTO `json:"access"` +} + +type portalAccessCountDTO struct { + Event string `json:"event"` + Count int `json:"count"` + LastAccess time.Time `json:"lastAccess"` +} + +type portalProfileListResponse struct { + SchemaVersion string `json:"schema_version"` + Profiles []portalProfileDTO `json:"profiles"` + Disclosure string `json:"disclosure"` +} + +func (s *Server) handleCreatePortalProfile(w http.ResponseWriter, r *http.Request) { + var req createPortalProfileRequest + if err := decodeBody(w, r, maxDeviceBodyBytes, &req); err != nil { + writeDecodeError(w, err) + return + } + if req.ExpiresInDays <= 0 { + writeError(w, http.StatusBadRequest, "expiresInDays must be positive") + return + } + now := s.now() + expiresAt := now.Add(time.Duration(req.ExpiresInDays) * 24 * time.Hour) + + profileID, err := portal.NewProfileID() + if err != nil { + writeError(w, http.StatusInternalServerError, "share profile creation failed") + return + } + token, err := portal.NewLinkToken() + if err != nil { + writeError(w, http.StatusInternalServerError, "share profile creation failed") + return + } + if err := s.portal.store.CreateProfile(r.Context(), portal.CreateProfileInput{ + ProfileID: profileID, + Token: token, + Passcode: req.Passcode, + Grants: req.Grants, + CreatedAt: now, + ExpiresAt: expiresAt, + }); err != nil { + // Validation failures here are the caller's fault (short passcode, + // over-long lifetime); they carry no secret and are safe to relay. + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := s.store.PutPortalLabel(r.Context(), profileID, req.Label, now.Format(time.RFC3339Nano)); err != nil { + writeError(w, http.StatusInternalServerError, "share profile creation failed") + return + } + // Materialize immediately so the recipient does not open a blank page. + if err := s.portal.materializer.MaterializeAll(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "share profile creation failed") + return + } + + writeJSON(w, http.StatusCreated, createPortalProfileResponse{ + SchemaVersion: syncmodel.SchemaVersion, + ProfileID: profileID, + LinkURL: s.portal.origin + "/p/" + token, + ExpiresAt: expiresAt, + Disclosure: sharingDisclosure, + }) +} + +func (s *Server) handleListPortalProfiles(w http.ResponseWriter, r *http.Request) { + now := s.now() + states, err := s.portal.store.ListProfiles(r.Context(), now) + if err != nil { + writeError(w, http.StatusInternalServerError, "share profile list failed") + return + } + labels, err := s.store.PortalLabels(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "share profile list failed") + return + } + since := now.Add(-30 * 24 * time.Hour) + + profiles := make([]portalProfileDTO, 0, len(states)) + for _, state := range states { + summaries, err := s.portal.store.SummarizeAccess(r.Context(), state.ID, since) + if err != nil { + writeError(w, http.StatusInternalServerError, "share profile list failed") + return + } + access := make([]portalAccessCountDTO, 0, len(summaries)) + for _, summary := range summaries { + access = append(access, portalAccessCountDTO{ + Event: string(summary.Event), + Count: summary.Count, + LastAccess: summary.LastAccess, + }) + } + profiles = append(profiles, portalProfileDTO{ + ProfileID: state.ID, + Label: labels[state.ID], + State: profileState(state), + CreatedAt: state.CreatedAt, + ExpiresAt: state.ExpiresAt, + Grants: state.Grants, + Access: access, + }) + } + writeJSON(w, http.StatusOK, portalProfileListResponse{ + SchemaVersion: syncmodel.SchemaVersion, + Profiles: profiles, + Disclosure: sharingDisclosure, + }) +} + +func profileState(state portal.ProfileState) string { + switch { + case state.Revoked: + return "revoked" + case state.Expired: + return "expired" + default: + return "active" + } +} + +func (s *Server) handleRevokePortalProfile(w http.ResponseWriter, r *http.Request) { + profileID := strings.TrimSpace(r.PathValue("id")) + if profileID == "" { + writeError(w, http.StatusBadRequest, "share profile id is required") + return + } + if err := s.portal.store.RevokeProfile(r.Context(), profileID, s.now()); err != nil { + if errors.Is(err, portal.ErrProfileNotFound) { + writeError(w, http.StatusNotFound, "share profile not found") + return + } + writeError(w, http.StatusInternalServerError, "share profile revoke failed") + return + } + writeJSON(w, http.StatusOK, map[string]string{ + "schema_version": syncmodel.SchemaVersion, + "status": "revoked", + }) +} + +// handleErasePortalProfile removes the owner-side label and the portal-side +// access audit for a link. Revocation stops access; this erases the record +// that the link existed, matching the ADR-0014/0017 erasure right. +func (s *Server) handleErasePortalProfile(w http.ResponseWriter, r *http.Request) { + profileID := strings.TrimSpace(r.PathValue("id")) + if profileID == "" { + writeError(w, http.StatusBadRequest, "share profile id is required") + return + } + if err := s.portal.store.RevokeProfile(r.Context(), profileID, s.now()); err != nil && !errors.Is(err, portal.ErrProfileNotFound) { + writeError(w, http.StatusInternalServerError, "share profile erase failed") + return + } + if err := s.portal.store.EraseAudit(r.Context(), profileID); err != nil { + writeError(w, http.StatusInternalServerError, "share profile erase failed") + return + } + if err := s.store.DeletePortalLabel(r.Context(), profileID); err != nil { + writeError(w, http.StatusInternalServerError, "share profile erase failed") + return + } + writeJSON(w, http.StatusOK, map[string]string{ + "schema_version": syncmodel.SchemaVersion, + "status": "erased", + }) +} + +// refreshPortal republishes availability after private data changed. Failures +// are reported to the caller's log path rather than failing the sync request: +// a stale portal snapshot is a visible, honest state, but a rejected push +// would lose the device's data. +func (s *Server) refreshPortal(r *http.Request) { + if s.portal == nil { + return + } + if err := s.portal.materializer.MaterializeAll(r.Context()); err != nil { + log.Printf("portal: snapshot refresh failed: %v", err) + } +} diff --git a/apps/server/internal/api/portal_test.go b/apps/server/internal/api/portal_test.go new file mode 100644 index 0000000..f8d09c0 --- /dev/null +++ b/apps/server/internal/api/portal_test.go @@ -0,0 +1,189 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "path/filepath" + "strings" + "testing" + "time" + + "non24.app/server/internal/portal" + "non24.app/server/internal/portalbridge" + "non24.app/server/internal/readmodel" +) + +var portalTestNow = time.Date(2026, 3, 5, 12, 0, 0, 0, time.UTC) + +// TestSharingRoutesAbsentWithoutPortal keeps the owner surface off too. A +// daemon with the portal disabled exposes no sharing endpoints at all. +func TestSharingRoutesAbsentWithoutPortal(t *testing.T) { + h := newTestHarness(t) + token := h.registerDevice(t, "desktop") + + routes := []struct { + method string + path string + }{ + {http.MethodGet, "/v1/portal/profiles"}, + {http.MethodPost, "/v1/portal/profiles"}, + {http.MethodPost, "/v1/portal/profiles/abc/revoke"}, + {http.MethodPost, "/v1/portal/profiles/abc/erase"}, + } + for _, route := range routes { + status, body := h.request(t, route.method, route.path, token, "{}") + if status != http.StatusNotFound { + t.Errorf("%s %s status = %d body = %s, want 404 when the portal is disabled", + route.method, route.path, status, body) + } + } +} + +func newPortalHarness(t *testing.T) (*testHarness, *portal.Store) { + t.Helper() + portalStore, err := portal.Open(filepath.Join(t.TempDir(), "portal.db"), bytes.Repeat([]byte{7}, 32)) + if err != nil { + t.Fatalf("open portal store: %v", err) + } + t.Cleanup(func() { _ = portalStore.Close() }) + + harness := newTestHarness(t, func(s *Server) { + WithPortal(portalStore, "https://share.example.test", portalbridge.Materializer{ + Sleep: readmodel.SleepReader{Store: s.store}, + Profiles: portalStore, + Sink: portalStore, + Now: func() time.Time { return portalTestNow }, + })(s) + }) + return harness, portalStore +} + +func TestSharingLifecycle(t *testing.T) { + h, portalStore := newPortalHarness(t) + token := h.registerDevice(t, "desktop") + + status, data := h.request(t, http.MethodPost, "/v1/portal/profiles", token, + `{"label":"Mum","passcode":"long-enough-passcode","expiresInDays":30,"grants":{"wakingWindows":true,"allowRequests":false,"allowMessages":false}}`) + if status != http.StatusCreated { + t.Fatalf("create status = %d body = %s", status, data) + } + var created createPortalProfileResponse + if err := json.Unmarshal(data, &created); err != nil { + t.Fatalf("decode create: %v", err) + } + + if !strings.HasPrefix(created.LinkURL, "https://share.example.test/p/") { + t.Errorf("link url = %q, want the configured public origin", created.LinkURL) + } + for _, phrase := range []string{"2 hours", "cannot be revoked"} { + if !strings.Contains(created.Disclosure, phrase) { + t.Errorf("disclosure does not mention %q: %q", phrase, created.Disclosure) + } + } + + linkToken := strings.TrimPrefix(created.LinkURL, "https://share.example.test/p/") + if _, err := portalStore.ResolveLink(t.Context(), linkToken, portalTestNow); err != nil { + t.Fatalf("issued link does not resolve: %v", err) + } + + status, data = h.request(t, http.MethodGet, "/v1/portal/profiles", token, "") + if status != http.StatusOK { + t.Fatalf("list status = %d body = %s", status, data) + } + var list portalProfileListResponse + if err := json.Unmarshal(data, &list); err != nil { + t.Fatalf("decode list: %v", err) + } + if len(list.Profiles) != 1 { + t.Fatalf("profile count = %d, want 1", len(list.Profiles)) + } + if list.Profiles[0].Label != "Mum" { + t.Errorf("label = %q, want Mum", list.Profiles[0].Label) + } + if list.Profiles[0].State != "active" { + t.Errorf("state = %q, want active", list.Profiles[0].State) + } + + status, data = h.request(t, http.MethodPost, "/v1/portal/profiles/"+created.ProfileID+"/revoke", token, "{}") + if status != http.StatusOK { + t.Fatalf("revoke status = %d body = %s", status, data) + } + if _, err := portalStore.ResolveLink(t.Context(), linkToken, portalTestNow); err == nil { + t.Error("link still resolves after revocation") + } + + status, data = h.request(t, http.MethodPost, "/v1/portal/profiles/"+created.ProfileID+"/erase", token, "{}") + if status != http.StatusOK { + t.Fatalf("erase status = %d body = %s", status, data) + } + label, err := h.st.PortalLabel(t.Context(), created.ProfileID) + if err != nil { + t.Fatalf("read label: %v", err) + } + if label != "" { + t.Errorf("private label survived erasure: %q", label) + } +} + +// TestSharingLabelStaysOutOfPortalStore is the split-store invariant at the +// owner boundary: the recipient's name is the owner's data, not the portal's. +func TestSharingLabelStaysOutOfPortalStore(t *testing.T) { + h, portalStore := newPortalHarness(t) + token := h.registerDevice(t, "desktop") + + status, data := h.request(t, http.MethodPost, "/v1/portal/profiles", token, + `{"label":"Dr Alvarez neurology","passcode":"long-enough-passcode","expiresInDays":7,"grants":{"wakingWindows":true,"allowRequests":false,"allowMessages":false}}`) + if status != http.StatusCreated { + t.Fatalf("create status = %d body = %s", status, data) + } + var created createPortalProfileResponse + if err := json.Unmarshal(data, &created); err != nil { + t.Fatal(err) + } + + profiles, err := portalStore.ListProfiles(t.Context(), portalTestNow) + if err != nil { + t.Fatalf("list portal profiles: %v", err) + } + encoded, err := json.Marshal(profiles) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(encoded, []byte("Alvarez")) { + t.Error("the private share label reached the portal store") + } + if created.ProfileID == "" { + t.Error("no profile id was returned") + } +} + +func TestSharingRejectsWeakPasscode(t *testing.T) { + h, _ := newPortalHarness(t) + token := h.registerDevice(t, "desktop") + + status, _ := h.request(t, http.MethodPost, "/v1/portal/profiles", token, + `{"label":"Short","passcode":"abc","expiresInDays":30,"grants":{"wakingWindows":true,"allowRequests":false,"allowMessages":false}}`) + if status != http.StatusBadRequest { + t.Errorf("status = %d, want 400 for a short passcode", status) + } +} + +func TestSharingRejectsOverlongLifetime(t *testing.T) { + h, _ := newPortalHarness(t) + token := h.registerDevice(t, "desktop") + + status, _ := h.request(t, http.MethodPost, "/v1/portal/profiles", token, + `{"label":"Forever","passcode":"long-enough-passcode","expiresInDays":400,"grants":{"wakingWindows":true,"allowRequests":false,"allowMessages":false}}`) + if status != http.StatusBadRequest { + t.Errorf("status = %d, want 400 for a lifetime past the 90-day cap", status) + } +} + +func TestSharingRequiresDeviceAuth(t *testing.T) { + h, _ := newPortalHarness(t) + status, _ := h.request(t, http.MethodGet, "/v1/portal/profiles", "", "") + if status != http.StatusUnauthorized { + t.Errorf("status = %d, want 401 without a device token", status) + } +} diff --git a/apps/server/internal/api/server.go b/apps/server/internal/api/server.go index 50e793d..58730e7 100644 --- a/apps/server/internal/api/server.go +++ b/apps/server/internal/api/server.go @@ -32,6 +32,7 @@ type Server struct { store *store.Store enrollmentSecret string assistant *assistant.Service + portal *portalAdmin now func() time.Time } @@ -75,6 +76,15 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /v1/sync/push", s.requireDevice(http.HandlerFunc(s.handlePush))) mux.Handle("GET /v1/sync/pull", s.requireDevice(http.HandlerFunc(s.handlePull))) mux.Handle("POST /v1/sync/erase", s.requireDevice(http.HandlerFunc(s.handleErase))) + if s.portal != nil { + // Registered only when the portal is configured. With the portal off + // there is no sharing route to probe, on the owner surface or the + // public one. + mux.Handle("GET /v1/portal/profiles", s.requireDevice(http.HandlerFunc(s.handleListPortalProfiles))) + mux.Handle("POST /v1/portal/profiles", s.requireDevice(http.HandlerFunc(s.handleCreatePortalProfile))) + mux.Handle("POST /v1/portal/profiles/{id}/revoke", s.requireDevice(http.HandlerFunc(s.handleRevokePortalProfile))) + mux.Handle("POST /v1/portal/profiles/{id}/erase", s.requireDevice(http.HandlerFunc(s.handleErasePortalProfile))) + } return mux } @@ -435,6 +445,9 @@ func (s *Server) handlePush(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "sync push failed") return } + // An accepted push can change sleep inputs, so the shared projection is + // republished before the response returns (design section 5). + s.refreshPortal(r) writeJSON(w, http.StatusOK, syncmodel.PushResponse{ SchemaVersion: syncmodel.SchemaVersion, Cursor: cursor, @@ -461,6 +474,9 @@ func (s *Server) handleErase(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "sync erase failed") return } + // Erasure must reach the shared projection too: a window derived from a + // record the user just deleted may not keep being published. + s.refreshPortal(r) writeJSON(w, http.StatusOK, syncmodel.EraseResponse{ SchemaVersion: syncmodel.SchemaVersion, Erased: erased, diff --git a/apps/server/internal/config/config.go b/apps/server/internal/config/config.go index 250b164..e83bcce 100644 --- a/apps/server/internal/config/config.go +++ b/apps/server/internal/config/config.go @@ -16,8 +16,10 @@ import ( "io" "math/big" "net" + "net/url" "os" "path/filepath" + "strconv" "strings" "time" ) @@ -39,6 +41,8 @@ const ( EnvLLMAPIKey = "ZEITBOARD_LLM_API_KEY" EnvLLMAPIKeyFile = "ZEITBOARD_LLM_API_KEY_FILE" EnvLLMEndpoint = "ZEITBOARD_LLM_ENDPOINT" + EnvPortalEnabled = "ZEITBOARD_PORTAL_ENABLED" + EnvPortalOrigin = "ZEITBOARD_PORTAL_ORIGIN" ) type Config struct { @@ -51,9 +55,23 @@ type Config struct { DataKey []byte `json:"-"` EnrollmentSecret string `json:"-"` Assistant AssistantConfig `json:"assistant"` + Portal PortalConfig `json:"portal"` UsesSelfSignedDevCert bool `json:"-"` } +// PortalConfig gates the public availability portal. Enabled defaults to false +// and must stay that way until the exposure gate in docs/portal-design.md +// section 12 passes: when it is false the daemon never constructs a portal +// handler, so no /p/ route exists to be probed. +type PortalConfig struct { + Enabled bool `json:"enabled"` + + // PublicOrigin is the exact scheme://host[:port] visitors reach. It is + // required when the portal is enabled because mutating requests are + // accepted only with an exactly matching Origin header. + PublicOrigin string `json:"publicOrigin"` +} + type AssistantConfig struct { Provider string `json:"provider"` Model string `json:"model"` @@ -92,6 +110,10 @@ func Load(path string) (Config, error) { overrideString(&cfg.Assistant.Model, EnvLLMModel) overrideString(&cfg.Assistant.APIKeyFile, EnvLLMAPIKeyFile) overrideString(&cfg.Assistant.Endpoint, EnvLLMEndpoint) + overrideString(&cfg.Portal.PublicOrigin, EnvPortalOrigin) + if err := overrideBool(&cfg.Portal.Enabled, EnvPortalEnabled); err != nil { + return Config{}, err + } dataKey, err := loadDataKey(cfg.DataKeyFile) if err != nil { @@ -131,9 +153,67 @@ func Load(path string) (Config, error) { } cfg.UsesSelfSignedDevCert = true } + if err := validatePortal(&cfg.Portal); err != nil { + return Config{}, err + } return cfg, nil } +// validatePortal refuses a half-configured public surface. An enabled portal +// without an exact origin could not enforce its CSRF check, so it must fail to +// start rather than serve with the check disabled. +func validatePortal(portal *PortalConfig) error { + portal.PublicOrigin = strings.TrimSuffix(strings.TrimSpace(portal.PublicOrigin), "/") + if !portal.Enabled { + return nil + } + if portal.PublicOrigin == "" { + return errors.New("portal publicOrigin is required when the portal is enabled") + } + parsed, err := url.Parse(portal.PublicOrigin) + if err != nil || parsed.Host == "" { + return errors.New("portal publicOrigin must be an absolute scheme://host origin") + } + if parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.User != nil { + return errors.New("portal publicOrigin must contain only a scheme and host") + } + switch parsed.Scheme { + case "https": + case "http": + // Plain HTTP is tolerated only for loopback development, because the + // session cookie carries the __Host- prefix and Secure attribute, + // which browsers accept over http only for trustworthy origins. + if !isLocalHostname(parsed.Hostname()) { + return errors.New("portal publicOrigin must use https outside loopback") + } + default: + return errors.New("portal publicOrigin must use http or https") + } + return nil +} + +func isLocalHostname(host string) bool { + switch strings.ToLower(host) { + case "localhost", "127.0.0.1", "::1": + return true + default: + return false + } +} + +func overrideBool(target *bool, envName string) error { + raw := strings.TrimSpace(os.Getenv(envName)) + if raw == "" { + return nil + } + value, err := strconv.ParseBool(raw) + if err != nil { + return fmt.Errorf("%s must be a boolean", envName) + } + *target = value + return nil +} + func (c Config) TLSConfig() (*tls.Config, error) { tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12} if c.TLSCertPath != "" { diff --git a/apps/server/internal/config/portal_test.go b/apps/server/internal/config/portal_test.go new file mode 100644 index 0000000..a1c2526 --- /dev/null +++ b/apps/server/internal/config/portal_test.go @@ -0,0 +1,151 @@ +package config + +import ( + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func writePortalConfig(t *testing.T, portal map[string]any) string { + t.Helper() + dir := t.TempDir() + keyPath := filepath.Join(dir, "data-key.txt") + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + if err := os.WriteFile(keyPath, []byte(key), 0o600); err != nil { + t.Fatal(err) + } + secretPath := filepath.Join(dir, "enrollment-secret.txt") + if err := os.WriteFile(secretPath, []byte("enrollment-secret-123"), 0o600); err != nil { + t.Fatal(err) + } + payload := map[string]any{ + "listenAddress": "127.0.0.1:8765", + "dataDir": filepath.Join(dir, "data"), + "dataKeyFile": keyPath, + "enrollmentSecretFile": secretPath, + } + if portal != nil { + payload["portal"] = portal + } + raw, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func clearPortalEnv(t *testing.T) { + t.Helper() + t.Setenv(EnvPortalEnabled, "") + t.Setenv(EnvPortalOrigin, "") +} + +// TestPortalIsDisabledByDefault is exposure-gate item 1. A config that says +// nothing about the portal must not serve one. +func TestPortalIsDisabledByDefault(t *testing.T) { + clearPortalEnv(t) + cfg, err := Load(writePortalConfig(t, nil)) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.Portal.Enabled { + t.Error("portal is enabled by a config that never mentions it") + } + if cfg.Portal.PublicOrigin != "" { + t.Errorf("portal origin defaulted to %q", cfg.Portal.PublicOrigin) + } +} + +// TestPortalRequiresOriginWhenEnabled refuses a half-configured public +// surface: without an exact origin the CSRF check could not be enforced, so +// the daemon must fail to start rather than serve without it. +func TestPortalRequiresOriginWhenEnabled(t *testing.T) { + clearPortalEnv(t) + _, err := Load(writePortalConfig(t, map[string]any{"enabled": true})) + if err == nil { + t.Fatal("an enabled portal without a public origin was accepted") + } + if !strings.Contains(err.Error(), "publicOrigin") { + t.Errorf("error does not name the missing field: %v", err) + } +} + +func TestPortalOriginRules(t *testing.T) { + clearPortalEnv(t) + cases := map[string]struct { + origin string + wantErr bool + }{ + "https": {"https://share.example.com", false}, + "https with port": {"https://share.example.com:8443", false}, + "loopback http": {"http://localhost:8765", false}, + "remote http": {"http://share.example.com", true}, + "with path": {"https://share.example.com/portal", true}, + "with query": {"https://share.example.com?a=b", true}, + "with credentials": {"https://user:pass@share.example.com", true}, + "unsupported scheme": {"ftp://share.example.com", true}, + "bare host": {"share.example.com", true}, + } + for name, testCase := range cases { + cfg, err := Load(writePortalConfig(t, map[string]any{ + "enabled": true, + "publicOrigin": testCase.origin, + })) + if testCase.wantErr { + if err == nil { + t.Errorf("%s: origin %q was accepted", name, testCase.origin) + } + continue + } + if err != nil { + t.Errorf("%s: origin %q was rejected: %v", name, testCase.origin, err) + continue + } + if cfg.Portal.PublicOrigin != testCase.origin { + t.Errorf("%s: origin = %q, want %q", name, cfg.Portal.PublicOrigin, testCase.origin) + } + } +} + +// TestPortalOriginTrailingSlashIsNormalized keeps the Origin comparison exact: +// browsers never send a trailing slash, so a configured one must not silently +// break every mutation. +func TestPortalOriginTrailingSlashIsNormalized(t *testing.T) { + clearPortalEnv(t) + cfg, err := Load(writePortalConfig(t, map[string]any{ + "enabled": true, + "publicOrigin": "https://share.example.com/", + })) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.Portal.PublicOrigin != "https://share.example.com" { + t.Errorf("origin = %q, want the trailing slash removed", cfg.Portal.PublicOrigin) + } +} + +func TestPortalEnvOverrides(t *testing.T) { + clearPortalEnv(t) + path := writePortalConfig(t, nil) + t.Setenv(EnvPortalEnabled, "true") + t.Setenv(EnvPortalOrigin, "https://env.example.com") + cfg, err := Load(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if !cfg.Portal.Enabled || cfg.Portal.PublicOrigin != "https://env.example.com" { + t.Errorf("env overrides not applied: %+v", cfg.Portal) + } + + t.Setenv(EnvPortalEnabled, "yes-please") + if _, err := Load(path); err == nil { + t.Error("a non-boolean portal toggle was accepted") + } +} diff --git a/apps/server/internal/daemon/portal_test.go b/apps/server/internal/daemon/portal_test.go new file mode 100644 index 0000000..218163c --- /dev/null +++ b/apps/server/internal/daemon/portal_test.go @@ -0,0 +1,50 @@ +package daemon + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestPortalRoutesAreAbsentWhenDisabled is exposure-gate item 1 at the routing +// layer: with the portal off there must be no /p/ path to probe at all, not +// merely one that refuses. +func TestPortalRoutesAreAbsentWhenDisabled(t *testing.T) { + private := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTeapot) + }) + handler := mountPortal(private, nil) + + for _, path := range []string{"/p/", "/p/some-token", "/p/some-token/availability", "/p/assets/portal.css"} { + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) + // The private handler answers everything, which is exactly the point: + // no portal mux was mounted, so /p/ is not special. + if recorder.Code != http.StatusTeapot { + t.Errorf("%s returned %d; a portal route appears to be mounted while disabled", path, recorder.Code) + } + } +} + +func TestPortalRoutesAreMountedWhenEnabled(t *testing.T) { + private := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTeapot) + }) + public := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusGone) + }) + handler := mountPortal(private, public) + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/p/token", nil)) + if recorder.Code != http.StatusGone { + t.Errorf("/p/token returned %d, want the public handler's 410", recorder.Code) + } + + // The device API must keep working alongside it. + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/status", nil)) + if recorder.Code != http.StatusTeapot { + t.Errorf("/v1/status returned %d, want the private handler", recorder.Code) + } +} diff --git a/apps/server/internal/daemon/server.go b/apps/server/internal/daemon/server.go index f5891d5..2fd871c 100644 --- a/apps/server/internal/daemon/server.go +++ b/apps/server/internal/daemon/server.go @@ -13,7 +13,10 @@ import ( "non24.app/server/internal/api" "non24.app/server/internal/config" + "non24.app/server/internal/portal" + "non24.app/server/internal/portalbridge" "non24.app/server/internal/provider" + "non24.app/server/internal/readmodel" "non24.app/server/internal/store" ) @@ -59,7 +62,49 @@ func serve(configPath string, stop <-chan struct{}, ready chan<- struct{}) error return fmt.Errorf("open store: %w", err) } defer st.Close() - handler := api.New(st, cfg.EnrollmentSecret, api.WithProvider(llm, providerStatus)).Handler() + + options := []api.Option{api.WithProvider(llm, providerStatus)} + var publicHandler http.Handler + if cfg.Portal.Enabled { + portalStore, portalErr := portal.Open(filepath.Join(cfg.DataDir, "zeitboard-portal.db"), cfg.DataKey) + if portalErr != nil { + return fmt.Errorf("open portal store: %w", portalErr) + } + defer portalStore.Close() + + materializer := portalbridge.Materializer{ + Sleep: readmodel.SleepReader{Store: st}, + Profiles: portalStore, + Sink: portalStore, + } + portalHandler, handlerErr := portal.NewHandler(portal.HandlerConfig{ + Store: portalStore, + PublicOrigin: cfg.Portal.PublicOrigin, + }) + if handlerErr != nil { + return fmt.Errorf("configure portal: %w", handlerErr) + } + // Refresh once at startup so a link opened before the first sync of + // the day still shows current freshness rather than yesterday's. + if err := materializer.MaterializeAll(context.Background()); err != nil { + log.Printf("portal: initial snapshot refresh failed: %v", err) + } + // A dedicated stop channel rather than the caller's: `stop` may be + // nil, and this defer must run before the store closes (LIFO) so the + // worker never touches a closed database. + maintenanceStop := make(chan struct{}) + maintenanceDone := startPortalMaintenance(portalStore, maintenanceStop) + defer func() { + close(maintenanceStop) + <-maintenanceDone + }() + + options = append(options, api.WithPortal(portalStore, cfg.Portal.PublicOrigin, materializer)) + publicHandler = portalHandler.Routes() + log.Printf("zeitboardd portal enabled at %s/p/", cfg.Portal.PublicOrigin) + } + + handler := mountPortal(api.New(st, cfg.EnrollmentSecret, options...).Handler(), publicHandler) server := &http.Server{ Addr: cfg.ListenAddress, Handler: handler, @@ -109,6 +154,45 @@ func serve(configPath string, stop <-chan struct{}, ready chan<- struct{}) error } } +const portalMaintenanceInterval = time.Hour + +// startPortalMaintenance expires sessions and rate buckets and enforces the +// access-audit retention window. Retention that nothing enforces is not +// retention, so this runs on a timer rather than being left to an operator. +// The returned channel closes once the worker has stopped. +func startPortalMaintenance(store *portal.Store, stop <-chan struct{}) <-chan struct{} { + done := make(chan struct{}) + go func() { + defer close(done) + ticker := time.NewTicker(portalMaintenanceInterval) + defer ticker.Stop() + for { + if err := store.PurgeExpired(context.Background(), time.Now().UTC()); err != nil { + log.Printf("portal: maintenance sweep failed: %v", err) + } + select { + case <-ticker.C: + case <-stop: + return + } + } + }() + return done +} + +// mountPortal routes /p/ to the public handler and everything else to the +// device API. When the portal is disabled publicHandler is nil and the private +// handler is returned unchanged, so no /p/ path exists at all. +func mountPortal(private http.Handler, publicHandler http.Handler) http.Handler { + if publicHandler == nil { + return private + } + mux := http.NewServeMux() + mux.Handle("/", private) + mux.Handle("/p/", publicHandler) + return mux +} + func normalizeServeError(err error) error { if err == nil || errors.Is(err, http.ErrServerClosed) { return nil diff --git a/apps/server/internal/daemon/server_test.go b/apps/server/internal/daemon/server_test.go index 3caff66..b400d3f 100644 --- a/apps/server/internal/daemon/server_test.go +++ b/apps/server/internal/daemon/server_test.go @@ -70,6 +70,8 @@ func writeDaemonTestConfig(t *testing.T) string { config.EnvLLMAPIKey, config.EnvLLMAPIKeyFile, config.EnvLLMEndpoint, + config.EnvPortalEnabled, + config.EnvPortalOrigin, } { t.Setenv(name, "") } diff --git a/apps/server/internal/portal/assets/pages.gohtml b/apps/server/internal/portal/assets/pages.gohtml new file mode 100644 index 0000000..cfc92eb --- /dev/null +++ b/apps/server/internal/portal/assets/pages.gohtml @@ -0,0 +1,73 @@ +{{define "head"}} + + + + + + +{{if .Refresh}}{{end}} +{{.Title}} + + + +
+{{end}} + +{{define "foot"}}
+ + +{{end}} + +{{define "dashboard"}}{{template "head" .}} +{{with .View}} +
+

{{.Headline}}

+

{{.Detail}}

+

{{.Freshness}}

+
+ +{{if .Windows}} +
+

Likely waking windows

+ +

Times shown in {{.ZoneLabel}}.

+
+{{end}} + +
+

How accurate is this? {{.Qualifier}}

+

{{.Notice}}

+
+{{end}} +{{template "foot" .}}{{end}} + +{{define "passcode"}}{{template "head" .}} +
+

This availability link needs a passcode

+

Enter the passcode you were given along with this link.

+ {{if .Error}}

{{.Error}}

{{end}} +
+ + + +
+
+ +
+

{{.View.Notice}}

+
+{{template "foot" .}}{{end}} + +{{define "generic"}}{{template "head" .}} +
+

{{.Title}}

+

{{.Error}}

+
+{{template "foot" .}}{{end}} diff --git a/apps/server/internal/portal/assets/portal.css b/apps/server/internal/portal/assets/portal.css new file mode 100644 index 0000000..5be1a96 --- /dev/null +++ b/apps/server/internal/portal/assets/portal.css @@ -0,0 +1,255 @@ +/* Availability portal stylesheet. + Served from the portal's own origin so the page needs no inline style and + the CSP can stay at style-src 'self'. No fonts, images, or other assets are + fetched from anywhere. */ + +:root { + color-scheme: light dark; + + --ink: #24302d; + --muted: #5b655f; + --line: #dedfd8; + --paper: #fffefa; + --canvas: #f5f3ee; + --divider: #ecece7; + + --awake: #55766b; + --awake-glow: #e4ece7; + --not-awake: #4a5e7a; + --not-awake-glow: #e3e8ef; + --uncertain: #9a6b16; + --uncertain-glow: #f5ecda; + + --button-text: #fff; + --button-bg: #3e5f55; + --button-hover: #304e45; + --focus: #3e5f55; +} + +@media (prefers-color-scheme: dark) { + :root { + --ink: #e6e8e4; + --muted: #a3aca6; + --line: #333a37; + --paper: #1c211f; + --canvas: #141817; + --divider: #2a302d; + + --awake: #8fb3a5; + --awake-glow: #21302b; + --not-awake: #91a8c4; + --not-awake-glow: #1f2833; + --uncertain: #d9ac5c; + --uncertain-glow: #322818; + + --button-text: #14201c; + --button-bg: #8fb3a5; + --button-hover: #a6c6b9; + --focus: #8fb3a5; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 2rem 1.25rem 3rem; + background: var(--canvas); + color: var(--ink); + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; + line-height: 1.5; +} + +main { + max-width: 34rem; + margin: 0 auto; +} + +.card { + background: var(--paper); + border: 1px solid var(--line); + border-radius: 14px; + padding: 1.5rem; +} + +.card + .card { + margin-top: 1rem; +} + +.state { + border-left: 4px solid var(--muted); + padding-left: 1rem; +} + +/* These set background-image rather than the background shorthand: the + shorthand would reset the card's paper background-color to transparent and + let the page canvas show through the fade. */ +.state.is-awake { + border-left-color: var(--awake); + background-image: linear-gradient(90deg, var(--awake-glow), transparent 60%); +} + +.state.is-not-awake { + border-left-color: var(--not-awake); + background-image: linear-gradient(90deg, var(--not-awake-glow), transparent 60%); +} + +.state.is-unavailable, +.state.is-stale { + border-left-color: var(--uncertain); + background-image: linear-gradient(90deg, var(--uncertain-glow), transparent 60%); +} + +h1 { + margin: 0 0 0.35rem; + font-size: 1.45rem; + font-weight: 600; + letter-spacing: -0.01em; +} + +h2 { + margin: 0 0 0.75rem; + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--muted); +} + +p { + margin: 0 0 0.75rem; +} + +p:last-child { + margin-bottom: 0; +} + +.detail { + color: var(--muted); +} + +.freshness { + margin-top: 0.75rem; + font-size: 0.85rem; + color: var(--muted); +} + +.freshness.is-stale { + color: var(--uncertain); + font-weight: 600; +} + +.windows { + list-style: none; + margin: 0; + padding: 0; +} + +.windows li { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 0.75rem; + justify-content: space-between; + padding: 0.6rem 0; + border-bottom: 1px solid var(--divider); +} + +.windows li:last-child { + border-bottom: 0; +} + +.windows .day { + font-weight: 600; +} + +.windows .range { + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +/* The badge is real markup rather than generated content so assistive + technology announces it along with the day. */ +.windows .badge { + margin-left: 0.35rem; + padding: 0.05rem 0.4rem; + border-radius: 999px; + background: var(--awake-glow); + color: var(--awake); + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + vertical-align: 0.1em; +} + +.zone { + margin-top: 0.75rem; + font-size: 0.8rem; + color: var(--muted); +} + +.fineprint { + font-size: 0.85rem; + color: var(--muted); +} + +.fineprint strong { + color: var(--ink); + font-weight: 600; +} + +label { + display: block; + margin-bottom: 0.35rem; + font-weight: 600; +} + +input[type="password"] { + width: 100%; + padding: 0.6rem 0.7rem; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--canvas); + color: var(--ink); + font: inherit; +} + +button { + margin-top: 0.9rem; + padding: 0.6rem 1.1rem; + border: 0; + border-radius: 8px; + background: var(--button-bg); + color: var(--button-text); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +button:hover { + background: var(--button-hover); +} + +:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +.error { + margin-bottom: 0.9rem; + padding: 0.6rem 0.8rem; + border-radius: 8px; + background: var(--uncertain-glow); + color: var(--uncertain); + font-weight: 600; +} + +@media (prefers-reduced-motion: no-preference) { + .card { + transition: border-color 120ms ease; + } +} diff --git a/apps/server/internal/portal/handlers.go b/apps/server/internal/portal/handlers.go new file mode 100644 index 0000000..472c63a --- /dev/null +++ b/apps/server/internal/portal/handlers.go @@ -0,0 +1,275 @@ +package portal + +import ( + "bytes" + "embed" + "encoding/json" + "html/template" + "net/http" + "net/url" + "strings" + "time" +) + +//go:embed assets/portal.css assets/pages.gohtml +var assetsFS embed.FS + +var pageTemplates = template.Must(template.ParseFS(assetsFS, "assets/pages.gohtml")) + +// pageData is the only value templates ever see. It has no field that could +// carry a private record, which is what makes the canary test in +// portal_leak_test.go a structural guarantee rather than a spot check. +type pageData struct { + Title string + Refresh bool + Error string + FormAction string + View AvailabilityView +} + +func (h *Handler) handleStylesheet(w http.ResponseWriter, r *http.Request) { + data, err := assetsFS.ReadFile("assets/portal.css") + if err != nil { + h.writeGeneric(w, r, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/css; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} + +func (h *Handler) handlePage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + profile := profileFromContext(ctx) + if _, ok := h.sessionFor(r, profile); !ok { + h.renderPasscode(w, r, "") + return + } + _ = h.store.RecordAccess(ctx, profile.ID, EventPageView, sourceFromContext(ctx), h.now()) + + snapshot, err := h.store.ReadSnapshot(ctx, profile.ID) + if err != nil { + // A missing snapshot is a legitimate state, not an error: the link + // exists but nothing has been materialized for it yet. + snapshot = Snapshot{} + } + view := BuildView(snapshot, h.now()) + h.renderPage(w, r, http.StatusOK, "dashboard", pageData{ + Title: "Availability", + Refresh: true, + View: view, + }) +} + +func (h *Handler) renderPasscode(w http.ResponseWriter, r *http.Request, message string) { + h.renderPage(w, r, http.StatusOK, "passcode", pageData{ + Title: "Passcode required", + FormAction: sessionPath(r.PathValue("linkToken")), + Error: message, + View: AvailabilityView{Notice: NoticeNotMedical}, + }) +} + +func (h *Handler) handleSession(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + profile := profileFromContext(ctx) + sourceID := sourceFromContext(ctx) + bucket := "passcode:" + profile.ID + ":" + sourceID + + if err := r.ParseForm(); err != nil { + h.writeGeneric(w, r, http.StatusBadRequest) + return + } + delay, err := h.store.PasscodeDelay(ctx, bucket, h.now()) + if err != nil { + h.writeGeneric(w, r, http.StatusServiceUnavailable) + return + } + if delay > 0 { + _ = h.store.RecordAccess(ctx, profile.ID, EventThrottled, sourceID, h.now()) + w.Header().Set("Retry-After", retryAfterSeconds(delay)) + h.renderPage(w, r, http.StatusTooManyRequests, "passcode", pageData{ + Title: "Passcode required", + FormAction: sessionPath(r.PathValue("linkToken")), + Error: "Too many attempts. Wait a moment and try again.", + View: AvailabilityView{Notice: NoticeNotMedical}, + }) + return + } + + passcode := r.PostFormValue("passcode") + if err := h.store.VerifyPasscode(ctx, profile.ID, passcode); err != nil { + if _, noteErr := h.store.NotePasscodeFailure(ctx, bucket, h.now()); noteErr != nil { + h.writeGeneric(w, r, http.StatusServiceUnavailable) + return + } + _ = h.store.RecordAccess(ctx, profile.ID, EventPasscodeRejected, sourceID, h.now()) + h.renderPage(w, r, http.StatusUnauthorized, "passcode", pageData{ + Title: "Passcode required", + FormAction: sessionPath(r.PathValue("linkToken")), + Error: "That passcode did not match.", + View: AvailabilityView{Notice: NoticeNotMedical}, + }) + return + } + + if err := h.store.ClearPasscodeFailures(ctx, bucket); err != nil { + h.writeGeneric(w, r, http.StatusServiceUnavailable) + return + } + // Rotate: any session presented alongside this login is discarded rather + // than reused, so a fixated cookie value cannot survive authentication. + if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" { + _ = h.store.DeleteSession(ctx, cookie.Value) + } + token, err := h.store.CreateSession(ctx, profile, h.now()) + if err != nil { + h.writeGeneric(w, r, http.StatusServiceUnavailable) + return + } + _ = h.store.RecordAccess(ctx, profile.ID, EventPasscodeAccepted, sourceID, h.now()) + + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: token.Session, + Path: "/", + Expires: token.ExpiresAt, + MaxAge: int(time.Until(token.ExpiresAt).Seconds()), + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) + http.Redirect(w, r, pagePath(r.PathValue("linkToken")), http.StatusSeeOther) +} + +// availabilityDTO is the exact public allowlist from docs/portal-design.md +// section 5. Adding a field widens the public boundary and must go through +// that review; TestAvailabilityDTOAllowlist fails otherwise. +type availabilityDTO struct { + Version int64 `json:"version"` + Windows []windowDTO `json:"windows"` + GeneratedAt string `json:"generatedAt"` + HorizonEnd string `json:"horizonEnd"` + Status string `json:"status"` +} + +type windowDTO struct { + StartAt string `json:"startAt"` + EndAt string `json:"endAt"` + ZoneID string `json:"zoneId"` +} + +func (h *Handler) handleAvailability(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + profile := profileFromContext(ctx) + _ = h.store.RecordAccess(ctx, profile.ID, EventAvailabilityRead, sourceFromContext(ctx), h.now()) + + snapshot, err := h.store.ReadSnapshot(ctx, profile.ID) + if err != nil { + writeJSON(w, http.StatusOK, availabilityDTO{ + Windows: []windowDTO{}, + Status: StatusInsufficientData, + }) + return + } + writeJSON(w, http.StatusOK, toAvailabilityDTO(snapshot, h.now())) +} + +func toAvailabilityDTO(snapshot Snapshot, now time.Time) availabilityDTO { + dto := availabilityDTO{ + Version: snapshot.Version, + Windows: []windowDTO{}, + GeneratedAt: formatTime(snapshot.GeneratedAt), + HorizonEnd: formatTime(snapshot.HorizonEnd), + Status: snapshot.Status, + } + if dto.Status == "" { + dto.Status = StatusInsufficientData + } + // Apply the same age rules the rendered page applies, so a JSON consumer + // cannot present an out-of-date "awake now" the HTML would have withheld. + if !snapshot.GeneratedAt.IsZero() && now.Sub(snapshot.GeneratedAt) >= UnavailableAfter { + dto.Status = StatusInsufficientData + return dto + } + if dto.Status != StatusAvailable { + return dto + } + for _, window := range snapshot.Windows { + if !window.EndAt.After(now) { + continue + } + dto.Windows = append(dto.Windows, windowDTO{ + StartAt: formatTime(window.StartAt), + EndAt: formatTime(window.EndAt), + ZoneID: window.ZoneID, + }) + } + if len(dto.Windows) == 0 { + dto.Status = StatusInsufficientData + } + return dto +} + +// writeGeneric emits one uniform failure. The message never varies with the +// reason, so a caller cannot tell an unknown link from a revoked one. +func (h *Handler) writeGeneric(w http.ResponseWriter, r *http.Request, status int) { + title, message := genericMessage(status) + if wantsJSON(r) { + writeJSON(w, status, map[string]string{"status": "unavailable", "message": message}) + return + } + h.renderPage(w, r, status, "generic", pageData{Title: title, Error: message}) +} + +func genericMessage(status int) (string, string) { + switch status { + case http.StatusTooManyRequests: + return "Too many requests", "This link has received too many requests recently. Try again shortly." + case http.StatusUnauthorized: + return "Passcode required", "This page needs a passcode. Open the link again to enter it." + case http.StatusForbidden: + return "Request refused", "This request could not be accepted. Open the link directly and try again." + default: + // 410 and every unexpected status collapse here on purpose: unknown, + // expired, and revoked links must be indistinguishable. + return "Link unavailable", "This link is no longer available." + } +} + +func wantsJSON(r *http.Request) bool { + if strings.HasSuffix(r.URL.Path, "/availability") { + return true + } + accept := r.Header.Get("Accept") + return strings.Contains(accept, "application/json") && !strings.Contains(accept, "text/html") +} + +func (h *Handler) renderPage(w http.ResponseWriter, r *http.Request, status int, name string, data pageData) { + var buffer bytes.Buffer + if err := pageTemplates.ExecuteTemplate(&buffer, name, data); err != nil { + // Render into a buffer first so a template failure cannot emit a + // half-written page with a 200 already committed. + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("Unavailable

This page is unavailable.")) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + _, _ = w.Write(buffer.Bytes()) +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func pagePath(token string) string { + return (&url.URL{Path: "/p/" + token}).String() +} + +func sessionPath(token string) string { + return (&url.URL{Path: "/p/" + token + "/session"}).String() +} diff --git a/apps/server/internal/portal/http.go b/apps/server/internal/portal/http.go new file mode 100644 index 0000000..d239556 --- /dev/null +++ b/apps/server/internal/portal/http.go @@ -0,0 +1,288 @@ +package portal + +import ( + "context" + "errors" + "net/http" + "strings" + "time" +) + +const ( + // maxPublicBodyBytes caps a request before any decoding happens. + maxPublicBodyBytes = 4 << 10 + + // sessionCookieName uses the __Host- prefix, which browsers only accept + // with Secure, Path=/, and no Domain attribute. + sessionCookieName = "__Host-zb_portal" + + // resolutionFloor is applied to link resolution whether it succeeds or + // fails, so an enumerator cannot separate "real link" from "unknown + // link" by response time. It is a bounded floor, not a constant-time + // guarantee; docs/portal-design.md section 4 states that limit. + resolutionFloor = 120 * time.Millisecond +) + +type profileContextKey struct{} +type sessionContextKey struct{} + +// Handler serves the public portal. It holds a portal store and nothing else: +// there is no field through which a private record could be reached. +type Handler struct { + store *Store + now func() time.Time + publicOrigin string + resolutionFloor time.Duration +} + +type HandlerConfig struct { + Store *Store + Now func() time.Time + + // PublicOrigin is the exact scheme://host[:port] the portal is served + // on. Mutating requests must carry it as Origin. + PublicOrigin string + + // ResolutionFloor overrides the enumeration timing floor. Values at or + // below zero use the package default, so a caller cannot disable the + // floor by leaving the field unset; tests shorten it explicitly. + ResolutionFloor time.Duration +} + +func NewHandler(cfg HandlerConfig) (*Handler, error) { + if cfg.Store == nil { + return nil, errors.New("portal handler requires a store") + } + origin := strings.TrimSpace(strings.TrimSuffix(cfg.PublicOrigin, "/")) + if origin == "" { + return nil, errors.New("portal handler requires a public origin") + } + now := cfg.Now + if now == nil { + now = func() time.Time { return time.Now().UTC() } + } + floor := cfg.ResolutionFloor + if floor <= 0 { + floor = resolutionFloor + } + return &Handler{store: cfg.Store, now: now, publicOrigin: origin, resolutionFloor: floor}, nil +} + +// Routes returns the public mux. It is mounted only when the portal is +// enabled; when disabled the daemon never calls this and no /p/ route exists. +func (h *Handler) Routes() http.Handler { + mux := http.NewServeMux() + mux.Handle("GET /p/assets/portal.css", h.baseChain(http.HandlerFunc(h.handleStylesheet))) + mux.Handle("GET /p/{linkToken}", h.linkChain(http.HandlerFunc(h.handlePage))) + mux.Handle("POST /p/{linkToken}/session", h.linkChain(h.requireOrigin(http.HandlerFunc(h.handleSession)))) + mux.Handle("GET /p/{linkToken}/availability", h.linkChain(h.requireSession(http.HandlerFunc(h.handleAvailability)))) + return mux +} + +// baseChain applies the caps and headers that every public response needs, +// including responses that never resolve a link. +func (h *Handler) baseChain(next http.Handler) http.Handler { + return h.limitBody(h.securityHeaders(next)) +} + +// linkChain is the full public order from docs/portal-design.md section 3: +// size cap, security headers, source throttling, link resolution, then the +// handler. The passcode-session gate is applied per route because the page +// itself must render for an unauthenticated visitor. +func (h *Handler) linkChain(next http.Handler) http.Handler { + return h.limitBody(h.securityHeaders(h.throttle(h.resolveLink(next)))) +} + +func (h *Handler) limitBody(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxPublicBodyBytes) + next.ServeHTTP(w, r) + }) +} + +func (h *Handler) securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := w.Header() + header.Set("Cache-Control", "no-store, max-age=0") + header.Set("Content-Security-Policy", + "default-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; "+ + "img-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'") + header.Set("Referrer-Policy", "no-referrer") + header.Set("X-Content-Type-Options", "nosniff") + header.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()") + header.Set("Cross-Origin-Resource-Policy", "same-origin") + // Search engines must never index a share link. + header.Set("X-Robots-Tag", "noindex, nofollow, noarchive") + next.ServeHTTP(w, r) + }) +} + +// throttle applies the persisted per-source read limit before any expensive +// work. It runs before link resolution and therefore before the passcode KDF, +// which is what keeps argon2id from becoming a memory-exhaustion lever. +func (h *Handler) throttle(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + sourceID, err := h.store.SourceID(ctx, r.RemoteAddr) + if err != nil { + h.writeGeneric(w, r, http.StatusServiceUnavailable) + return + } + allowed, retryAfter, err := h.store.Allow(ctx, "read:"+sourceID, ReadLimitPerHour, ReadLimitWindow, h.now()) + if err != nil { + h.writeGeneric(w, r, http.StatusServiceUnavailable) + return + } + if !allowed { + _ = h.store.RecordAccess(ctx, "", EventThrottled, sourceID, h.now()) + w.Header().Set("Retry-After", retryAfterSeconds(retryAfter)) + h.writeGeneric(w, r, http.StatusTooManyRequests) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(ctx, sourceContextKey{}, sourceID))) + }) +} + +type sourceContextKey struct{} + +func sourceFromContext(ctx context.Context) string { + value, _ := ctx.Value(sourceContextKey{}).(string) + return value +} + +// resolveLink turns the path token into a profile. Unknown, expired, and +// revoked links are indistinguishable here because ResolveLink returns one +// error for all three; this handler could not leak the difference if it tried. +func (h *Handler) resolveLink(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + started := h.now() + ctx := r.Context() + token := r.PathValue("linkToken") + profile, err := h.store.ResolveLink(ctx, token, h.now()) + h.holdFloor(started) + if err != nil { + if errors.Is(err, ErrLinkNotUsable) { + _ = h.store.RecordAccess(ctx, "", EventLinkRejected, sourceFromContext(ctx), h.now()) + h.writeGeneric(w, r, http.StatusGone) + return + } + h.writeGeneric(w, r, http.StatusServiceUnavailable) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(ctx, profileContextKey{}, profile))) + }) +} + +// holdFloor pads a resolution to the timing floor. It sleeps rather than +// busy-waits so a flood costs memory for a parked goroutine, not CPU. +func (h *Handler) holdFloor(started time.Time) { + elapsed := h.now().Sub(started) + if elapsed < h.resolutionFloor { + time.Sleep(h.resolutionFloor - elapsed) + } +} + +// requireSession gates authenticated reads. The session must belong to the +// same profile the path token resolved to, so holding a session for one link +// grants nothing on another. +func (h *Handler) requireSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + profile := profileFromContext(r.Context()) + session, ok := h.sessionFor(r, profile) + if !ok { + h.writeGeneric(w, r, http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), sessionContextKey{}, session))) + }) +} + +func (h *Handler) sessionFor(r *http.Request, profile Profile) (Session, bool) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil || cookie.Value == "" { + return Session{}, false + } + session, err := h.store.ResolveSession(r.Context(), cookie.Value, h.now()) + if err != nil { + return Session{}, false + } + if session.ProfileID != profile.ID { + return Session{}, false + } + return session, true +} + +// requireOrigin is the pre-session CSRF defence. A passcode POST cannot carry +// a synchronizer token yet, so browser attestation that the request came from +// this origin is what stops a third party from silently authenticating a +// visitor's browser to a link. +func (h *Handler) requireOrigin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !h.isSameOriginMutation(r) { + h.writeGeneric(w, r, http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +// isSameOriginMutation decides whether a mutating request really came from a +// page this server served. +// +// Origin alone is not sufficient here. These responses set +// Referrer-Policy: no-referrer, and per the Fetch standard that makes a +// browser send `Origin: null` on a same-origin form POST rather than the real +// origin. Relaxing the referrer policy to recover Origin would put the link +// token into Referer headers, which section 9 of the design specifically +// avoids. Sec-Fetch-Site is unaffected by referrer policy and cannot be set by +// page script, so it is the primary signal; an exact Origin match remains the +// fallback for clients that predate Fetch Metadata. +func (h *Handler) isSameOriginMutation(r *http.Request) bool { + origin := r.Header.Get("Origin") + // A present, non-null Origin must match no matter what else is claimed. + if origin != "" && origin != "null" && !strings.EqualFold(origin, h.publicOrigin) { + return false + } + switch r.Header.Get("Sec-Fetch-Site") { + case "same-origin": + return true + case "": + // No Fetch Metadata at all: fall back to a real Origin match. A + // request with neither header is refused, so this fails closed. + return origin != "" && strings.EqualFold(origin, h.publicOrigin) + default: + // cross-site, same-site, and none are all refused: a subdomain is not + // this origin, and a POST is never user-initiated navigation. + return false + } +} + +func profileFromContext(ctx context.Context) Profile { + profile, _ := ctx.Value(profileContextKey{}).(Profile) + return profile +} + +func sessionFromContext(ctx context.Context) Session { + session, _ := ctx.Value(sessionContextKey{}).(Session) + return session +} + +func retryAfterSeconds(value time.Duration) string { + seconds := int(value.Seconds()) + if seconds < 1 { + seconds = 1 + } + return itoa(seconds) +} + +func itoa(value int) string { + if value == 0 { + return "0" + } + digits := make([]byte, 0, 12) + for value > 0 { + digits = append([]byte{byte('0' + value%10)}, digits...) + value /= 10 + } + return string(digits) +} diff --git a/apps/server/internal/portal/portal_test.go b/apps/server/internal/portal/portal_test.go new file mode 100644 index 0000000..ba5cf2f --- /dev/null +++ b/apps/server/internal/portal/portal_test.go @@ -0,0 +1,769 @@ +package portal + +import ( + "context" + "encoding/json" + "go/parser" + "go/token" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +const ( + testOrigin = "https://share.example.test" + testPasscode = "open-sesame" +) + +type clock struct { + mu sync.Mutex + now time.Time +} + +func (c *clock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *clock) Advance(delta time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.now = c.now.Add(delta) +} + +type harness struct { + t *testing.T + store *Store + handler http.Handler + clock *clock + token string + profile Profile +} + +func newHarness(t *testing.T) *harness { + t.Helper() + fixed := &clock{now: time.Date(2026, 8, 3, 15, 0, 0, 0, time.UTC)} + store, err := Open(filepath.Join(t.TempDir(), "portal.db"), make([]byte, 32)) + if err != nil { + t.Fatalf("open portal store: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + + handler, err := NewHandler(HandlerConfig{ + Store: store, + Now: fixed.Now, + PublicOrigin: testOrigin, + ResolutionFloor: time.Nanosecond, + }) + if err != nil { + t.Fatalf("new portal handler: %v", err) + } + + h := &harness{t: t, store: store, handler: handler.Routes(), clock: fixed} + h.token, h.profile = h.createProfile(Grants{WakingWindows: true}) + return h +} + +func (h *harness) createProfile(grants Grants) (string, Profile) { + h.t.Helper() + profileID, err := NewProfileID() + if err != nil { + h.t.Fatalf("new profile id: %v", err) + } + token, err := NewLinkToken() + if err != nil { + h.t.Fatalf("new link token: %v", err) + } + now := h.clock.Now() + if err := h.store.CreateProfile(context.Background(), CreateProfileInput{ + ProfileID: profileID, + Token: token, + Passcode: testPasscode, + Grants: grants, + CreatedAt: now, + ExpiresAt: now.Add(30 * 24 * time.Hour), + }); err != nil { + h.t.Fatalf("create profile: %v", err) + } + profile, err := h.store.ResolveLink(context.Background(), token, now) + if err != nil { + h.t.Fatalf("resolve new link: %v", err) + } + return token, profile +} + +func (h *harness) publish(profileID string, snapshot Snapshot) { + h.t.Helper() + if err := h.store.PublishSnapshot(context.Background(), profileID, snapshot); err != nil { + h.t.Fatalf("publish snapshot: %v", err) + } +} + +func (h *harness) sampleSnapshot() Snapshot { + now := h.clock.Now() + return Snapshot{ + Version: now.UnixMilli(), + GeneratedAt: now, + Status: StatusAvailable, + HorizonEnd: now.Add(30 * time.Hour), + Windows: []Window{ + {StartAt: now.Add(-time.Hour), EndAt: now.Add(6 * time.Hour), ZoneID: "America/New_York"}, + {StartAt: now.Add(24 * time.Hour), EndAt: now.Add(30 * time.Hour), ZoneID: "America/New_York"}, + }, + } +} + +func (h *harness) get(path string, cookie string) *httptest.ResponseRecorder { + h.t.Helper() + request := httptest.NewRequest(http.MethodGet, path, nil) + request.RemoteAddr = "203.0.113.10:5555" + if cookie != "" { + request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: cookie}) + } + recorder := httptest.NewRecorder() + h.handler.ServeHTTP(recorder, request) + return recorder +} + +// login performs the passcode exchange using only an Origin header, which is +// the pre-Fetch-Metadata client shape. +func (h *harness) login(token, passcode, origin string) *httptest.ResponseRecorder { + headers := map[string]string{} + if origin != "" { + headers["Origin"] = origin + } + return h.loginWithHeaders(token, passcode, headers) +} + +func (h *harness) loginWithHeaders(token, passcode string, headers map[string]string) *httptest.ResponseRecorder { + h.t.Helper() + form := url.Values{"passcode": {passcode}} + request := httptest.NewRequest(http.MethodPost, "/p/"+token+"/session", strings.NewReader(form.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.RemoteAddr = "203.0.113.10:5555" + for name, value := range headers { + request.Header.Set(name, value) + } + recorder := httptest.NewRecorder() + h.handler.ServeHTTP(recorder, request) + return recorder +} + +func (h *harness) sessionCookie(recorder *httptest.ResponseRecorder) string { + h.t.Helper() + for _, cookie := range recorder.Result().Cookies() { + if cookie.Name == sessionCookieName { + return cookie.Value + } + } + h.t.Fatalf("no session cookie in response (status %d)", recorder.Code) + return "" +} + +// TestPortalPackageDoesNotReachPrivateData is the structural half of the +// boundary claim in docs/portal-design.md section 2. The public package must +// not be able to reach a sleep record by following an import, and a reviewer +// should not have to notice a new import to keep that true. +func TestPortalPackageDoesNotReachPrivateData(t *testing.T) { + forbidden := []string{ + "non24.app/server/internal/store", + "non24.app/server/internal/readmodel", + "non24.app/server/internal/assistant", + "non24.app/server/internal/api", + "non24.app/server/internal/portalbridge", + "non24.app/server/internal/provider", + "non24.app/core/estimation", + "non24.app/core/domain", + } + fileSet := token.NewFileSet() + packages, err := parser.ParseDir(fileSet, ".", nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse portal package: %v", err) + } + for name, pkg := range packages { + for path, file := range pkg.Files { + if strings.HasSuffix(path, "_test.go") { + continue + } + for _, imported := range file.Imports { + value := strings.Trim(imported.Path.Value, `"`) + for _, banned := range forbidden { + if value == banned { + t.Errorf("package %s file %s imports forbidden package %s", name, path, banned) + } + } + } + } + } +} + +// TestAvailabilityDTOAllowlist asserts the exact public JSON shape. Any new +// key is a widening of the public boundary and must fail here first. +func TestAvailabilityDTOAllowlist(t *testing.T) { + h := newHarness(t) + h.publish(h.profile.ID, h.sampleSnapshot()) + cookie := h.sessionCookie(h.login(h.token, testPasscode, testOrigin)) + + recorder := h.get("/p/"+h.token+"/availability", cookie) + if recorder.Code != http.StatusOK { + t.Fatalf("availability status = %d, want 200", recorder.Code) + } + var decoded map[string]any + if err := json.Unmarshal(recorder.Body.Bytes(), &decoded); err != nil { + t.Fatalf("decode availability: %v", err) + } + + allowed := map[string]bool{"version": true, "windows": true, "generatedAt": true, "horizonEnd": true, "status": true} + for key := range decoded { + if !allowed[key] { + t.Errorf("availability response has non-allowlisted key %q", key) + } + } + for _, required := range []string{"version", "windows", "generatedAt", "horizonEnd", "status"} { + if _, ok := decoded[required]; !ok { + t.Errorf("availability response is missing %q", required) + } + } + + windowKeys := map[string]bool{"startAt": true, "endAt": true, "zoneId": true} + windows, ok := decoded["windows"].([]any) + if !ok || len(windows) == 0 { + t.Fatalf("expected windows in availability response, got %v", decoded["windows"]) + } + for _, entry := range windows { + window, ok := entry.(map[string]any) + if !ok { + t.Fatalf("window is not an object: %v", entry) + } + for key := range window { + if !windowKeys[key] { + t.Errorf("window has non-allowlisted key %q", key) + } + } + } +} + +// TestUnknownExpiredRevokedAreIndistinguishable is the enumeration guard: all +// three must produce byte-identical responses. +func TestUnknownExpiredRevokedAreIndistinguishable(t *testing.T) { + h := newHarness(t) + + expiredToken, expiredProfile := h.createProfile(Grants{WakingWindows: true}) + revokedToken, revokedProfile := h.createProfile(Grants{WakingWindows: true}) + if err := h.store.RevokeProfile(context.Background(), revokedProfile.ID, h.clock.Now()); err != nil { + t.Fatalf("revoke: %v", err) + } + _ = expiredProfile + h.clock.Advance(31 * 24 * time.Hour) + + unknownToken, err := NewLinkToken() + if err != nil { + t.Fatalf("new token: %v", err) + } + + responses := make([]*httptest.ResponseRecorder, 0, 3) + for _, token := range []string{unknownToken, expiredToken, revokedToken} { + responses = append(responses, h.get("/p/"+token, "")) + } + for i, recorder := range responses { + if recorder.Code != http.StatusGone { + t.Errorf("response %d status = %d, want 410", i, recorder.Code) + } + if recorder.Body.String() != responses[0].Body.String() { + t.Errorf("response %d body differs from the first; link states are distinguishable", i) + } + } +} + +// TestRevocationIsImmediate covers the kill switch: an already-authenticated +// visitor must lose access at once, not when their cookie expires. +func TestRevocationIsImmediate(t *testing.T) { + h := newHarness(t) + h.publish(h.profile.ID, h.sampleSnapshot()) + cookie := h.sessionCookie(h.login(h.token, testPasscode, testOrigin)) + + if recorder := h.get("/p/"+h.token, cookie); recorder.Code != http.StatusOK { + t.Fatalf("pre-revocation status = %d, want 200", recorder.Code) + } + if err := h.store.RevokeProfile(context.Background(), h.profile.ID, h.clock.Now()); err != nil { + t.Fatalf("revoke: %v", err) + } + if recorder := h.get("/p/"+h.token, cookie); recorder.Code != http.StatusGone { + t.Errorf("post-revocation status = %d, want 410", recorder.Code) + } + if _, err := h.store.ReadSnapshot(context.Background(), h.profile.ID); err == nil { + t.Error("snapshot survived revocation; availability data must be deleted") + } +} + +func TestPasscodeGate(t *testing.T) { + h := newHarness(t) + h.publish(h.profile.ID, h.sampleSnapshot()) + + page := h.get("/p/"+h.token, "") + if page.Code != http.StatusOK { + t.Fatalf("unauthenticated page status = %d, want 200", page.Code) + } + if !strings.Contains(page.Body.String(), "needs a passcode") { + t.Error("unauthenticated visitor was not shown the passcode form") + } + if strings.Contains(page.Body.String(), "Likely awake") { + t.Error("availability rendered before the passcode was accepted") + } + + rejected := h.login(h.token, "wrong-passcode", testOrigin) + if rejected.Code != http.StatusUnauthorized { + t.Errorf("wrong passcode status = %d, want 401", rejected.Code) + } + + // The failed attempt arms the backoff, so advance past it before the + // legitimate visitor tries again. + h.clock.Advance(2 * time.Second) + accepted := h.login(h.token, testPasscode, testOrigin) + if accepted.Code != http.StatusSeeOther { + t.Fatalf("correct passcode status = %d, want 303", accepted.Code) + } + cookie := h.sessionCookie(accepted) + dashboard := h.get("/p/"+h.token, cookie) + if !strings.Contains(dashboard.Body.String(), "Likely awake right now") { + t.Errorf("dashboard did not render the current state: %s", dashboard.Body.String()) + } +} + +// TestPasscodeBackoffIsPerProfileAndSource proves progressive delay exists and +// that it does not become a global lockout an attacker could trigger. +func TestPasscodeBackoffIsPerProfileAndSource(t *testing.T) { + h := newHarness(t) + if recorder := h.login(h.token, "wrong", testOrigin); recorder.Code != http.StatusUnauthorized { + t.Fatalf("first failure status = %d, want 401", recorder.Code) + } + // Immediately retrying with the *correct* passcode is throttled, not + // permanently refused. + throttled := h.login(h.token, testPasscode, testOrigin) + if throttled.Code != http.StatusTooManyRequests { + t.Errorf("immediate retry status = %d, want 429", throttled.Code) + } + h.clock.Advance(5 * time.Second) + if recorder := h.login(h.token, testPasscode, testOrigin); recorder.Code != http.StatusSeeOther { + t.Errorf("retry after backoff status = %d, want 303", recorder.Code) + } +} + +func TestSessionIsBoundToOneProfile(t *testing.T) { + h := newHarness(t) + otherToken, otherProfile := h.createProfile(Grants{WakingWindows: true}) + h.publish(h.profile.ID, h.sampleSnapshot()) + h.publish(otherProfile.ID, h.sampleSnapshot()) + + cookie := h.sessionCookie(h.login(h.token, testPasscode, testOrigin)) + crossed := h.get("/p/"+otherToken+"/availability", cookie) + if crossed.Code != http.StatusUnauthorized { + t.Errorf("cross-link availability status = %d, want 401", crossed.Code) + } +} + +func TestSessionRequiresExactOrigin(t *testing.T) { + h := newHarness(t) + for name, origin := range map[string]string{ + "missing": "", + "foreign": "https://evil.example", + "subdomain": "https://x.share.example.test", + "scheme swap": "http://share.example.test", + } { + recorder := h.login(h.token, testPasscode, origin) + if recorder.Code != http.StatusForbidden { + t.Errorf("%s origin status = %d, want 403", name, recorder.Code) + } + } +} + +// TestSameOriginFormPostWithNullOrigin is the case a browser actually +// produces. Referrer-Policy: no-referrer makes a browser send `Origin: null` +// on a same-origin form submission, so gating on Origin alone would refuse +// every real login. Sec-Fetch-Site carries the truth. +func TestSameOriginFormPostWithNullOrigin(t *testing.T) { + h := newHarness(t) + recorder := h.loginWithHeaders(h.token, testPasscode, map[string]string{ + "Origin": "null", + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + }) + if recorder.Code != http.StatusSeeOther { + t.Fatalf("same-origin form post status = %d, want 303", recorder.Code) + } +} + +func TestFetchMetadataDecidesMutations(t *testing.T) { + cases := map[string]struct { + headers map[string]string + want int + }{ + "same-origin without Origin": { + map[string]string{"Sec-Fetch-Site": "same-origin"}, http.StatusSeeOther, + }, + "same-origin with matching Origin": { + map[string]string{"Sec-Fetch-Site": "same-origin", "Origin": testOrigin}, http.StatusSeeOther, + }, + "cross-site even with a spoofed Origin": { + map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": testOrigin}, http.StatusForbidden, + }, + "same-site subdomain": { + map[string]string{"Sec-Fetch-Site": "same-site"}, http.StatusForbidden, + }, + "user-initiated navigation": { + map[string]string{"Sec-Fetch-Site": "none"}, http.StatusForbidden, + }, + "no metadata and no Origin": { + map[string]string{}, http.StatusForbidden, + }, + "no metadata with matching Origin": { + map[string]string{"Origin": testOrigin}, http.StatusSeeOther, + }, + "null Origin without metadata": { + map[string]string{"Origin": "null"}, http.StatusForbidden, + }, + "same-origin metadata but foreign Origin": { + map[string]string{"Sec-Fetch-Site": "same-origin", "Origin": "https://evil.example"}, http.StatusForbidden, + }, + } + for name, testCase := range cases { + h := newHarness(t) + recorder := h.loginWithHeaders(h.token, testPasscode, testCase.headers) + if recorder.Code != testCase.want { + t.Errorf("%s: status = %d, want %d", name, recorder.Code, testCase.want) + } + } +} + +func TestSecurityHeadersOnEveryResponse(t *testing.T) { + h := newHarness(t) + h.publish(h.profile.ID, h.sampleSnapshot()) + cookie := h.sessionCookie(h.login(h.token, testPasscode, testOrigin)) + + paths := []string{"/p/" + h.token, "/p/" + h.token + "/availability", "/p/assets/portal.css", "/p/nonexistent-token"} + for _, path := range paths { + recorder := h.get(path, cookie) + header := recorder.Header() + csp := header.Get("Content-Security-Policy") + if !strings.Contains(csp, "default-src 'none'") { + t.Errorf("%s missing default-src 'none': %q", path, csp) + } + if !strings.Contains(csp, "frame-ancestors 'none'") { + t.Errorf("%s missing frame-ancestors 'none'", path) + } + for _, directive := range []string{"style-src 'self'", "script-src 'self'", "connect-src 'self'", "img-src 'self'"} { + if !strings.Contains(csp, directive) { + t.Errorf("%s missing %q", path, directive) + } + } + if got := header.Get("Cache-Control"); got != "no-store, max-age=0" { + t.Errorf("%s Cache-Control = %q", path, got) + } + if got := header.Get("Referrer-Policy"); got != "no-referrer" { + t.Errorf("%s Referrer-Policy = %q", path, got) + } + if got := header.Get("X-Content-Type-Options"); got != "nosniff" { + t.Errorf("%s X-Content-Type-Options = %q", path, got) + } + if !strings.Contains(header.Get("X-Robots-Tag"), "noindex") { + t.Errorf("%s is not marked noindex", path) + } + } +} + +// TestPageHasNoInlineOrThirdPartyAssets keeps the CSP honest: a page with an +// inline style would render fine in a browser that ignores CSP and break in +// one that does not. +func TestPageHasNoInlineOrThirdPartyAssets(t *testing.T) { + h := newHarness(t) + h.publish(h.profile.ID, h.sampleSnapshot()) + cookie := h.sessionCookie(h.login(h.token, testPasscode, testOrigin)) + body := h.get("/p/"+h.token, cookie).Body.String() + + for _, forbidden := range []string{" 0 { + return nil + } + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return err + } + _, err := s.db.ExecContext(ctx, + `INSERT INTO portal_audit_keys (key, created_at) VALUES (?, ?)`, + key, formatTime(time.Now())) + return err +} + +func (s *Store) currentAuditKey(ctx context.Context) ([]byte, error) { + var key []byte + err := s.db.QueryRowContext(ctx, + `SELECT key FROM portal_audit_keys ORDER BY id DESC LIMIT 1`).Scan(&key) + if errors.Is(err, sql.ErrNoRows) { + return nil, errors.New("portal audit key is missing") + } + return key, err +} + +// SourceID converts a remote address into a pseudonymous identifier. The raw +// address is never stored. This is not anonymity: NAT groups distinct people +// under one identifier and a changing network splits one person across +// several. docs/privacy.md states that limitation. +func (s *Store) SourceID(ctx context.Context, remoteAddr string) (string, error) { + key, err := s.currentAuditKey(ctx) + if err != nil { + return "", err + } + mac := hmac.New(sha256.New, key) + _, _ = mac.Write([]byte(normalizeRemoteAddr(remoteAddr))) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)[:16]), nil +} + +// RotateAuditKey makes every previously issued source identifier unlinkable to +// future ones, and drops audit rows older than retain so the old key's outputs +// do not linger. Rotation without that deletion would be a rotation in name +// only. +func (s *Store) RotateAuditKey(ctx context.Context, now time.Time, retain time.Duration) error { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return err + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, + `INSERT INTO portal_audit_keys (key, created_at) VALUES (?, ?)`, key, formatTime(now)); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, + `DELETE FROM portal_access_audit WHERE occurred_at <= ?`, formatTime(now.Add(-retain))); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, + `DELETE FROM portal_audit_keys WHERE id NOT IN (SELECT id FROM portal_audit_keys ORDER BY id DESC LIMIT 1)`); err != nil { + return err + } + return tx.Commit() +} + +// normalizeRemoteAddr strips the port and collapses an IPv6 address to its /64 +// prefix, because a single host is routinely given a whole /64 and would +// otherwise defeat per-source limits by picking a new address per request. +func normalizeRemoteAddr(remoteAddr string) string { + host := strings.TrimSpace(remoteAddr) + if parsedHost, _, err := net.SplitHostPort(host); err == nil { + host = parsedHost + } + addr, err := netip.ParseAddr(host) + if err != nil { + return host + } + addr = addr.Unmap() + if addr.Is6() { + if prefix, err := addr.Prefix(64); err == nil { + return prefix.String() + } + } + return addr.String() +} + +// Allow applies a persisted fixed-window counter. It returns the wait before +// the caller may retry when the window is exhausted. +func (s *Store) Allow(ctx context.Context, bucketKey string, limit int, window time.Duration, now time.Time) (bool, time.Duration, error) { + if limit <= 0 || window <= 0 { + return false, 0, errors.New("portal rate limit requires a positive limit and window") + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return false, 0, err + } + defer func() { _ = tx.Rollback() }() + + var ( + windowStartRaw string + count int + ) + err = tx.QueryRowContext(ctx, + `SELECT window_start, count FROM portal_rate_buckets WHERE bucket_key = ?`, bucketKey). + Scan(&windowStartRaw, &count) + switch { + case errors.Is(err, sql.ErrNoRows): + if _, err := tx.ExecContext(ctx, + `INSERT INTO portal_rate_buckets (bucket_key, window_start, count) VALUES (?, ?, 1)`, + bucketKey, formatTime(now)); err != nil { + return false, 0, err + } + return true, 0, tx.Commit() + case err != nil: + return false, 0, err + } + + windowStart, err := parseTime(windowStartRaw) + if err != nil { + return false, 0, err + } + if !now.Before(windowStart.Add(window)) { + if _, err := tx.ExecContext(ctx, + `UPDATE portal_rate_buckets SET window_start = ?, count = 1 WHERE bucket_key = ?`, + formatTime(now), bucketKey); err != nil { + return false, 0, err + } + return true, 0, tx.Commit() + } + if count >= limit { + retryAfter := windowStart.Add(window).Sub(now) + return false, retryAfter, tx.Commit() + } + if _, err := tx.ExecContext(ctx, + `UPDATE portal_rate_buckets SET count = count + 1 WHERE bucket_key = ?`, bucketKey); err != nil { + return false, 0, err + } + return true, 0, tx.Commit() +} + +// PasscodeDelay reports how long the caller must wait before another passcode +// attempt is accepted. The backoff is per profile-and-source: there is +// deliberately no global lockout, because an attacker could otherwise use it +// to deny access to every legitimate visitor (design section 4). +func (s *Store) PasscodeDelay(ctx context.Context, bucketKey string, now time.Time) (time.Duration, error) { + var blockedUntilRaw string + err := s.db.QueryRowContext(ctx, + `SELECT blocked_until FROM portal_rate_buckets WHERE bucket_key = ?`, bucketKey).Scan(&blockedUntilRaw) + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + if err != nil { + return 0, err + } + blockedUntil, err := parseTime(blockedUntilRaw) + if err != nil { + return 0, err + } + if blockedUntil.After(now) { + return blockedUntil.Sub(now), nil + } + return 0, nil +} + +// NotePasscodeFailure records one rejected attempt and returns the new wait. +func (s *Store) NotePasscodeFailure(ctx context.Context, bucketKey string, now time.Time) (time.Duration, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer func() { _ = tx.Rollback() }() + + var failures int + err = tx.QueryRowContext(ctx, + `SELECT failures FROM portal_rate_buckets WHERE bucket_key = ?`, bucketKey).Scan(&failures) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return 0, err + } + failures++ + delay := passcodeBackoff(failures) + blockedUntil := formatTime(now.Add(delay)) + + if errors.Is(err, sql.ErrNoRows) { + if _, err := tx.ExecContext(ctx, + `INSERT INTO portal_rate_buckets (bucket_key, window_start, count, failures, blocked_until) + VALUES (?, ?, 0, ?, ?)`, + bucketKey, formatTime(now), failures, blockedUntil); err != nil { + return 0, err + } + return delay, tx.Commit() + } + if _, err := tx.ExecContext(ctx, + `UPDATE portal_rate_buckets SET failures = ?, blocked_until = ? WHERE bucket_key = ?`, + failures, blockedUntil, bucketKey); err != nil { + return 0, err + } + return delay, tx.Commit() +} + +func (s *Store) ClearPasscodeFailures(ctx context.Context, bucketKey string) error { + _, err := s.db.ExecContext(ctx, + `UPDATE portal_rate_buckets SET failures = 0, blocked_until = '' WHERE bucket_key = ?`, bucketKey) + return err +} + +func passcodeBackoff(failures int) time.Duration { + if failures <= 1 { + return time.Second + } + if failures > 20 { + return maxPasscodeBackoff + } + delay := time.Second << uint(failures-1) + if delay > maxPasscodeBackoff || delay <= 0 { + return maxPasscodeBackoff + } + return delay +} + +// RecordAccess appends one coarse audit row. It stores an event from the +// closed enum, a pseudonymous source, and a time. There is no field for a URL, +// a user agent, or any visitor text. +func (s *Store) RecordAccess(ctx context.Context, profileID string, event AccessEvent, sourceID string, now time.Time) error { + if !event.valid() { + return fmt.Errorf("unsupported portal access event %q", string(event)) + } + if profileID == "" { + profileID = unknownProfile + } + _, err := s.db.ExecContext(ctx, + `INSERT INTO portal_access_audit (profile_id, event, source_hmac, occurred_at) VALUES (?, ?, ?, ?)`, + profileID, string(event), sourceID, formatTime(now)) + return err +} + +// AccessSummary is the coarse view the owner's Sharing screen renders. It +// counts events rather than listing them so the owner cannot reconstruct a +// visitor's browsing sequence. +type AccessSummary struct { + ProfileID string + Event AccessEvent + Count int + LastAccess time.Time +} + +func (s *Store) SummarizeAccess(ctx context.Context, profileID string, since time.Time) ([]AccessSummary, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT event, COUNT(1), MAX(occurred_at) FROM portal_access_audit + WHERE profile_id = ? AND occurred_at >= ? + GROUP BY event ORDER BY event`, profileID, formatTime(since)) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var summaries []AccessSummary + for rows.Next() { + var ( + event string + count int + lastRaw string + ) + if err := rows.Scan(&event, &count, &lastRaw); err != nil { + return nil, err + } + last, err := parseTime(lastRaw) + if err != nil { + return nil, err + } + summaries = append(summaries, AccessSummary{ + ProfileID: profileID, + Event: AccessEvent(event), + Count: count, + LastAccess: last, + }) + } + return summaries, rows.Err() +} + +// EraseAudit drops a profile's audit rows. It exists so audit data is subject +// to the same erasure right as everything else (ADR-0014/0017). +func (s *Store) EraseAudit(ctx context.Context, profileID string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM portal_access_audit WHERE profile_id = ?`, profileID) + return err +} diff --git a/apps/server/internal/portal/store.go b/apps/server/internal/portal/store.go new file mode 100644 index 0000000..04793b6 --- /dev/null +++ b/apps/server/internal/portal/store.go @@ -0,0 +1,754 @@ +// Package portal implements the public-facing availability portal (phase 5). +// +// Every type here is constructed with the portal database only. The package +// deliberately does not import the private store, so a defect in a public +// handler cannot reach sleep records, medications, tasks, or profile labels. +// The private side pushes allowlisted snapshots in through PublishSnapshot; +// nothing flows the other way in this slice. +// +// The portal is disabled by default. See config.PortalConfig and the exposure +// gate in docs/portal-design.md section 12. +package portal + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "golang.org/x/crypto/argon2" + + _ "modernc.org/sqlite" +) + +var ( + // ErrLinkNotUsable is returned for unknown, expired, and revoked links + // alike. Collapsing the three at the store boundary is deliberate: a + // handler cannot leak which case occurred because it is never told. + ErrLinkNotUsable = errors.New("portal link is not usable") + + ErrProfileNotFound = errors.New("portal profile not found") + ErrPasscodeRejected = errors.New("portal passcode rejected") + ErrSessionInvalid = errors.New("portal session invalid") + ErrNoSnapshot = errors.New("portal snapshot has not been materialized") +) + +// Snapshot status values. These are the only statuses the public surface can +// describe, and they carry no estimator internals. +const ( + StatusAvailable = "available" + StatusRefused = "refused" + StatusInsufficientData = "insufficient_data" +) + +const ( + // MaxLinkLifetime caps how long a share link may live (design section 4). + MaxLinkLifetime = 90 * 24 * time.Hour + + // SessionLifetime caps an authenticated portal session. The effective + // expiry is the earlier of this and link expiry. + SessionLifetime = 24 * time.Hour + + // MinPasscodeLength is a usability floor, not a security claim. The + // throttle in ratelimit.go is what makes short passcodes survivable. + MinPasscodeLength = 6 + MaxPasscodeLength = 128 +) + +// Argon2id parameters. Stored per row so they can be raised later without +// invalidating existing passcodes. +const ( + argonTime uint32 = 3 + argonMemory uint32 = 64 * 1024 + argonThreads uint8 = 4 + argonKeyLen uint32 = 32 + argonSaltLen = 16 +) + +// Grants are the portal-side copy of what a link is allowed to do. Only the +// fields the public handlers must enforce live here; the private display label +// stays in the owner's store. +type Grants struct { + WakingWindows bool `json:"wakingWindows"` + AllowRequests bool `json:"allowRequests"` + AllowMessages bool `json:"allowMessages"` +} + +// Profile is the portal-side projection of a share profile. It has no label, +// no owner identity, and no health fields by construction. +type Profile struct { + ID string + Grants Grants + CreatedAt time.Time + ExpiresAt time.Time +} + +// Window is one likely-awake interval. Half-open: [StartAt, EndAt). +type Window struct { + StartAt time.Time `json:"startAt"` + EndAt time.Time `json:"endAt"` + ZoneID string `json:"zoneId"` +} + +// Snapshot is the entire set of facts the public surface may describe. Adding +// a field here widens the public boundary and requires the design review in +// docs/portal-design.md section 5. +type Snapshot struct { + Version int64 + Windows []Window + GeneratedAt time.Time + HorizonEnd time.Time + Status string +} + +type Store struct { + db *sql.DB + aead cipher.AEAD +} + +// Open creates or opens the portal database. rootKey is the daemon data key; +// the portal key is derived one-way from it so that reading the portal +// database never yields the private store's key. +func Open(path string, rootKey []byte) (*Store, error) { + if len(rootKey) != 32 { + return nil, errors.New("portal root key must be 32 bytes") + } + if path == "" { + return nil, errors.New("portal database path is required") + } + if path != ":memory:" { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, fmt.Errorf("create portal data directory: %w", err) + } + } + derived := sha256.Sum256(append([]byte("zeitboard-portal-store\x00"), rootKey...)) + block, err := aes.NewCipher(derived[:]) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + store := &Store{db: db, aead: aead} + if err := store.migrate(context.Background()); err != nil { + _ = db.Close() + return nil, err + } + return store, nil +} + +func (s *Store) Close() error { + return s.db.Close() +} + +func (s *Store) migrate(ctx context.Context) error { + statements := []string{ + `PRAGMA foreign_keys = ON`, + `PRAGMA journal_mode = WAL`, + `PRAGMA busy_timeout = 5000`, + `PRAGMA secure_delete = ON`, + `CREATE TABLE IF NOT EXISTS portal_profiles ( + profile_id TEXT PRIMARY KEY, + token_hash BLOB NOT NULL UNIQUE, + grant_windows INTEGER NOT NULL, + grant_requests INTEGER NOT NULL, + grant_messages INTEGER NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT NOT NULL DEFAULT '', + passcode_hash BLOB NOT NULL, + passcode_salt BLOB NOT NULL, + passcode_time INTEGER NOT NULL, + passcode_memory INTEGER NOT NULL, + passcode_threads INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS portal_snapshots ( + profile_id TEXT PRIMARY KEY REFERENCES portal_profiles(profile_id) ON DELETE CASCADE, + version INTEGER NOT NULL, + generated_at TEXT NOT NULL, + horizon_end TEXT NOT NULL, + status TEXT NOT NULL, + nonce BLOB NOT NULL, + ciphertext BLOB NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS portal_sessions ( + session_hash BLOB PRIMARY KEY, + profile_id TEXT NOT NULL REFERENCES portal_profiles(profile_id) ON DELETE CASCADE, + csrf_hash BLOB NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_portal_sessions_profile + ON portal_sessions(profile_id)`, + `CREATE TABLE IF NOT EXISTS portal_rate_buckets ( + bucket_key TEXT PRIMARY KEY, + window_start TEXT NOT NULL, + count INTEGER NOT NULL, + failures INTEGER NOT NULL DEFAULT 0, + blocked_until TEXT NOT NULL DEFAULT '' + )`, + `CREATE TABLE IF NOT EXISTS portal_access_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + profile_id TEXT NOT NULL, + event TEXT NOT NULL, + source_hmac TEXT NOT NULL, + occurred_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_portal_access_audit_profile + ON portal_access_audit(profile_id, id)`, + `CREATE TABLE IF NOT EXISTS portal_audit_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key BLOB NOT NULL, + created_at TEXT NOT NULL + )`, + } + for _, statement := range statements { + if _, err := s.db.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("portal migrate: %w", err) + } + } + return s.ensureAuditKey(ctx) +} + +// CreateProfileInput carries a new link. The caller supplies the raw token and +// passcode; only derived values are persisted. +type CreateProfileInput struct { + ProfileID string + Token string + Passcode string + Grants Grants + CreatedAt time.Time + ExpiresAt time.Time +} + +func (s *Store) CreateProfile(ctx context.Context, input CreateProfileInput) error { + switch { + case input.ProfileID == "": + return errors.New("portal profile id is required") + case input.Token == "": + return errors.New("portal link token is required") + case len(input.Passcode) < MinPasscodeLength: + return fmt.Errorf("portal passcode must be at least %d characters", MinPasscodeLength) + case len(input.Passcode) > MaxPasscodeLength: + return fmt.Errorf("portal passcode must be at most %d characters", MaxPasscodeLength) + case !input.ExpiresAt.After(input.CreatedAt): + return errors.New("portal link expiry must be in the future") + case input.ExpiresAt.Sub(input.CreatedAt) > MaxLinkLifetime: + return fmt.Errorf("portal link lifetime must not exceed %d days", int(MaxLinkLifetime.Hours()/24)) + } + + salt := make([]byte, argonSaltLen) + if _, err := rand.Read(salt); err != nil { + return err + } + hash := argon2.IDKey([]byte(input.Passcode), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + tokenHash := hashToken(input.Token) + + _, err := s.db.ExecContext(ctx, `INSERT INTO portal_profiles + (profile_id, token_hash, grant_windows, grant_requests, grant_messages, + created_at, expires_at, revoked_at, + passcode_hash, passcode_salt, passcode_time, passcode_memory, passcode_threads) + VALUES (?, ?, ?, ?, ?, ?, ?, '', ?, ?, ?, ?, ?)`, + input.ProfileID, tokenHash[:], + boolToInt(input.Grants.WakingWindows), boolToInt(input.Grants.AllowRequests), boolToInt(input.Grants.AllowMessages), + formatTime(input.CreatedAt), formatTime(input.ExpiresAt), + hash, salt, int64(argonTime), int64(argonMemory), int64(argonThreads)) + if err != nil { + return fmt.Errorf("create portal profile: %w", err) + } + return nil +} + +// ResolveLink maps a raw link token to a usable profile. Unknown, expired, and +// revoked tokens all produce ErrLinkNotUsable. +func (s *Store) ResolveLink(ctx context.Context, token string, now time.Time) (Profile, error) { + if token == "" { + return Profile{}, ErrLinkNotUsable + } + tokenHash := hashToken(token) + row := s.db.QueryRowContext(ctx, `SELECT profile_id, grant_windows, grant_requests, grant_messages, + created_at, expires_at, revoked_at + FROM portal_profiles WHERE token_hash = ?`, tokenHash[:]) + profile, expired, revoked, err := scanProfile(row, now) + switch { + case errors.Is(err, sql.ErrNoRows): + return Profile{}, ErrLinkNotUsable + case err != nil: + return Profile{}, err + case expired || revoked: + return Profile{}, ErrLinkNotUsable + } + return profile, nil +} + +// LookupProfile is the owner-side read. Unlike ResolveLink it distinguishes +// missing from revoked, because the owner is entitled to that difference. +func (s *Store) LookupProfile(ctx context.Context, profileID string, now time.Time) (Profile, bool, bool, error) { + row := s.db.QueryRowContext(ctx, `SELECT profile_id, grant_windows, grant_requests, grant_messages, + created_at, expires_at, revoked_at + FROM portal_profiles WHERE profile_id = ?`, profileID) + profile, expired, revoked, err := scanProfile(row, now) + if errors.Is(err, sql.ErrNoRows) { + return Profile{}, false, false, ErrProfileNotFound + } + if err != nil { + return Profile{}, false, false, err + } + return profile, expired, revoked, nil +} + +func scanProfile(row *sql.Row, now time.Time) (Profile, bool, bool, error) { + var ( + profile Profile + windows, requests, messages int + createdAtRaw, expiresAtRaw, revoked string + ) + if err := row.Scan(&profile.ID, &windows, &requests, &messages, &createdAtRaw, &expiresAtRaw, &revoked); err != nil { + return Profile{}, false, false, err + } + createdAt, err := parseTime(createdAtRaw) + if err != nil { + return Profile{}, false, false, err + } + expiresAt, err := parseTime(expiresAtRaw) + if err != nil { + return Profile{}, false, false, err + } + profile.CreatedAt = createdAt + profile.ExpiresAt = expiresAt + profile.Grants = Grants{ + WakingWindows: windows == 1, + AllowRequests: requests == 1, + AllowMessages: messages == 1, + } + return profile, !expiresAt.After(now), revoked != "", nil +} + +// ListActiveProfiles returns profiles that are neither expired nor revoked. +// The materializer uses it to decide what to refresh; the owner UI uses +// ListProfiles, which also shows dead links. +func (s *Store) ListActiveProfiles(ctx context.Context, now time.Time) ([]Profile, error) { + return s.listProfiles(ctx, now, true) +} + +// ListProfiles returns every profile, including expired and revoked ones, so +// the owner can audit links they have already turned off. +func (s *Store) ListProfiles(ctx context.Context, now time.Time) ([]ProfileState, error) { + profiles, err := s.listProfileStates(ctx, now) + if err != nil { + return nil, err + } + return profiles, nil +} + +// ProfileState adds the owner-visible lifecycle that ResolveLink deliberately +// refuses to distinguish for the public. +type ProfileState struct { + Profile + Expired bool + Revoked bool +} + +func (s *Store) listProfiles(ctx context.Context, now time.Time, activeOnly bool) ([]Profile, error) { + states, err := s.listProfileStates(ctx, now) + if err != nil { + return nil, err + } + profiles := make([]Profile, 0, len(states)) + for _, state := range states { + if activeOnly && (state.Expired || state.Revoked) { + continue + } + profiles = append(profiles, state.Profile) + } + return profiles, nil +} + +func (s *Store) listProfileStates(ctx context.Context, now time.Time) ([]ProfileState, error) { + rows, err := s.db.QueryContext(ctx, `SELECT profile_id, grant_windows, grant_requests, grant_messages, + created_at, expires_at, revoked_at FROM portal_profiles ORDER BY created_at, profile_id`) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var states []ProfileState + for rows.Next() { + var ( + profile Profile + windows, requests, messages int + createdAtRaw, expiresAtRaw, revoked string + ) + if err := rows.Scan(&profile.ID, &windows, &requests, &messages, &createdAtRaw, &expiresAtRaw, &revoked); err != nil { + return nil, err + } + createdAt, err := parseTime(createdAtRaw) + if err != nil { + return nil, err + } + expiresAt, err := parseTime(expiresAtRaw) + if err != nil { + return nil, err + } + profile.CreatedAt = createdAt + profile.ExpiresAt = expiresAt + profile.Grants = Grants{ + WakingWindows: windows == 1, + AllowRequests: requests == 1, + AllowMessages: messages == 1, + } + states = append(states, ProfileState{ + Profile: profile, + Expired: !expiresAt.After(now), + Revoked: revoked != "", + }) + } + return states, rows.Err() +} + +// RevokeProfile is the kill switch. It drops sessions immediately so that an +// already-authenticated visitor cannot keep reading through an open session, +// and it deletes the materialized snapshot so no availability data survives in +// the portal database. +func (s *Store) RevokeProfile(ctx context.Context, profileID string, now time.Time) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + result, err := tx.ExecContext(ctx, + `UPDATE portal_profiles SET revoked_at = ? WHERE profile_id = ? AND revoked_at = ''`, + formatTime(now), profileID) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + var exists int + if err := tx.QueryRowContext(ctx, + `SELECT COUNT(1) FROM portal_profiles WHERE profile_id = ?`, profileID).Scan(&exists); err != nil { + return err + } + if exists == 0 { + return ErrProfileNotFound + } + } + if _, err := tx.ExecContext(ctx, `DELETE FROM portal_sessions WHERE profile_id = ?`, profileID); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM portal_snapshots WHERE profile_id = ?`, profileID); err != nil { + return err + } + return tx.Commit() +} + +// PublishSnapshot replaces the materialized availability for one profile. It +// is the only way availability data enters the portal database. +func (s *Store) PublishSnapshot(ctx context.Context, profileID string, snapshot Snapshot) error { + if err := validateSnapshot(snapshot); err != nil { + return err + } + payload, err := json.Marshal(snapshot.Windows) + if err != nil { + return err + } + nonce := make([]byte, s.aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return err + } + ciphertext := s.aead.Seal(nil, nonce, payload, snapshotAAD(profileID, snapshot.Version)) + + result, err := s.db.ExecContext(ctx, `INSERT INTO portal_snapshots + (profile_id, version, generated_at, horizon_end, status, nonce, ciphertext) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(profile_id) DO UPDATE SET + version = excluded.version, + generated_at = excluded.generated_at, + horizon_end = excluded.horizon_end, + status = excluded.status, + nonce = excluded.nonce, + ciphertext = excluded.ciphertext + WHERE excluded.version >= portal_snapshots.version`, + profileID, snapshot.Version, formatTime(snapshot.GeneratedAt), formatTime(snapshot.HorizonEnd), + snapshot.Status, nonce, ciphertext) + if err != nil { + return fmt.Errorf("publish portal snapshot: %w", err) + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + // An older version lost the race with a newer one. That is the + // intended outcome, not an error. + return nil + } + return nil +} + +func (s *Store) ReadSnapshot(ctx context.Context, profileID string) (Snapshot, error) { + row := s.db.QueryRowContext(ctx, `SELECT version, generated_at, horizon_end, status, nonce, ciphertext + FROM portal_snapshots WHERE profile_id = ?`, profileID) + var ( + snapshot Snapshot + generatedRaw, horizonRaw string + nonce, ciphertext []byte + ) + if err := row.Scan(&snapshot.Version, &generatedRaw, &horizonRaw, &snapshot.Status, &nonce, &ciphertext); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Snapshot{}, ErrNoSnapshot + } + return Snapshot{}, err + } + generatedAt, err := parseTime(generatedRaw) + if err != nil { + return Snapshot{}, err + } + horizonEnd, err := parseTime(horizonRaw) + if err != nil { + return Snapshot{}, err + } + plaintext, err := s.aead.Open(nil, nonce, ciphertext, snapshotAAD(profileID, snapshot.Version)) + if err != nil { + return Snapshot{}, fmt.Errorf("decrypt portal snapshot: %w", err) + } + var windows []Window + if err := json.Unmarshal(plaintext, &windows); err != nil { + return Snapshot{}, err + } + snapshot.GeneratedAt = generatedAt + snapshot.HorizonEnd = horizonEnd + snapshot.Windows = windows + return snapshot, nil +} + +func validateSnapshot(snapshot Snapshot) error { + switch snapshot.Status { + case StatusAvailable, StatusRefused, StatusInsufficientData: + default: + return fmt.Errorf("unsupported portal snapshot status %q", snapshot.Status) + } + if snapshot.Version <= 0 { + return errors.New("portal snapshot version must be positive") + } + if snapshot.GeneratedAt.IsZero() { + return errors.New("portal snapshot requires a generation time") + } + for _, window := range snapshot.Windows { + if !window.EndAt.After(window.StartAt) { + return errors.New("portal snapshot windows must be non-empty half-open intervals") + } + if window.ZoneID == "" { + return errors.New("portal snapshot windows require a zone id") + } + } + return nil +} + +// VerifyPasscode reports whether the supplied passcode matches. It always runs +// the KDF, including for links that cannot be used, so a caller cannot +// distinguish "no such link" from "wrong passcode" by timing alone. +func (s *Store) VerifyPasscode(ctx context.Context, profileID, passcode string) error { + row := s.db.QueryRowContext(ctx, + `SELECT passcode_hash, passcode_salt, passcode_time, passcode_memory, passcode_threads + FROM portal_profiles WHERE profile_id = ?`, profileID) + var ( + storedHash, salt []byte + timeCost, memoryCost, threads int64 + ) + if err := row.Scan(&storedHash, &salt, &timeCost, &memoryCost, &threads); err != nil { + if errors.Is(err, sql.ErrNoRows) { + // Burn comparable work before refusing so the absence of a + // profile is not measurably cheaper than a wrong passcode. + decoySalt := make([]byte, argonSaltLen) + argon2.IDKey([]byte(passcode), decoySalt, argonTime, argonMemory, argonThreads, argonKeyLen) + return ErrPasscodeRejected + } + return err + } + computed := argon2.IDKey([]byte(passcode), salt, uint32(timeCost), uint32(memoryCost), uint8(threads), uint32(len(storedHash))) + if subtle.ConstantTimeCompare(computed, storedHash) != 1 { + return ErrPasscodeRejected + } + return nil +} + +// SessionToken is the pair handed to a visitor after a successful passcode. +type SessionToken struct { + Session string + CSRF string + ExpiresAt time.Time +} + +// CreateSession issues a portal session bound to one profile. The session +// expires at the earlier of SessionLifetime and link expiry, so revoking or +// letting a link lapse cannot be outlived by a cookie. +func (s *Store) CreateSession(ctx context.Context, profile Profile, now time.Time) (SessionToken, error) { + sessionValue, err := randomToken() + if err != nil { + return SessionToken{}, err + } + csrfValue, err := randomToken() + if err != nil { + return SessionToken{}, err + } + expiresAt := now.Add(SessionLifetime) + if profile.ExpiresAt.Before(expiresAt) { + expiresAt = profile.ExpiresAt + } + sessionHash := hashToken(sessionValue) + csrfHash := hashToken(csrfValue) + if _, err := s.db.ExecContext(ctx, `INSERT INTO portal_sessions + (session_hash, profile_id, csrf_hash, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`, + sessionHash[:], profile.ID, csrfHash[:], formatTime(now), formatTime(expiresAt)); err != nil { + return SessionToken{}, fmt.Errorf("create portal session: %w", err) + } + return SessionToken{Session: sessionValue, CSRF: csrfValue, ExpiresAt: expiresAt}, nil +} + +// Session is a resolved, unexpired portal session. +type Session struct { + ProfileID string + CSRFHash []byte + ExpiresAt time.Time +} + +func (s *Store) ResolveSession(ctx context.Context, sessionValue string, now time.Time) (Session, error) { + if sessionValue == "" { + return Session{}, ErrSessionInvalid + } + sessionHash := hashToken(sessionValue) + row := s.db.QueryRowContext(ctx, + `SELECT profile_id, csrf_hash, expires_at FROM portal_sessions WHERE session_hash = ?`, sessionHash[:]) + var ( + session Session + expiresAtRaw string + ) + if err := row.Scan(&session.ProfileID, &session.CSRFHash, &expiresAtRaw); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Session{}, ErrSessionInvalid + } + return Session{}, err + } + expiresAt, err := parseTime(expiresAtRaw) + if err != nil { + return Session{}, err + } + if !expiresAt.After(now) { + return Session{}, ErrSessionInvalid + } + session.ExpiresAt = expiresAt + return session, nil +} + +// MatchesCSRF compares a presented synchronizer token against the session. +func (session Session) MatchesCSRF(value string) bool { + if value == "" { + return false + } + presented := hashToken(value) + return subtle.ConstantTimeCompare(presented[:], session.CSRFHash) == 1 +} + +func (s *Store) DeleteSession(ctx context.Context, sessionValue string) error { + sessionHash := hashToken(sessionValue) + _, err := s.db.ExecContext(ctx, `DELETE FROM portal_sessions WHERE session_hash = ?`, sessionHash[:]) + return err +} + +// AuditRetention bounds how long coarse access rows are kept. Retention that +// is only documented is not retention, so PurgeExpired enforces it and the +// daemon runs PurgeExpired on a timer. +const AuditRetention = 30 * 24 * time.Hour + +// PurgeExpired removes expired sessions, stale rate buckets, and access-audit +// rows past AuditRetention. It is safe to call concurrently with request +// handling. +func (s *Store) PurgeExpired(ctx context.Context, now time.Time) error { + if _, err := s.db.ExecContext(ctx, + `DELETE FROM portal_sessions WHERE expires_at <= ?`, formatTime(now)); err != nil { + return err + } + bucketCutoff := formatTime(now.Add(-24 * time.Hour)) + if _, err := s.db.ExecContext(ctx, + `DELETE FROM portal_rate_buckets WHERE window_start <= ? AND blocked_until <= ?`, + bucketCutoff, formatTime(now)); err != nil { + return err + } + if _, err := s.db.ExecContext(ctx, + `DELETE FROM portal_access_audit WHERE occurred_at <= ?`, + formatTime(now.Add(-AuditRetention))); err != nil { + return err + } + return nil +} + +func hashToken(value string) [32]byte { + return sha256.Sum256([]byte(value)) +} + +func randomToken() (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +// NewLinkToken mints a 256-bit link token. It is shown to the owner once and +// stored only as a hash. +func NewLinkToken() (string, error) { return randomToken() } + +// NewProfileID mints an opaque profile identifier. +func NewProfileID() (string, error) { + raw := make([]byte, 16) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +func snapshotAAD(profileID string, version int64) []byte { + return []byte(fmt.Sprintf("portal-snapshot\x00%s\x00%d", profileID, version)) +} + +func boolToInt(value bool) int { + if value { + return 1 + } + return 0 +} + +func formatTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339Nano) +} + +func parseTime(value string) (time.Time, error) { + if value == "" { + return time.Time{}, nil + } + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return time.Time{}, err + } + return parsed.UTC(), nil +} diff --git a/apps/server/internal/portal/view.go b/apps/server/internal/portal/view.go new file mode 100644 index 0000000..9bd7d3a --- /dev/null +++ b/apps/server/internal/portal/view.go @@ -0,0 +1,210 @@ +package portal + +import ( + "fmt" + "time" +) + +// Freshness thresholds from docs/portal-design.md section 1. Past StaleAfter +// the page says so; past UnavailableAfter it stops making a current-state +// claim entirely rather than presenting an old "awake now". +const ( + StaleAfter = 6 * time.Hour + UnavailableAfter = 24 * time.Hour +) + +// Qualifier is required on every availability view. The numbers come from the +// measured real-history backtest in ADR-0022 (median 1.71 h, P90 5.41 h), not +// from a marketing estimate. +const Qualifier = "This is an estimate from recent patterns. It is often off by about 2 hours, and sometimes more." + +// NoticeNotMedical keeps the public surface's purpose unambiguous. +const NoticeNotMedical = "This page shows scheduling availability only. It is not medical information and does not describe any health condition." + +// AvailabilityView is the rendered form of a Snapshot. Every string here is +// derived from the allowlisted snapshot; none of it reaches back into private +// data. +type AvailabilityView struct { + Headline string + Detail string + Freshness string + Qualifier string + Notice string + Windows []WindowView + LikelyAwake bool + Stale bool + Unavailable bool + ZoneLabel string +} + +type WindowView struct { + DayLabel string + RangeLabel string + Current bool +} + +// BuildView classifies a snapshot for display. It is the single place the +// stale and unavailable rules are implemented, so the no-script page and any +// later live layer cannot drift apart. +func BuildView(snapshot Snapshot, now time.Time) AvailabilityView { + view := AvailabilityView{ + Qualifier: Qualifier, + Notice: NoticeNotMedical, + } + now = now.UTC() + + if snapshot.GeneratedAt.IsZero() { + view.Unavailable = true + view.Headline = "Availability is not being shared right now" + view.Detail = "This link is working, but there is no availability to show yet." + view.Freshness = "No update has been published yet." + return view + } + + age := now.Sub(snapshot.GeneratedAt) + view.Freshness = describeFreshness(age) + + switch { + case snapshot.Status == StatusRefused: + view.Unavailable = true + view.Headline = "Availability cannot be estimated right now" + view.Detail = "There is not enough recent, consistent history to estimate a waking pattern. You can still reach out and agree on a time directly." + return view + case snapshot.Status == StatusInsufficientData || len(snapshot.Windows) == 0: + view.Unavailable = true + view.Headline = "Availability is not being shared right now" + view.Detail = "This link does not currently show waking windows." + return view + case age >= UnavailableAfter: + view.Unavailable = true + view.Headline = "This availability is out of date" + view.Detail = "The last update is more than a day old, so it is not shown. An out-of-date estimate is worse than none." + return view + } + + view.Stale = age >= StaleAfter + location := windowLocation(snapshot.Windows[0].ZoneID) + view.ZoneLabel = zoneLabel(snapshot.Windows[0].ZoneID, now.In(location)) + + upcoming := make([]WindowView, 0, len(snapshot.Windows)) + for _, window := range snapshot.Windows { + if !window.EndAt.After(now) { + continue + } + current := !window.StartAt.After(now) && window.EndAt.After(now) + if current { + view.LikelyAwake = true + } + upcoming = append(upcoming, WindowView{ + DayLabel: describeDay(window.StartAt, now, location), + RangeLabel: describeRange(window.StartAt, window.EndAt, location), + Current: current, + }) + } + view.Windows = upcoming + + switch { + case view.LikelyAwake: + view.Headline = "Likely awake right now" + view.Detail = "Based on recent patterns, this is probably a reachable time." + case len(upcoming) > 0: + view.Headline = "Likely not awake right now" + view.Detail = fmt.Sprintf("The next likely waking window starts %s.", lowerFirst(upcoming[0].DayLabel)+" at "+startOnly(snapshot.Windows, now, location)) + default: + view.Unavailable = true + view.Headline = "Availability is not being shared right now" + view.Detail = "There are no upcoming waking windows to show." + } + + if view.Stale { + view.Detail += " This estimate has not refreshed recently, so treat it with extra caution." + } + return view +} + +func windowLocation(zoneID string) *time.Location { + if zoneID == "" { + return time.UTC + } + location, err := time.LoadLocation(zoneID) + if err != nil { + return time.UTC + } + return location +} + +func zoneLabel(zoneID string, local time.Time) string { + abbreviation := local.Format("MST") + if zoneID == "" { + return abbreviation + } + return fmt.Sprintf("%s (%s)", abbreviation, zoneID) +} + +func startOnly(windows []Window, now time.Time, location *time.Location) string { + for _, window := range windows { + if window.EndAt.After(now) && window.StartAt.After(now) { + return formatClock(window.StartAt.In(location)) + } + } + return "an unknown time" +} + +func describeDay(value time.Time, now time.Time, location *time.Location) string { + local := value.In(location) + today := now.In(location) + switch daysBetween(today, local) { + case 0: + return "Today" + case 1: + return "Tomorrow" + default: + return local.Format("Mon, Jan 2") + } +} + +func daysBetween(from, to time.Time) int { + fromDay := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, from.Location()) + toDay := time.Date(to.Year(), to.Month(), to.Day(), 0, 0, 0, 0, to.Location()) + return int(toDay.Sub(fromDay).Hours() / 24) +} + +func describeRange(start, end time.Time, location *time.Location) string { + localStart := start.In(location) + localEnd := end.In(location) + if daysBetween(localStart, localEnd) == 0 { + return fmt.Sprintf("%s to %s", formatClock(localStart), formatClock(localEnd)) + } + return fmt.Sprintf("%s to %s on %s", formatClock(localStart), formatClock(localEnd), localEnd.Format("Mon, Jan 2")) +} + +func formatClock(value time.Time) string { + return value.Format("3:04 PM") +} + +func describeFreshness(age time.Duration) string { + switch { + case age < 0: + return "Updated just now." + case age < 2*time.Minute: + return "Updated just now." + case age < time.Hour: + return fmt.Sprintf("Updated %d minutes ago.", int(age.Minutes())) + case age < 2*time.Hour: + return "Updated about an hour ago." + case age < UnavailableAfter: + return fmt.Sprintf("Updated about %d hours ago.", int(age.Hours())) + default: + return "Last updated more than a day ago." + } +} + +func lowerFirst(value string) string { + if value == "" { + return value + } + if value[0] >= 'A' && value[0] <= 'Z' { + return string(value[0]+('a'-'A')) + value[1:] + } + return value +} diff --git a/apps/server/internal/portal/view_test.go b/apps/server/internal/portal/view_test.go new file mode 100644 index 0000000..fdc4120 --- /dev/null +++ b/apps/server/internal/portal/view_test.go @@ -0,0 +1,211 @@ +package portal + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + "time" +) + +func snapshotAt(generatedAt time.Time, windows ...Window) Snapshot { + return Snapshot{ + Version: generatedAt.UnixMilli(), + GeneratedAt: generatedAt, + Status: StatusAvailable, + Windows: windows, + } +} + +// TestFreshnessLadder walks the three age bands in design section 1: fresh, +// stale after six hours, and withheld entirely after a day. +func TestFreshnessLadder(t *testing.T) { + now := time.Date(2026, 8, 3, 15, 0, 0, 0, time.UTC) + window := Window{StartAt: now.Add(-time.Hour), EndAt: now.Add(4 * time.Hour), ZoneID: "UTC"} + + fresh := BuildView(snapshotAt(now.Add(-30*time.Minute), window), now) + if fresh.Stale || fresh.Unavailable { + t.Errorf("30-minute-old snapshot marked stale=%v unavailable=%v", fresh.Stale, fresh.Unavailable) + } + if !fresh.LikelyAwake { + t.Error("a window containing now did not produce a likely-awake state") + } + + stale := BuildView(snapshotAt(now.Add(-7*time.Hour), window), now) + if !stale.Stale { + t.Error("7-hour-old snapshot was not marked stale") + } + if stale.Unavailable { + t.Error("7-hour-old snapshot was withheld; it should show with a caution") + } + if !strings.Contains(stale.Detail, "not refreshed recently") { + t.Errorf("stale detail does not warn the reader: %q", stale.Detail) + } + + old := BuildView(snapshotAt(now.Add(-25*time.Hour), window), now) + if !old.Unavailable { + t.Error("25-hour-old snapshot was still presented as current") + } + if old.LikelyAwake { + t.Error("a day-old snapshot produced an 'awake now' claim") + } + if len(old.Windows) != 0 { + t.Error("a day-old snapshot still listed windows") + } +} + +// TestEveryViewCarriesTheQualifier enforces the honesty budget: no rendered +// state may omit the measured-uncertainty line. +func TestEveryViewCarriesTheQualifier(t *testing.T) { + now := time.Date(2026, 8, 3, 15, 0, 0, 0, time.UTC) + views := map[string]AvailabilityView{ + "empty": BuildView(Snapshot{}, now), + "refused": BuildView(Snapshot{Version: 1, GeneratedAt: now, Status: StatusRefused}, now), + "insufficent": BuildView(Snapshot{Version: 1, GeneratedAt: now, Status: StatusInsufficientData}, now), + "available": BuildView(snapshotAt(now, Window{ + StartAt: now.Add(time.Hour), EndAt: now.Add(5 * time.Hour), ZoneID: "UTC", + }), now), + "stale": BuildView(snapshotAt(now.Add(-8*time.Hour), Window{ + StartAt: now.Add(-time.Hour), EndAt: now.Add(2 * time.Hour), ZoneID: "UTC", + }), now), + } + for name, view := range views { + if view.Qualifier != Qualifier { + t.Errorf("%s view is missing the uncertainty qualifier", name) + } + if view.Notice != NoticeNotMedical { + t.Errorf("%s view is missing the not-medical notice", name) + } + if view.Headline == "" { + t.Errorf("%s view has no headline", name) + } + } +} + +// TestRefusalDoesNotLeakItsReason keeps a typed estimator refusal private: a +// visitor learns that no estimate is available, never why. +func TestRefusalDoesNotLeakItsReason(t *testing.T) { + now := time.Date(2026, 8, 3, 15, 0, 0, 0, time.UTC) + view := BuildView(Snapshot{Version: 1, GeneratedAt: now, Status: StatusRefused}, now) + lowered := strings.ToLower(view.Headline + " " + view.Detail) + for _, forbidden := range []string{"sleep", "episode", "record", "refus", "ambiguous", "cycle", "estimator"} { + if strings.Contains(lowered, forbidden) { + t.Errorf("refusal view mentions %q: %q", forbidden, view.Detail) + } + } +} + +// TestPastWindowsAreNotRendered stops the page from advertising availability +// that has already elapsed. +func TestPastWindowsAreNotRendered(t *testing.T) { + now := time.Date(2026, 8, 3, 15, 0, 0, 0, time.UTC) + view := BuildView(snapshotAt(now, + Window{StartAt: now.Add(-10 * time.Hour), EndAt: now.Add(-4 * time.Hour), ZoneID: "UTC"}, + Window{StartAt: now.Add(3 * time.Hour), EndAt: now.Add(9 * time.Hour), ZoneID: "UTC"}, + ), now) + if len(view.Windows) != 1 { + t.Fatalf("expected only the future window, got %d", len(view.Windows)) + } + if view.LikelyAwake { + t.Error("state says awake though no window contains now") + } + if !strings.Contains(view.Detail, "next likely waking window") { + t.Errorf("detail does not point at the next window: %q", view.Detail) + } +} + +// TestCivilTimeIsPrimary holds the cross-phase invariant that a human reads +// clock time, not an instant, and that the zone is stated rather than assumed. +func TestCivilTimeIsPrimary(t *testing.T) { + now := time.Date(2026, 8, 3, 15, 0, 0, 0, time.UTC) + view := BuildView(snapshotAt(now, Window{ + StartAt: now.Add(2 * time.Hour), + EndAt: now.Add(8 * time.Hour), + ZoneID: "America/New_York", + }), now) + if len(view.Windows) != 1 { + t.Fatalf("expected one window, got %d", len(view.Windows)) + } + rendered := view.Windows[0].RangeLabel + if !strings.Contains(rendered, "PM") && !strings.Contains(rendered, "AM") { + t.Errorf("window is not rendered in civil time: %q", rendered) + } + if strings.Contains(rendered, "Z") || strings.Contains(rendered, "T") { + t.Errorf("window leaks an RFC3339 instant into the visible label: %q", rendered) + } + if !strings.Contains(view.ZoneLabel, "America/New_York") { + t.Errorf("zone is not stated: %q", view.ZoneLabel) + } +} + +// TestJSONAppliesTheSameAgeRules stops a JSON consumer from presenting an +// out-of-date "awake now" the HTML page would have withheld. +func TestJSONAppliesTheSameAgeRules(t *testing.T) { + h := newHarness(t) + now := h.clock.Now() + h.publish(h.profile.ID, snapshotAt(now.Add(-25*time.Hour), Window{ + StartAt: now.Add(-time.Hour), EndAt: now.Add(3 * time.Hour), ZoneID: "UTC", + })) + cookie := h.sessionCookie(h.login(h.token, testPasscode, testOrigin)) + + recorder := h.get("/p/"+h.token+"/availability", cookie) + var decoded availabilityDTO + if err := json.Unmarshal(recorder.Body.Bytes(), &decoded); err != nil { + t.Fatalf("decode: %v", err) + } + if decoded.Status != StatusInsufficientData { + t.Errorf("status = %q, want %q for a day-old snapshot", decoded.Status, StatusInsufficientData) + } + if len(decoded.Windows) != 0 { + t.Errorf("day-old snapshot still returned %d windows", len(decoded.Windows)) + } +} + +// TestMissingSnapshotRendersHonestly covers a link created before the first +// materialization: it must say so rather than 404 or show an empty page. +func TestMissingSnapshotRendersHonestly(t *testing.T) { + h := newHarness(t) + cookie := h.sessionCookie(h.login(h.token, testPasscode, testOrigin)) + + page := h.get("/p/"+h.token, cookie) + if page.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", page.Code) + } + body := page.Body.String() + if !strings.Contains(body, "not being shared right now") { + t.Errorf("page does not explain the empty state: %s", body) + } + + availability := h.get("/p/"+h.token+"/availability", cookie) + var decoded availabilityDTO + if err := json.Unmarshal(availability.Body.Bytes(), &decoded); err != nil { + t.Fatalf("decode: %v", err) + } + if decoded.Status != StatusInsufficientData { + t.Errorf("status = %q, want %q", decoded.Status, StatusInsufficientData) + } +} + +// TestGrantWithoutWindowsShowsNothing proves the grant is enforced on read, +// not merely respected by the materializer. +func TestSnapshotVersionDoesNotRegress(t *testing.T) { + h := newHarness(t) + ctx := context.Background() + now := h.clock.Now() + + newer := snapshotAt(now, Window{StartAt: now.Add(time.Hour), EndAt: now.Add(5 * time.Hour), ZoneID: "UTC"}) + h.publish(h.profile.ID, newer) + + older := snapshotAt(now.Add(-2*time.Hour), Window{StartAt: now.Add(time.Hour), EndAt: now.Add(2 * time.Hour), ZoneID: "UTC"}) + if err := h.store.PublishSnapshot(ctx, h.profile.ID, older); err != nil { + t.Fatalf("publish older: %v", err) + } + stored, err := h.store.ReadSnapshot(ctx, h.profile.ID) + if err != nil { + t.Fatalf("read: %v", err) + } + if stored.Version != newer.Version { + t.Errorf("an out-of-order publish overwrote a newer snapshot (version %d, want %d)", stored.Version, newer.Version) + } +} diff --git a/apps/server/internal/portalbridge/leak_test.go b/apps/server/internal/portalbridge/leak_test.go new file mode 100644 index 0000000..cb884ee --- /dev/null +++ b/apps/server/internal/portalbridge/leak_test.go @@ -0,0 +1,427 @@ +package portalbridge_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "non24.app/server/internal/auth" + "non24.app/server/internal/portal" + "non24.app/server/internal/portalbridge" + "non24.app/server/internal/readmodel" + "non24.app/server/internal/store" + syncmodel "non24.app/server/internal/sync" +) + +const ( + testOrigin = "https://share.example.test" + testPasscode = "open-sesame-please" +) + +// canaries are distinctive values planted in every private field that could +// plausibly be carried along by a projection bug. Any appearance of one on the +// public surface, or anywhere in the portal database, is a leak. +// Record and observation identifiers must satisfy the sync identifier pattern +// (lowercase, hyphen or underscore), so the canaries are written in that shape +// rather than in shouting caps. +var canaries = map[string]string{ + "device label": "canary-device-a41f", + "observation id": "canary-observation-b72c", + "source record id": "canary-source-c93d", + "task title": "canary-task-title-d04e", + "share label": "canary-share-label-e15f", + "correction id": "canary-correction-f260", +} + +type leakFixture struct { + private *store.Store + portal *portal.Store + handler http.Handler + dbPath string + token string + now time.Time +} + +func newLeakFixture(t *testing.T) *leakFixture { + t.Helper() + ctx := context.Background() + dir := t.TempDir() + now := time.Date(2026, 8, 3, 15, 0, 0, 0, time.UTC) + + private, err := store.Open(filepath.Join(dir, "zeitboardd.db"), bytes.Repeat([]byte{7}, 32)) + if err != nil { + t.Fatalf("open private store: %v", err) + } + t.Cleanup(func() { _ = private.Close() }) + + if err := private.RegisterDevice(ctx, "dev_canary", canaries["device label"], auth.HashToken("token"), now.Add(-90*24*time.Hour)); err != nil { + t.Fatalf("register device: %v", err) + } + + // A rhythm the estimator can actually fit: twelve principal episodes + // drifting about 50 minutes per cycle. + records := make([]syncmodel.PushRecord, 0, 14) + base := now.Add(-14 * 24 * time.Hour) + for i := 0; i < 12; i++ { + start := base.Add(time.Duration(i) * (24*time.Hour + 50*time.Minute)) + id := fmt.Sprintf("obs-%02d", i) + if i == 0 { + id = canaries["observation id"] + } + records = append(records, syncmodel.PushRecord{ + RecordID: id, + Kind: syncmodel.KindObservation, + CreatedAt: now.Add(-time.Hour), + Payload: sleepObservationPayload(id, start, start.Add(8*time.Hour)), + }) + } + records = append(records, syncmodel.PushRecord{ + RecordID: canaries["correction id"], + Kind: syncmodel.KindCorrection, + CreatedAt: now.Add(-time.Hour), + Payload: json.RawMessage(fmt.Sprintf( + `{"correction_id":%q,"target_observation_id":%q,"created_at":%q,"reason":"user_edit","changes":{"start_at":%q}}`, + canaries["correction id"], canaries["observation id"], + base.Add(20*time.Minute).UTC().Format(time.RFC3339), + base.Add(10*time.Minute).UTC().Format(time.RFC3339))), + }) + // A task carries free user text, which is the most obviously private + // thing a projection bug could pick up. + records = append(records, syncmodel.PushRecord{ + RecordID: "canary-task-d04e_r1", + Kind: syncmodel.KindTask, + CreatedAt: now.Add(-time.Hour), + Payload: json.RawMessage(fmt.Sprintf( + `{"task_id":"canary-task-d04e","revision":1,"title":%q,"duration_minutes":30,"status":"open","created_at":%q,"updated_at":%q}`, + canaries["task title"], + now.Add(-2*time.Hour).UTC().Format(time.RFC3339), + now.Add(-2*time.Hour).UTC().Format(time.RFC3339))), + }) + + request := syncmodel.PushRequest{SchemaVersion: syncmodel.SchemaVersion, Records: records} + if err := syncmodel.ValidatePushRequest(&request); err != nil { + t.Fatalf("validate push: %v", err) + } + if _, _, err := private.Append(ctx, "dev_canary", request.Records); err != nil { + t.Fatalf("append records: %v", err) + } + + portalStore, err := portal.Open(filepath.Join(dir, "zeitboard-portal.db"), bytes.Repeat([]byte{7}, 32)) + if err != nil { + t.Fatalf("open portal store: %v", err) + } + t.Cleanup(func() { _ = portalStore.Close() }) + + profileID, _ := portal.NewProfileID() + token, _ := portal.NewLinkToken() + if err := portalStore.CreateProfile(ctx, portal.CreateProfileInput{ + ProfileID: profileID, + Token: token, + Passcode: testPasscode, + Grants: portal.Grants{WakingWindows: true}, + CreatedAt: now, + ExpiresAt: now.Add(30 * 24 * time.Hour), + }); err != nil { + t.Fatalf("create profile: %v", err) + } + if err := private.PutPortalLabel(ctx, profileID, canaries["share label"], now.Format(time.RFC3339Nano)); err != nil { + t.Fatalf("put label: %v", err) + } + + materializer := portalbridge.Materializer{ + Sleep: readmodel.SleepReader{Store: private}, + Profiles: portalStore, + Sink: portalStore, + Now: func() time.Time { return now }, + } + if err := materializer.MaterializeAll(ctx); err != nil { + t.Fatalf("materialize: %v", err) + } + snapshot, err := portalStore.ReadSnapshot(ctx, profileID) + if err != nil { + t.Fatalf("read snapshot: %v", err) + } + if snapshot.Status != portal.StatusAvailable || len(snapshot.Windows) == 0 { + t.Fatalf("fixture did not produce availability: status=%q windows=%d", snapshot.Status, len(snapshot.Windows)) + } + + handler, err := portal.NewHandler(portal.HandlerConfig{ + Store: portalStore, + Now: func() time.Time { return now }, + PublicOrigin: testOrigin, + ResolutionFloor: time.Nanosecond, + }) + if err != nil { + t.Fatalf("new handler: %v", err) + } + + return &leakFixture{ + private: private, + portal: portalStore, + handler: handler.Routes(), + dbPath: filepath.Join(dir, "zeitboard-portal.db"), + token: token, + now: now, + } +} + +func sleepObservationPayload(id string, start, end time.Time) json.RawMessage { + return json.RawMessage(fmt.Sprintf( + `{"observation_id":%q,"kind":"sleep_episode","start_at":%q,"end_at":%q,"zone_id":"America/New_York","sleep":{"classification":"principal"},"provenance":{"acquisition_method":"synthetic","evidence_status":"directly_observed","recorded_at":"2026-03-01T12:00:00Z","source_record_id":%q}}`, + id, + start.UTC().Format(time.RFC3339), + end.UTC().Format(time.RFC3339), + canaries["source record id"], + )) +} + +// TestNoCanaryReachesThePublicSurface is the acceptance test for the P5-a +// projection firewall. It walks every public response, including failures, +// and asserts that nothing planted in the private store appears. +func TestNoCanaryReachesThePublicSurface(t *testing.T) { + fixture := newLeakFixture(t) + + cookie := fixture.authenticate(t) + responses := map[string]*httptest.ResponseRecorder{ + "page": fixture.get(t, "/p/"+fixture.token, cookie), + "availability": fixture.get(t, "/p/"+fixture.token+"/availability", cookie), + "stylesheet": fixture.get(t, "/p/assets/portal.css", ""), + "unauth page": fixture.get(t, "/p/"+fixture.token, ""), + "unknown link": fixture.get(t, "/p/not-a-real-token", ""), + "unauth JSON": fixture.get(t, "/p/"+fixture.token+"/availability", ""), + "foreign login": fixture.post(t, "/p/"+fixture.token+"/session", "https://evil.example"), + } + + for name, recorder := range responses { + haystack := recorder.Body.String() + for _, values := range recorder.Header() { + haystack += "\n" + strings.Join(values, "\n") + } + for what, canary := range canaries { + if strings.Contains(haystack, canary) { + t.Errorf("%s response leaked the %s canary", name, what) + } + } + } +} + +// TestNoCanaryReachesThePortalDatabase is the stronger structural claim: even +// at rest, the public database must not contain private values. It reads the +// file bytes rather than querying, so an unexpected column would still fail. +func TestNoCanaryReachesThePortalDatabase(t *testing.T) { + fixture := newLeakFixture(t) + cookie := fixture.authenticate(t) + fixture.get(t, "/p/"+fixture.token, cookie) + fixture.get(t, "/p/"+fixture.token+"/availability", cookie) + + // Checkpoint the WAL so pages that are still in the write-ahead log are + // included in what we scan. + for _, suffix := range []string{"", "-wal"} { + path := fixture.dbPath + suffix + data, err := os.ReadFile(path) + if err != nil { + if suffix == "-wal" && os.IsNotExist(err) { + continue + } + t.Fatalf("read %s: %v", path, err) + } + for what, canary := range canaries { + if bytes.Contains(data, []byte(canary)) { + t.Errorf("portal database file %s contains the %s canary", filepath.Base(path), what) + } + } + } +} + +// TestPortalAuditNeverStoresVisitorOrLinkText checks the audit table holds +// only closed-enum events and pseudonymous sources. +func TestPortalAuditNeverStoresVisitorOrLinkText(t *testing.T) { + fixture := newLeakFixture(t) + cookie := fixture.authenticate(t) + fixture.get(t, "/p/"+fixture.token, cookie) + + summaries, err := fixture.portal.SummarizeAccess(context.Background(), fixture.profileID(t), time.Time{}) + if err != nil { + t.Fatalf("summarize: %v", err) + } + if len(summaries) == 0 { + t.Fatal("no audit rows were recorded") + } + allowed := map[portal.AccessEvent]bool{ + portal.EventPageView: true, portal.EventAvailabilityRead: true, + portal.EventPasscodeAccepted: true, portal.EventPasscodeRejected: true, + portal.EventThrottled: true, portal.EventLinkRejected: true, + } + for _, summary := range summaries { + if !allowed[summary.Event] { + t.Errorf("audit contains unexpected event %q", summary.Event) + } + if strings.Contains(string(summary.Event), fixture.token) { + t.Error("audit event text contains the link token") + } + } +} + +// TestMaterializerDropsEstimatorInternals asserts the narrowing directly on +// the value that crosses the boundary, independent of any HTTP handler. +func TestMaterializerDropsEstimatorInternals(t *testing.T) { + fixture := newLeakFixture(t) + snapshot, err := portalbridge.Materializer{ + Sleep: readmodel.SleepReader{Store: fixture.private}, + Now: func() time.Time { return fixture.now }, + }.Snapshot(context.Background()) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + if snapshot.Status != portal.StatusAvailable { + t.Fatalf("status = %q, want %q", snapshot.Status, portal.StatusAvailable) + } + if len(snapshot.Windows) == 0 { + t.Fatal("no windows were produced") + } + + encoded, err := json.Marshal(snapshot) + if err != nil { + t.Fatalf("marshal: %v", err) + } + for what, canary := range canaries { + if bytes.Contains(encoded, []byte(canary)) { + t.Errorf("snapshot carries the %s canary", what) + } + } + // Confidence is deliberately absent: ADR-0022 measured the buckets + // inverted, so a public label would misinform. + for _, forbidden := range []string{"confidence", "Confidence", "explanation", "estimateId", "high", "medium"} { + if bytes.Contains(encoded, []byte(forbidden)) { + t.Errorf("snapshot carries %q, which is not in the allowlist", forbidden) + } + } + for _, window := range snapshot.Windows { + if !window.EndAt.After(fixture.now) { + t.Error("snapshot contains an already-elapsed window") + } + if window.StartAt.Before(fixture.now) { + t.Error("snapshot window starts before now; in-progress windows must be clipped") + } + } +} + +// TestMaterializerRefusesRatherThanGuesses covers the empty-history case: the +// portal must report a refusal, not invent availability. +func TestMaterializerRefusesRatherThanGuesses(t *testing.T) { + dir := t.TempDir() + private, err := store.Open(filepath.Join(dir, "empty.db"), bytes.Repeat([]byte{3}, 32)) + if err != nil { + t.Fatalf("open: %v", err) + } + defer func() { _ = private.Close() }() + + snapshot, err := portalbridge.Materializer{ + Sleep: readmodel.SleepReader{Store: private}, + Now: func() time.Time { return time.Date(2026, 8, 3, 15, 0, 0, 0, time.UTC) }, + }.Snapshot(context.Background()) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + if snapshot.Status == portal.StatusAvailable { + t.Error("an empty history produced an availability claim") + } + if len(snapshot.Windows) != 0 { + t.Errorf("an empty history produced %d windows", len(snapshot.Windows)) + } +} + +// TestGrantWithoutWindowsPublishesNothing proves the grant gates what is +// materialized, not merely what is rendered. +func TestGrantWithoutWindowsPublishesNothing(t *testing.T) { + fixture := newLeakFixture(t) + ctx := context.Background() + profileID, _ := portal.NewProfileID() + token, _ := portal.NewLinkToken() + if err := fixture.portal.CreateProfile(ctx, portal.CreateProfileInput{ + ProfileID: profileID, + Token: token, + Passcode: testPasscode, + Grants: portal.Grants{WakingWindows: false}, + CreatedAt: fixture.now, + ExpiresAt: fixture.now.Add(24 * time.Hour), + }); err != nil { + t.Fatalf("create profile: %v", err) + } + materializer := portalbridge.Materializer{ + Sleep: readmodel.SleepReader{Store: fixture.private}, + Profiles: fixture.portal, + Sink: fixture.portal, + Now: func() time.Time { return fixture.now }, + } + if err := materializer.MaterializeAll(ctx); err != nil { + t.Fatalf("materialize: %v", err) + } + snapshot, err := fixture.portal.ReadSnapshot(ctx, profileID) + if err != nil { + t.Fatalf("read: %v", err) + } + if len(snapshot.Windows) != 0 { + t.Errorf("a profile without the windows grant received %d windows", len(snapshot.Windows)) + } + if snapshot.Status == portal.StatusAvailable { + t.Error("a profile without the windows grant was marked available") + } +} + +func (f *leakFixture) profileID(t *testing.T) string { + t.Helper() + profile, err := f.portal.ResolveLink(context.Background(), f.token, f.now) + if err != nil { + t.Fatalf("resolve link: %v", err) + } + return profile.ID +} + +func (f *leakFixture) get(t *testing.T, path, cookie string) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(http.MethodGet, path, nil) + request.RemoteAddr = "203.0.113.44:6000" + if cookie != "" { + request.AddCookie(&http.Cookie{Name: "__Host-zb_portal", Value: cookie}) + } + recorder := httptest.NewRecorder() + f.handler.ServeHTTP(recorder, request) + return recorder +} + +func (f *leakFixture) post(t *testing.T, path, origin string) *httptest.ResponseRecorder { + t.Helper() + form := url.Values{"passcode": {testPasscode}} + request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("Origin", origin) + request.RemoteAddr = "203.0.113.44:6000" + recorder := httptest.NewRecorder() + f.handler.ServeHTTP(recorder, request) + return recorder +} + +func (f *leakFixture) authenticate(t *testing.T) string { + t.Helper() + recorder := f.post(t, "/p/"+f.token+"/session", testOrigin) + if recorder.Code != http.StatusSeeOther { + t.Fatalf("login status = %d, want 303", recorder.Code) + } + for _, cookie := range recorder.Result().Cookies() { + if cookie.Name == "__Host-zb_portal" { + return cookie.Value + } + } + t.Fatal("no session cookie issued") + return "" +} diff --git a/apps/server/internal/portalbridge/materialize.go b/apps/server/internal/portalbridge/materialize.go new file mode 100644 index 0000000..cada6a2 --- /dev/null +++ b/apps/server/internal/portalbridge/materialize.go @@ -0,0 +1,164 @@ +// Package portalbridge is the owner-side half of the portal boundary. It is +// the only code that holds both a private read model and the portal store, and +// its single job is to narrow the former into the allowlisted snapshot defined +// in docs/portal-design.md section 5. +// +// The portal package itself never imports this one. That direction matters: a +// public handler cannot reach a sleep session by following an import. +package portalbridge + +import ( + "context" + "errors" + "fmt" + "time" + + "non24.app/core/domain" + "non24.app/core/estimation" + "non24.app/server/internal/portal" +) + +// SleepSource is the private read model, narrowed to the one call +// materialization needs. +type SleepSource interface { + EffectiveSleepSessions(ctx context.Context) ([]domain.SleepSession, error) +} + +// SnapshotSink is the portal store, narrowed to publication. Nothing here can +// read the portal database back, and nothing in the portal database can be +// used to reach a sleep session. +type SnapshotSink interface { + PublishSnapshot(ctx context.Context, profileID string, snapshot portal.Snapshot) error +} + +// ProfileSource lists the profiles that should hold a fresh snapshot. +type ProfileSource interface { + ListActiveProfiles(ctx context.Context, now time.Time) ([]portal.Profile, error) +} + +type Materializer struct { + Sleep SleepSource + Profiles ProfileSource + Sink SnapshotSink + Estimator estimation.RobustEstimator + Now func() time.Time +} + +func (m Materializer) now() time.Time { + if m.Now != nil { + return m.Now().UTC() + } + return time.Now().UTC() +} + +func (m Materializer) estimator() estimation.RobustEstimator { + if m.Estimator.Config.MinimumEpisodes == 0 { + return estimation.RobustEstimator{} + } + return m.Estimator +} + +// Snapshot computes the allowlisted projection without publishing it. It is +// separated from MaterializeAll so the boundary can be tested directly on the +// value that would cross it. +func (m Materializer) Snapshot(ctx context.Context) (portal.Snapshot, error) { + now := m.now() + snapshot := portal.Snapshot{ + Version: now.UnixMilli(), + GeneratedAt: now, + } + + sessions, err := m.Sleep.EffectiveSleepSessions(ctx) + if err != nil { + return portal.Snapshot{}, fmt.Errorf("read sleep sessions: %w", err) + } + + estimate, err := m.estimator().Estimate(ctx, sessions, now) + if err != nil { + var refusal *estimation.EstimationRefusal + if errors.As(err, &refusal) { + // A typed refusal is a legitimate outcome, not a failure. The + // refusal code and message stay private: the public surface + // learns only that no availability can be shown. + snapshot.Status = portal.StatusRefused + return snapshot, nil + } + return portal.Snapshot{}, fmt.Errorf("estimate availability: %w", err) + } + + windows := wakingWindows(estimate, now) + if len(windows) == 0 { + snapshot.Status = portal.StatusInsufficientData + return snapshot, nil + } + snapshot.Status = portal.StatusAvailable + snapshot.Windows = windows + snapshot.HorizonEnd = windows[len(windows)-1].EndAt + return snapshot, nil +} + +// wakingWindows narrows predicted waking availability to instants and a zone. +// Window IDs, estimate IDs, confidence levels, explanations, and every input +// session ID are dropped here; confidence in particular is withheld because +// ADR-0022 measured the buckets inverted (High 0.61 hit rate below Medium +// 0.81), so publishing a label would misinform a visitor. +func wakingWindows(estimate domain.PhaseEstimate, now time.Time) []portal.Window { + windows := make([]portal.Window, 0, len(estimate.PredictedWakingWindows)) + for _, window := range estimate.PredictedWakingWindows { + interval := window.Interval + if !interval.End.UTC.After(now) { + continue + } + start := interval.Start.UTC + if start.Before(now) { + // Clip a window already in progress so the public page never + // implies knowledge about a past that the visitor cannot use. + start = now + } + if !interval.End.UTC.After(start) { + continue + } + zoneID := interval.Start.ZoneID + if zoneID == "" { + zoneID = "UTC" + } + windows = append(windows, portal.Window{ + StartAt: start.UTC(), + EndAt: interval.End.UTC.UTC(), + ZoneID: zoneID, + }) + } + return windows +} + +// MaterializeAll refreshes every active profile. Callers invoke it after an +// accepted sync push, after an estimate-affecting edit or erasure, and on +// daemon start. +func (m Materializer) MaterializeAll(ctx context.Context) error { + now := m.now() + profiles, err := m.Profiles.ListActiveProfiles(ctx, now) + if err != nil { + return fmt.Errorf("list portal profiles: %w", err) + } + if len(profiles) == 0 { + return nil + } + snapshot, err := m.Snapshot(ctx) + if err != nil { + return err + } + for _, profile := range profiles { + published := snapshot + if !profile.Grants.WakingWindows { + // A link without the windows grant still gets a row so the page + // can render an honest "not shared" state rather than 404. + published.Windows = nil + published.HorizonEnd = time.Time{} + published.Status = portal.StatusInsufficientData + } + if err := m.Sink.PublishSnapshot(ctx, profile.ID, published); err != nil { + return fmt.Errorf("publish portal snapshot: %w", err) + } + } + return nil +} diff --git a/apps/server/internal/store/portal_labels.go b/apps/server/internal/store/portal_labels.go new file mode 100644 index 0000000..73c3f4c --- /dev/null +++ b/apps/server/internal/store/portal_labels.go @@ -0,0 +1,103 @@ +package store + +import ( + "context" + "crypto/rand" + "database/sql" + "errors" + "fmt" + "strings" +) + +// MaxPortalLabelLength bounds the owner's private note about a share link. +const MaxPortalLabelLength = 80 + +// PutPortalLabel stores the owner's private name for a share profile. The +// label is encrypted at rest and lives only in the private database: the +// portal store is given the opaque profile id and nothing else, so a compromise +// of the public surface cannot reveal who a link was shared with. +func (s *Store) PutPortalLabel(ctx context.Context, profileID, label, createdAt string) error { + profileID = strings.TrimSpace(profileID) + if profileID == "" { + return errors.New("portal profile id is required") + } + if len([]rune(label)) > MaxPortalLabelLength { + return fmt.Errorf("portal label must be at most %d characters", MaxPortalLabelLength) + } + nonce := make([]byte, s.aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return err + } + ciphertext := s.encrypt(nonce, []byte(label), portalLabelAAD(profileID, createdAt)) + _, err := s.db.ExecContext(ctx, `INSERT INTO portal_profile_labels + (profile_id, created_at, nonce, ciphertext) VALUES (?, ?, ?, ?) + ON CONFLICT(profile_id) DO UPDATE SET + created_at = excluded.created_at, + nonce = excluded.nonce, + ciphertext = excluded.ciphertext`, + profileID, createdAt, nonce, ciphertext) + if err != nil { + return fmt.Errorf("store portal label: %w", err) + } + return nil +} + +// PortalLabel returns the private label, or an empty string when none exists. +func (s *Store) PortalLabel(ctx context.Context, profileID string) (string, error) { + var ( + createdAt string + nonce, ciphertext []byte + ) + err := s.db.QueryRowContext(ctx, + `SELECT created_at, nonce, ciphertext FROM portal_profile_labels WHERE profile_id = ?`, profileID). + Scan(&createdAt, &nonce, &ciphertext) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", err + } + plaintext, err := s.decrypt(nonce, ciphertext, portalLabelAAD(profileID, createdAt)) + if err != nil { + return "", fmt.Errorf("decrypt portal label: %w", err) + } + return string(plaintext), nil +} + +// PortalLabels returns every stored label keyed by profile id. +func (s *Store) PortalLabels(ctx context.Context) (map[string]string, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT profile_id, created_at, nonce, ciphertext FROM portal_profile_labels`) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + labels := make(map[string]string) + for rows.Next() { + var ( + profileID, createdAt string + nonce, ciphertext []byte + ) + if err := rows.Scan(&profileID, &createdAt, &nonce, &ciphertext); err != nil { + return nil, err + } + plaintext, err := s.decrypt(nonce, ciphertext, portalLabelAAD(profileID, createdAt)) + if err != nil { + return nil, fmt.Errorf("decrypt portal label: %w", err) + } + labels[profileID] = string(plaintext) + } + return labels, rows.Err() +} + +// DeletePortalLabel erases the private label. Revoking a link removes portal +// state; this removes the owner-side name for it. +func (s *Store) DeletePortalLabel(ctx context.Context, profileID string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM portal_profile_labels WHERE profile_id = ?`, profileID) + return err +} + +func portalLabelAAD(profileID, createdAt string) []byte { + return []byte(strings.Join([]string{"portal-label", profileID, createdAt}, "\x00")) +} diff --git a/apps/server/internal/store/store.go b/apps/server/internal/store/store.go index 9bfe34c..1b20c9d 100644 --- a/apps/server/internal/store/store.go +++ b/apps/server/internal/store/store.go @@ -208,6 +208,15 @@ func (s *Store) Migrate(ctx context.Context) error { nonce BLOB NOT NULL, ciphertext BLOB NOT NULL )`, + // Share-link labels live here, in the private database, and never in + // the portal database. The portal only needs an opaque profile id; + // "Mum", "work", or a clinician's name is owner data. + `CREATE TABLE IF NOT EXISTS portal_profile_labels ( + profile_id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + nonce BLOB NOT NULL, + ciphertext BLOB NOT NULL + )`, } for _, statement := range statements { if _, err := s.db.ExecContext(ctx, statement); err != nil { diff --git a/docs/decisions/0029-availability-portal-foundation.md b/docs/decisions/0029-availability-portal-foundation.md new file mode 100644 index 0000000..92a9c32 --- /dev/null +++ b/docs/decisions/0029-availability-portal-foundation.md @@ -0,0 +1,171 @@ +# ADR 0029: Availability portal foundation (split store and projection firewall) + +- Status: accepted +- Date: 2026-07-31 +- Implements slice P5-a of [`portal-design.md`](../portal-design.md) and the + phase-5 goal in [`phase-goals.md`](../phase-goals.md). +- Extends [ADR-0016](0016-proposal-queue.md) (the queue a visitor request will + later join), [ADR-0017](0017-server-erasure-tombstones.md) (erasure reaches + portal state too), and [ADR-0022](0022-local-sleep-import-and-real-history-validation.md) + (the measured accuracy that bounds what may be claimed publicly). +- Supersedes nothing. Requests, messaging, and the live layer remain P5-b..P5-d. + +## Context + +Every surface built so far is reached by someone the user has already +authenticated: a device with an enrollment token, or a process on the user's +own machine. The portal is different in kind. It accepts traffic from people +the user has only shared a link with, against the same daemon that stores +sleep history and medication evidence. This is the largest threat-model change +in the project. + +The temptation is to add a handler to the existing API and filter the output. +That fails the wrong way: a filtering bug is a health-data disclosure, and the +filter is the only thing standing between an anonymous request and the private +store. The private store handle would be one field dereference away. + +## Decision + +1. **A separate portal database, and a package that cannot reach the private + one.** `internal/portal` owns `zeitboard-portal.db` and holds link hashes, + passcode hashes, materialized window snapshots, sessions, rate buckets, and + a coarse access audit. It contains no sleep record, medication row, task + detail, or private label. The package does not import `store`, + `readmodel`, `estimation`, `domain`, or `api`, and a test parses the + package's own imports to keep that true. A public handler therefore cannot + reach a sleep session by following an import — not merely "does not today". + +2. **The materializer is the only inbound path, and it lives outside the + portal package.** `internal/portalbridge` is the one place holding both a + private read model and the portal store. It narrows a `PhaseEstimate` to + `{version, windows[startAt,endAt,zoneId], generatedAt, horizonEnd, status}` + and nothing else. Window IDs, estimate IDs, explanations, input session + IDs, and confidence are dropped at that boundary. + +3. **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. Publishing a label that is anti-correlated with accuracy would + misinform a visitor making a scheduling decision, so the public surface + shows a window and a plain-language uncertainty statement instead. This is + a data-driven omission, not a UI simplification, and it stays until + calibration is fixed and reverified. + +4. **Freshness is part of the claim.** Every rendered state carries the + measured qualifier ("often off by about 2 hours, and sometimes more", + derived from the 1.71 h median / 5.41 h P90 backtest) and a plain-language + age. Past six hours the page says the estimate is stale; past twenty-four + it stops making a current-state claim at all, because an out-of-date "awake + now" is worse than no answer. `BuildView` implements those rules once, and + the JSON path applies the same age cut, so a future live layer cannot drift + from the no-script page. + +5. **Unknown, expired, and revoked links are indistinguishable by + construction.** `ResolveLink` returns a single `ErrLinkNotUsable` for all + three, so the handler is never told which occurred and cannot leak it. A + bounded timing floor is applied to resolution whether it succeeds or fails. + That is a floor, not a constant-time guarantee, and the design says so. + +6. **Every link requires a passcode, throttled rather than locked.** Argon2id + (t=3, 64 MiB, p=4) with per-row parameters, verified with a constant-time + compare, and a decoy KDF run when no profile matches so absence is not + measurably cheaper than a wrong passcode. Failures arm a per + profile-and-source exponential backoff capped at fifteen minutes. There is + deliberately no global lockout: an attacker could otherwise use it to deny + access to every legitimate visitor. + +7. **Source-based throttling runs before link resolution.** That ordering is + load-bearing rather than cosmetic — it is what stops an unauthenticated + caller from using a 64 MiB KDF as a memory-exhaustion lever. + +8. **Mutations are gated on browser attestation, primarily + `Sec-Fetch-Site`.** A passcode POST predates any session, so there is no + synchronizer token to check yet. Origin alone does not work: these + responses set `Referrer-Policy: no-referrer`, and per the Fetch standard + that makes a browser send `Origin: null` on a same-origin form submission. + Gating on Origin alone refused every real login — a defect the unit tests + could not see, because they set the header themselves, and which only + surfaced when the page was driven in an actual browser. Relaxing the + referrer policy to recover Origin would have put link tokens into `Referer` + headers, which is exactly what the design avoids. `Sec-Fetch-Site` is + unaffected by referrer policy and cannot be set by page script, so it is + the primary signal, with an exact Origin match as the fallback for clients + predating Fetch Metadata. A request carrying neither is refused. + +9. **Pseudonymous abuse data, and rotation that is real.** Source identifiers + are a keyed HMAC of a normalized address; the raw address is never stored. + IPv6 is collapsed to its /64 because a single host is routinely given a + whole /64 and could otherwise defeat per-source limits for free. Rotation + mints a new key, deletes audit rows past the retention window, and drops + the old key — rotation that kept the old key would be rotation in name + only. This is pseudonymity, not anonymity: NAT groups distinct people and a + changing network splits one person. `privacy.md` states that. + +10. **The audit table cannot hold free text.** Events come from a closed enum + and `RecordAccess` rejects anything else. There is no column for a URL, a + user agent, or visitor-supplied text, so link tokens and visitor content + cannot accumulate there by oversight. + +11. **Retention is enforced, not documented.** The daemon runs an hourly sweep + that expires sessions, drops stale rate buckets, and deletes access-audit + rows past a 30-day window. A retention policy that nothing executes is not + a retention policy. + +12. **Private labels stay private.** The owner's name for a link ("Mum", a + clinician) is encrypted in the *private* database. The portal receives an + opaque profile ID. A full compromise of the public surface reveals that a + link exists, not who it was shared with. + +13. **Off by default, and absent rather than refusing.** `portal.enabled` + defaults false. When it is false the daemon never constructs a portal + handler, never opens the portal database, and never registers the owner's + sharing routes — there is no `/p/` path to probe. An enabled portal + without an exact `publicOrigin` fails to start rather than serving with + its CSRF check disabled. + +14. **Revocation deletes, it does not merely flag.** Revoking drops every + session for the profile immediately and deletes the materialized snapshot, + so an already-authenticated visitor loses access at once and no + availability data survives in the public database. + +## Consequences + +- The estimator runs on the owner's side only. Public reads serve a + materialized row, so visitor traffic cannot drive estimator cost. +- Cross-database writes cannot be atomic. P5-b's request path needs the + transactional outbox described in design section 2; this slice avoids the + problem by making the flow one-directional. +- Materialization is currently synchronous inside sync push and erase. That is + acceptable at the present snapshot cost and keeps "the page reflects what + the user just changed" true, but it is the first thing to move to a + background worker if push latency becomes visible. +- The portal database is encrypted with a key derived one-way from the daemon + data key. Reading the portal database never yields the private key, but the + two are not independently rotatable. Separating them is future work. +- Sharing a live projection is inherently observable: a recipient who watches + the link sees the user's rhythm shift over time. That cannot be engineered + away, so it is disclosed in the owner's create-link response and must be + shown before a link is created. + +## Residual risk + +- A recipient can screenshot or remember what they saw. Revocation cannot + reach that, and the disclosure says so. +- Availability windows are health-adjacent by nature. The portal never names a + disorder, medication, observation, or treatment, but a person's waking + pattern is still personal information, which is why sharing it is opt-in, + passcode-gated, expiring, and revocable. +- The timing floor bounds enumeration signal rather than eliminating it. +- No independent review has run against this surface yet. Exposure-gate item 6 + in `portal-design.md` remains open, and public exposure stays prohibited + until it and the rest of the gate pass. + +## Verification + +`docs/verification.md` records the measured results: the canary suite (private +values planted in device labels, observation IDs, source record IDs, task +titles, correction IDs, and the share label; asserted absent from every public +response, every response header, and the portal database file bytes including +the write-ahead log), the recursive DTO allowlist, the indistinguishable-failure +check, the freshness ladder, the Fetch-Metadata matrix, the throttle and +backoff behaviour, and the routes-absent-when-disabled checks at both the +daemon mux and the owner API. diff --git a/docs/install-update-design.md b/docs/install-update-design.md index 1bb07b4..5602ec9 100644 --- a/docs/install-update-design.md +++ b/docs/install-update-design.md @@ -211,7 +211,11 @@ Firewall reconciliation is a post-service commit step. If that later step fails, the script exits unsuccessfully but leaves the verified running service in place and tells the operator to rerun the same command. -No public availability portal route is implemented or enabled by this tooling. +The tooling never enables the availability portal. `portal.enabled` defaults to +false, the installer writes no portal keys into the generated config, and a +daemon started by this tooling therefore serves no `/p/` route. Enabling it is +a deliberate operator edit, gated by the exposure checklist in +`docs/portal-design.md` section 12. ## 8. Uninstall and erasure diff --git a/docs/phase-goals.md b/docs/phase-goals.md index b4ee8c5..b24c00d 100644 --- a/docs/phase-goals.md +++ b/docs/phase-goals.md @@ -58,8 +58,16 @@ through `ask_zeitboard_facts`, and the medical refusal now lives in shared `core/agentpolicy` so chat, backend MCP, and the local endpoint cannot drift apart. Cloud skill packaging remains separately gated. -**Gap 5 — the portal does not exist, and it is the largest threat-model -change in the project's history.** Today's trusted-web prototype is static +**Gap 5 — partially closed 2026-07-31.** ADR-0029 delivers P5-a: the split +store, the projection firewall, the security middleware, and a read-only +availability page, all behind a default-false `portal.enabled`. What remains is +the interactive half — visitor time requests reaching the proposal queue +(P5-b), threads (P5-c), and the live layer (P5-d) — plus the exposure gate, +which no amount of implementation satisfies on its own because item 6 requires +an independent review. The original framing below still describes why this is +the largest threat-model change in the project's history. + +**Gap 5 (original framing).** Today's trusted-web prototype is static synthetic HTML; `future-relay-design.md` was written for exactly this successor. New requirements beyond the old design: *interactive* ask ("when will they be awake?"), *time requests* that land in the approval @@ -196,6 +204,15 @@ as stated. ### `/goal phase-5-availability-portal` +Status: **P5-a delivered 2026-07-31 via [ADR-0029](decisions/0029-availability-portal-foundation.md)** +— separate portal store with an import-enforced boundary, allowlisted +materializer, security middleware, passcode gate, read-only availability page, +and owner link CRUD, with the portal disabled by default. P5-b (visitor +requests via the transactional outbox into the ADR-0016 queue), P5-c +(messaging), and P5-d (SSE/live layer, audit UI, red-team pass) remain. The +acceptance line "a request round-trips to an in-app decision" belongs to P5-b +and is not yet met. + Implementation-ready design: [`portal-design.md`](portal-design.md) (projection firewall, hashed link tokens with uniform 410s, origin- `visitor` proposals, requester secrets, messaging caps, threat-model v2 diff --git a/docs/portal-design.md b/docs/portal-design.md index c1f37a1..04a7f7c 100644 --- a/docs/portal-design.md +++ b/docs/portal-design.md @@ -1,9 +1,10 @@ # Availability portal design (phase 5) -> Security and product contract for a future public-facing portal. Nothing in -> this document is implemented or exposed yet. `portal.enabled` must default to -> false, and the exposure gate in section 12 must pass before any public bind. -> The portal is scheduling support, not medical advice, and never names a +> Security and product contract for the public-facing portal. Slice P5-a is +> implemented as of 2026-07-31 ([ADR-0029](decisions/0029-availability-portal-foundation.md)); +> P5-b through P5-d are not. Nothing here is exposed: `portal.enabled` defaults +> to false, and the exposure gate in section 12 must pass before any public +> bind. The portal is scheduling support, not medical advice, and never names a > disorder, medication, sleep observation, or treatment. ## 1. Honest public claims @@ -110,11 +111,25 @@ the daemon's normal 30-second response write timeout must not silently kill the stream. Each event contains only projection version and freshness, prompting a normal authenticated DTO refresh. -Mutation requests require an exact allowed `Origin` plus a synchronizer CSRF -token. The authenticated cookie is named `__Host-zb_portal`, is opaque and -random, and has `Secure`, `HttpOnly`, `SameSite=Strict`, `Path=/`, and no Domain -attribute. It expires at the earlier of 24 hours or link expiry and rotates -after passcode authentication. +Mutation requests must be attested by the browser as same-origin, and once a +session exists they additionally carry a synchronizer CSRF token. + +The attestation is primarily `Sec-Fetch-Site: same-origin`, not `Origin`. +Because these responses set `Referrer-Policy: no-referrer`, the Fetch standard +requires a browser to send `Origin: null` on a same-origin form submission, so +an Origin-only gate refuses every real passcode POST. This was found by driving +the page in a browser; unit tests that set the header themselves cannot see it. +Relaxing the referrer policy to recover `Origin` is rejected, because it would +put link tokens into `Referer` headers that section 9 specifically avoids. +`Sec-Fetch-Site` is unaffected by referrer policy and cannot be set by page +script. An exact `Origin` match remains the fallback for clients predating +Fetch Metadata; a request carrying neither header is refused, and a present but +mismatched `Origin` is refused regardless of what `Sec-Fetch-Site` claims. + +The authenticated cookie is named `__Host-zb_portal`, is opaque and random, and +has `Secure`, `HttpOnly`, `SameSite=Strict`, `Path=/`, and no Domain attribute. +It expires at the earlier of 24 hours or link expiry and rotates after passcode +authentication. ## 4. Share profiles and links @@ -236,7 +251,12 @@ audit does not retain message bodies. ## 8. Abuse resistance and privacy-preserving audit -Persisted limits survive restarts: +Persisted limits survive restarts. P5-a implements the read limit, the passcode +backoff, and the body cap; the SSE, request-creation, and message limits arrive +with the slices that introduce those operations. The delivered read limit is +keyed on source alone — a per-session limit adds little while the only +unauthenticated surface is enumeration, and it is added in P5-b when sessions +begin carrying write authority. - page/availability reads: 120 per hour per authenticated session and source; - SSE: 2 streams per session, 20 per profile, with excess clients polling; @@ -295,7 +315,7 @@ only one can commit. | Slice | Scope | Acceptance spine | |---|---|---| -| P5-a | Separate portal store, security middleware, profile/link CRUD, allowlisted materializer, read-only public page | Canary leak test; exact DTO schema; stale/unavailable behavior; uniform generic failures; portal disabled by default | +| P5-a **(delivered 2026-07-31, ADR-0029)** | Separate portal store, security middleware, profile/link CRUD, allowlisted materializer, read-only public page | Canary leak test; exact DTO schema; stale/unavailable behavior; uniform generic failures; portal disabled by default | | P5-b | Request validation, requester-secret exchange, outbox bridge, visitor proposals, exact-slot approval | Queued/pending behavior under bridge failure; idempotent retry; one-use decision race; approved slot is inside request; no private-store reads from public package | | P5-c | Encrypted threads, owner replies, caps, close and hard-delete jobs | Cross-request authorization tests; content never reaches projection/logs; 14-day deletion with clock-controlled tests | | P5-d | SSE/polling, rate persistence, audit UI, threat/privacy/runbook integration, reverse-proxy profile | Connection bounds; CSRF/header suite; log-token redaction; restart persistence; external red-team pass | @@ -304,6 +324,11 @@ Phase 6 consumes only internal `request_created`, `request_decided`, and `message_added` events. Notification transports do not receive portal-store or health-store access. +P5-a deliberately leaves the store one-directional: nothing flows from the +portal database back into the private one. The transactional outbox in section +2 is required by P5-b, when a visitor request first needs to reach the owner's +proposal queue, and is not implemented yet. + ## 12. Exposure gate Public exposure is prohibited until all of the following are true: diff --git a/docs/privacy.md b/docs/privacy.md index 22f8fa3..dbf2e2c 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -154,6 +154,48 @@ tokens. Rhythm markers are not a grantable field and the strict trusted-view schema rejects them. The phase-one trusted website is static, synthetic, and makes no network request. +### Availability portal (implemented, off by default, not exposed) + +The portal is off by default and is not exposed. When an operator turns it on +and the user creates a link, this is what a recipient can and cannot see. + +A recipient with the link **and** its passcode sees broad windows when the user +is likely awake, when that estimate was last refreshed, and how far ahead it +runs. They see nothing else: no sleep record, no medication, no task, no +calendar text, no note, no marker, and no confidence label. Confidence is +withheld on purpose — ADR-0022 measured the buckets inverted on real history, +so a published label would mislead. Every view carries the measured uncertainty +in plain language, and an estimate more than a day old is withheld rather than +shown. + +Windows are rendered in the user's own time zone, which the page states. That +discloses the user's zone to anyone holding the link; it is unavoidable if the +times are to mean anything. + +Sharing a live projection is observable over time: a recipient who checks +repeatedly can watch the user's rhythm drift. That is inherent to the feature, +is disclosed when a link is created, and is a reason to share deliberately. + +The portal keeps its own database. It holds hashed link tokens, hashed +passcodes, the materialized windows, sessions, rate-limit counters, and a +coarse access log. It holds no health data, and public request handlers have no +route to the private database at all. The user's own name for a link — "Mum", a +clinician — is stored encrypted in the *private* database and never reaches the +portal. + +Abuse limits need to tell visitors apart, so the portal stores a keyed hash of +a normalized network address and discards the address itself. This is +pseudonymous, not anonymous: several people behind one router share an +identifier, and one person moving between networks gets several. Rotating the +key makes old identifiers unlinkable and deletes access rows past the retention +window. The Sharing screen shows counts and a last-access time, not a browsing +trail. + +Links expire (90 days at most), can be revoked at any moment, and revocation is +immediate: existing sessions stop working and the shared windows are deleted +from the portal database. What a recipient already saw, screenshotted, or +remembers cannot be recalled. + ## Agent connectors There are two, and neither can approve or apply anything. diff --git a/docs/roadmap.md b/docs/roadmap.md index 5ef2b43..25842e4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -197,7 +197,8 @@ M-E's separately reviewed local agent projection. path is documented in the self-hosting runbook. Cloud skill packaging and any reviewer-gated auto-apply stay future and separately gated. *Desktop-local agent endpoint delivered (ADR-0028):* the app serves its own - loopback MCP (token + `Origin` rejection + `0600` descriptor), exposing + loopback MCP (token + `Origin` rejection + an owner-only, inheritance- + disabled descriptor ACL), exposing allowlisted read projections, `set_appearance` as the ADR-0021 direct display action, and propose-only mutations with **no approve/apply tool**, so a voice client works with the backend off. The medical refusal moved to @@ -206,6 +207,20 @@ M-E's separately reviewed local agent projection. 9. **Android companion sync.** The Health Connect skeleton exists but the companion has no sync client; bring it onto the same enrollment + push/pull path (its ADR should reuse ADR-0015's model). +9b. **Availability portal.** *P5-a delivered (ADR-0029):* a separate portal + database that the public package cannot reach by import, an owner-side + materializer narrowing the estimate to windows plus freshness, the public + security middleware (CSP, per-source throttling, indistinguishable link + failures, argon2id passcodes with per profile-and-source backoff, + `Sec-Fetch-Site` mutation attestation), a no-JavaScript availability page + that withholds a day-old estimate rather than showing it, and owner + create/list/revoke/erase. Confidence labels are withheld because ADR-0022 + measured the buckets inverted. `portal.enabled` defaults false and no + `/p/` route exists when it is off. **Next:** P5-b visitor time requests + through the transactional outbox into the existing proposal queue, then + P5-c threads and P5-d the live layer. Public exposure stays prohibited + until the [`portal-design.md`](portal-design.md) §12 gate passes, + including an independent review. 10. **UI refactor + theme manager** (tracked in [`ui-refactor-plan.md`](ui-refactor-plan.md)). *U-A..U-C delivered:* the structural follow-up replaced Overview's metric-card grid with one diff --git a/docs/self-hosting.md b/docs/self-hosting.md index b537dc4..7e063c8 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -32,6 +32,10 @@ variables override file values. "model": "", "apiKeyFile": "", "endpoint": "" + }, + "portal": { + "enabled": false, + "publicOrigin": "" } } ``` @@ -53,6 +57,8 @@ Keys: | `assistant.apiKeyFile` | `ZEITBOARD_LLM_API_KEY_FILE` | Required when provider key env is absent | File containing the provider API key. | | n/a | `ZEITBOARD_LLM_API_KEY` | Required when a provider is enabled and no key file is set | Provider API key; never returned by status APIs. | | `assistant.endpoint` | `ZEITBOARD_LLM_ENDPOINT` | Required for `opencode_zen`, optional override otherwise | Plain HTTPS endpoint for the provider transport. | +| `portal.enabled` | `ZEITBOARD_PORTAL_ENABLED` | No | Defaults to `false`. Read the availability-portal section below before changing it. | +| `portal.publicOrigin` | `ZEITBOARD_PORTAL_ORIGIN` | Required when the portal is enabled | Exact `scheme://host[:port]` visitors reach, e.g. `https://share.example.com`. Scheme and host only. | Relative file and directory paths in a JSON config resolve from the directory containing that config file. Relative paths supplied through environment @@ -189,6 +195,48 @@ Zen, the daemon sends only the assistant's minimized redacted context to that pr using the operator's key. Provider credentials are not returned by `/v1/status`, are not placed in model context, and are not written to fixtures. +## Availability Portal + +**Do not enable this yet.** `portal.enabled` defaults to `false`, and public +exposure is prohibited until every item in section 12 of +[`portal-design.md`](portal-design.md) passes — including an independent +security review, which has not happened. This section documents what exists so +an operator can evaluate it, not a green light to publish. + +What slice P5-a implements ([ADR-0029](decisions/0029-availability-portal-foundation.md)): +share links that show broad likely-awake windows to someone holding the link +and its passcode. Visitor time requests, messaging, and the live-updating +dashboard are not implemented. + +When the portal is disabled the daemon never opens the portal database, never +constructs a public handler, and never registers the owner's sharing routes. +There is no `/p/` path to probe. + +When enabled: + +- A second database, `zeitboard-portal.db`, appears in the data directory. It + holds hashed link tokens, hashed passcodes, the materialized windows, + sessions, rate buckets, and a coarse access log — no health data. **Back it + up with the same care as the main database**, and note it is encrypted with a + key derived from the same `dataKeyFile`: losing that key loses both. +- `publicOrigin` must be the exact origin visitors reach. It is compared + byte-for-byte against browser attestation on every state-changing request, so + a mismatch between it and the reverse proxy's public URL breaks logins. + Outside loopback it must be `https`. +- Terminate TLS at the edge and set HSTS there. The daemon serves the portal on + the same listener as the device API. +- **Disable raw request-URI logging for `/p/`** in the reverse proxy. Link + tokens live in the path; a proxy access log is otherwise a file full of + working share links. The daemon itself does not log request paths. +- Public responses set `Cache-Control: no-store`. Do not add caching for `/p/` + in the proxy. + +Links are created from the app, not from a config file. Each one requires a +passcode of at least six characters, expires within 90 days, is displayed +exactly once, and can be revoked at any time. Revocation is immediate: existing +sessions stop working and the shared windows are deleted from the portal +database. + ## Network And Telemetry The project has no telemetry path. The daemon listens on the TLS address configured by diff --git a/docs/threat-model.md b/docs/threat-model.md index 4da09a4..afc4f64 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -47,6 +47,16 @@ availability. same-machine boundary crossed with a bearer token, guarded by a loopback bind and per-request check, rejection of any request carrying an `Origin` header, and an owner-restricted descriptor (ADR-0028). +13. Link recipient ↔ **availability portal** on the self-hosted instance: the + only boundary in the system crossed by someone the user has not enrolled. + Its defining property is a *split store* — public handlers are constructed + with a separate portal database and the portal package cannot import the + private store, so the boundary is enforced by the dependency graph rather + than by output filtering (ADR-0029). Off by default; not exposed. +14. Private read model → **allowlisted portal snapshot**: the one inbound path + to the portal database, owned by `portalbridge`, narrowing an estimate to + windows, a generation time, a horizon, and a status. Confidence labels are + withheld because ADR-0022 measured them inverted. ## Adversaries @@ -167,6 +177,18 @@ such a process could obtain is the allowlisted projection surface (including med timing facts and context markers) plus reversible appearance changes; it still cannot approve or apply a schedule change, because no tool on any surface can. +The availability portal adds residuals of its own, all of them accepted rather than +solved. A recipient can screenshot or simply remember a window, which revocation cannot +reach; the create-link response states this. Sharing a live projection is inherently +observable — someone watching the link over time sees the user's rhythm shift, which is a +consequence of the feature and not a defect. The timing floor on link resolution bounds +enumeration signal without eliminating it. Source identifiers are pseudonymous, not +anonymous. The portal database is encrypted with a key derived one-way from the daemon +data key, so reading it never yields the private key, but the two cannot be rotated +independently. No independent review has run against this surface, so exposure remains +prohibited: `portal.enabled` is false by default and the section 12 exposure gate in +`portal-design.md` is not yet satisfied. + ## Security verification - Sync: assert authentication is required, transport is TLS, and replayed messages are @@ -182,6 +204,18 @@ approve or apply a schedule change, because no tool on any surface can. - LLM context: assert outbound context is minimized and redacted (no forbidden fields). - Projection: test output against a forbidden-field list across all permission combinations; revoked and expired profiles return no view. +- Availability portal: plant canary values in private device labels, observation IDs, + source record IDs, task titles, correction IDs, and the share label, then assert none + appears in any public response body, any response header, or the portal database file + bytes including its write-ahead log; assert the availability JSON carries exactly the + five allowlisted keys; assert unknown, expired, and revoked links produce byte-identical + responses; assert an already-authenticated session dies the moment its link is revoked; + assert a session for one link grants nothing on another; assert mutations require + same-origin attestation and that a request with neither `Sec-Fetch-Site` nor a matching + `Origin` is refused; assert the read limit and the per profile-and-source passcode + backoff persist; assert the portal package does not import the private store, read + model, estimator, or domain packages; assert no `/p/` route and no owner sharing route + exists when the portal is disabled. - Import: unit-test size/shape limits, row accounting, duplicate/conflict handling, transaction atomicity, DST ambiguity, recurrence work caps, CalDAV redirect/credential boundaries, ownership-preserving calendar export, diff --git a/docs/verification.md b/docs/verification.md index a8a5416..164e322 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -324,6 +324,114 @@ behavior but does not seed real Health Connect provider records. Paging, source-offset, duplicate-revision, permission-loss, DST gap/overlap, and last-good-snapshot behavior are covered by adapter/repository tests. +## Availability portal foundation (ADR-0029) + +Verified 2026-07-31 for slice P5-a. The portal is not exposed: every check +below runs against a loopback test server or an in-process handler. + +**Projection firewall.** The canary suite plants distinctive values in six +private fields — device label, observation ID, source record ID, task title, +correction ID, and the owner's private share label — then drives the portal +with real materialized data derived from twelve principal sleep episodes. No +canary appears in any public response body, in any response header, or in the +portal database file bytes, including the write-ahead log. The database check +reads raw file bytes rather than querying, so an unexpected column would still +fail it. The materializer's output is asserted separately: it carries no canary +and no `confidence`, `explanation`, or `estimateId` field. + +**Structural boundary.** A test parses the `portal` package's own imports and +fails if it ever imports `store`, `readmodel`, `assistant`, `api`, +`portalbridge`, `provider`, `estimation`, or `domain`. The boundary is +therefore enforced by the dependency graph, not by review. + +**Public DTO.** The availability response carries exactly `version`, `windows`, +`generatedAt`, `horizonEnd`, `status`, and each window exactly `startAt`, +`endAt`, `zoneId`. Both the presence of every required key and the absence of +any other are asserted. + +**Honesty budget.** Every rendered state — empty, refused, insufficient, +available, and stale — carries the measured uncertainty qualifier and the +not-medical notice. A refusal's rendered text is asserted free of "sleep", +"episode", "record", "refus", "ambiguous", "cycle", and "estimator", so a typed +estimator refusal never reaches a visitor as a reason. Windows render in civil +time with the zone stated, and no RFC3339 instant appears in a visible label. +The freshness ladder is exercised at 30 minutes (fresh), 7 hours (stale, shown +with a caution), and 25 hours (withheld, no current-state claim, no windows). +The JSON path applies the same 24-hour cut, so a JSON consumer cannot present +an "awake now" the page would have withheld. + +**Enumeration and revocation.** Unknown, expired, and revoked links produce +byte-identical 410 responses. Revoking a profile ends existing sessions +immediately and deletes the materialized snapshot. A session issued for one +link returns 401 on another. + +**Passcode and CSRF.** A wrong passcode returns 401 and arms an exponential +per profile-and-source backoff; the immediately following attempt is throttled +with `Retry-After` even when the passcode is correct, and succeeds once the +backoff elapses. There is no global lockout. The Fetch-Metadata matrix covers +nine cases: `Sec-Fetch-Site: same-origin` succeeds with no Origin, with a +matching Origin, and with `Origin: null` — the shape a real browser sends under +`Referrer-Policy: no-referrer`; `cross-site`, `same-site`, and `none` are +refused even with a spoofed matching Origin; a matching Origin alone succeeds +as the pre-Fetch-Metadata fallback; and neither header, or a mismatched Origin +alongside same-origin metadata, is refused. + +**Headers and page content.** Every response — page, availability, stylesheet, +and generic failure — carries `default-src 'none'`, `frame-ancestors 'none'`, +`style-src 'self'`, `script-src 'self'`, `connect-src 'self'`, `img-src 'self'`, +`Cache-Control: no-store, max-age=0`, `Referrer-Policy: no-referrer`, +`nosniff`, and a noindex robots tag. The rendered page is asserted to contain +no ` Date: Fri, 31 Jul 2026 01:14:20 -0400 Subject: [PATCH 2/2] Portal: surface unreadable snapshots, fix cookie MaxAge clock mixing 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 --- apps/server/internal/portal/handlers.go | 20 +++++++++++++++++--- apps/server/internal/portal/http.go | 15 ++------------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/apps/server/internal/portal/handlers.go b/apps/server/internal/portal/handlers.go index 472c63a..6004093 100644 --- a/apps/server/internal/portal/handlers.go +++ b/apps/server/internal/portal/handlers.go @@ -4,7 +4,9 @@ import ( "bytes" "embed" "encoding/json" + "errors" "html/template" + "log" "net/http" "net/url" "strings" @@ -49,8 +51,14 @@ func (h *Handler) handlePage(w http.ResponseWriter, r *http.Request) { snapshot, err := h.store.ReadSnapshot(ctx, profile.ID) if err != nil { - // A missing snapshot is a legitimate state, not an error: the link - // exists but nothing has been materialized for it yet. + // A missing snapshot is a legitimate state: the link exists but + // nothing has been materialized for it yet. Anything else — a decrypt + // failure, a corrupt row — renders the same empty page to the visitor + // but must not vanish silently, because only the operator can act on + // it. The profile id is opaque; the link token is never logged. + if !errors.Is(err, ErrNoSnapshot) { + log.Printf("portal: snapshot unreadable for profile %s: %v", profile.ID, err) + } snapshot = Snapshot{} } view := BuildView(snapshot, h.now()) @@ -129,12 +137,15 @@ func (h *Handler) handleSession(w http.ResponseWriter, r *http.Request) { } _ = h.store.RecordAccess(ctx, profile.ID, EventPasscodeAccepted, sourceID, h.now()) + // MaxAge is derived from the handler's clock, not time.Now: mixing an + // injected clock with the wall clock can produce a negative MaxAge, which + // browsers read as "delete this cookie immediately". http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: token.Session, Path: "/", Expires: token.ExpiresAt, - MaxAge: int(time.Until(token.ExpiresAt).Seconds()), + MaxAge: int(token.ExpiresAt.Sub(h.now()).Seconds()), Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, @@ -166,6 +177,9 @@ func (h *Handler) handleAvailability(w http.ResponseWriter, r *http.Request) { snapshot, err := h.store.ReadSnapshot(ctx, profile.ID) if err != nil { + if !errors.Is(err, ErrNoSnapshot) { + log.Printf("portal: snapshot unreadable for profile %s: %v", profile.ID, err) + } writeJSON(w, http.StatusOK, availabilityDTO{ Windows: []windowDTO{}, Status: StatusInsufficientData, diff --git a/apps/server/internal/portal/http.go b/apps/server/internal/portal/http.go index d239556..9bb9af3 100644 --- a/apps/server/internal/portal/http.go +++ b/apps/server/internal/portal/http.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "strconv" "strings" "time" ) @@ -272,17 +273,5 @@ func retryAfterSeconds(value time.Duration) string { if seconds < 1 { seconds = 1 } - return itoa(seconds) -} - -func itoa(value int) string { - if value == 0 { - return "0" - } - digits := make([]byte, 0, 12) - for value > 0 { - digits = append([]byte{byte('0' + value%10)}, digits...) - value /= 10 - } - return string(digits) + return strconv.Itoa(seconds) }