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..6004093 --- /dev/null +++ b/apps/server/internal/portal/handlers.go @@ -0,0 +1,289 @@ +package portal + +import ( + "bytes" + "embed" + "encoding/json" + "errors" + "html/template" + "log" + "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: 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()) + 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()) + + // 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(token.ExpiresAt.Sub(h.now()).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 { + 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, + }) + 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..9bb9af3 --- /dev/null +++ b/apps/server/internal/portal/http.go @@ -0,0 +1,277 @@ +package portal + +import ( + "context" + "errors" + "net/http" + "strconv" + "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 strconv.Itoa(seconds) +} 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 `