diff --git a/apps/server/internal/api/portal.go b/apps/server/internal/api/portal.go index a245d03..5c9e36c 100644 --- a/apps/server/internal/api/portal.go +++ b/apps/server/internal/api/portal.go @@ -9,6 +9,7 @@ import ( "non24.app/server/internal/portal" "non24.app/server/internal/portalbridge" + "non24.app/server/internal/store" syncmodel "non24.app/server/internal/sync" ) @@ -19,17 +20,19 @@ type portalAdmin struct { store *portal.Store origin string materializer portalbridge.Materializer + requests *portalbridge.RequestBridge } // 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 { +func WithPortal(portalStore *portal.Store, publicOrigin string, materializer portalbridge.Materializer, requests *portalbridge.RequestBridge) Option { return func(s *Server) { s.portal = &portalAdmin{ store: portalStore, origin: strings.TrimSuffix(publicOrigin, "/"), materializer: materializer, + requests: requests, } } } @@ -237,6 +240,155 @@ func (s *Server) handleErasePortalProfile(w http.ResponseWriter, r *http.Request }) } +type visitorRequestDTO struct { + ProposalID string `json:"proposalId"` + ProfileID string `json:"profileId"` + Label string `json:"label"` + Status string `json:"status"` + WindowStartAt time.Time `json:"windowStartAt"` + WindowEndAt time.Time `json:"windowEndAt"` + ZoneID string `json:"zoneId"` + DurationMinutes int `json:"durationMinutes,omitempty"` + BeyondHorizon bool `json:"beyondHorizon"` + Handle string `json:"handle,omitempty"` + Message string `json:"message,omitempty"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt time.Time `json:"expiresAt"` + DecisionToken string `json:"decisionToken,omitempty"` + Disclosure string `json:"disclosure"` +} + +// approvalDisclosure is required by design section 6: approving reveals the +// exact block to the visitor. That is necessary coordination information, and +// the owner must be told it before choosing, not after. +const approvalDisclosure = "Approving tells them the exact time you pick. Declining tells them only that the time did not work — never why." + +type visitorRequestListResponse struct { + SchemaVersion string `json:"schema_version"` + Requests []visitorRequestDTO `json:"requests"` +} + +// handleListVisitorRequests returns open visitor requests with the private +// handle and message the owner needs to judge them. This is an owner-side, +// device-authenticated route; none of this text is public. +func (s *Server) handleListVisitorRequests(w http.ResponseWriter, r *http.Request) { + now := s.now() + page, err := s.store.ListProposalPage(r.Context(), store.ProposalPageCursor{}, store.MaxProposalPageLimit, now) + if err != nil { + writeError(w, http.StatusInternalServerError, "visitor request list failed") + return + } + labels, err := s.store.PortalLabels(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "visitor request list failed") + return + } + + requests := make([]visitorRequestDTO, 0) + for _, record := range page.Records { + if record.ActionID != store.ActionVisitorRequest { + continue + } + payload, decodeErr := store.VisitorProposalPayloadOf(record) + if decodeErr != nil { + writeError(w, http.StatusInternalServerError, "visitor request list failed") + return + } + requests = append(requests, visitorRequestDTO{ + ProposalID: record.ID, + ProfileID: payload.ProfileID, + Label: labels[payload.ProfileID], + Status: string(record.Status), + WindowStartAt: payload.WindowStart, + WindowEndAt: payload.WindowEnd, + ZoneID: payload.ZoneID, + DurationMinutes: payload.DurationMinutes, + BeyondHorizon: payload.BeyondHorizon, + Handle: payload.Handle, + Message: payload.Message, + CreatedAt: record.CreatedAt, + ExpiresAt: record.ExpiresAt, + DecisionToken: record.DecisionToken, + Disclosure: approvalDisclosure, + }) + } + writeJSON(w, http.StatusOK, visitorRequestListResponse{ + SchemaVersion: syncmodel.SchemaVersion, + Requests: requests, + }) +} + +type visitorDecisionRequest struct { + Decision string `json:"decision"` + Token string `json:"token"` + StartAt *time.Time `json:"startAt,omitempty"` + EndAt *time.Time `json:"endAt,omitempty"` +} + +func (s *Server) handleDecideVisitorRequest(w http.ResponseWriter, r *http.Request) { + device := deviceFromContext(r.Context()) + proposalID := strings.TrimSpace(r.PathValue("id")) + var req visitorDecisionRequest + if err := decodeBody(w, r, maxDeviceBodyBytes, &req); err != nil { + writeDecodeError(w, err) + return + } + slot := store.VisitorSlot{} + if req.StartAt != nil { + slot.StartAt = *req.StartAt + } + if req.EndAt != nil { + slot.EndAt = *req.EndAt + } + + record, err := s.store.DecideVisitorProposal(r.Context(), proposalID, device.ID, + store.ProposalStatus(req.Decision), req.Token, slot, s.now()) + switch { + case errors.Is(err, store.ErrVisitorSlotOutOfWindow): + writeError(w, http.StatusBadRequest, err.Error()) + return + case errors.Is(err, store.ErrNotVisitorProposal): + writeError(w, http.StatusConflict, "that proposal is not a visitor request") + return + case errors.Is(err, store.ErrExpiredApprovalToken): + writeError(w, http.StatusGone, "approval token expired") + return + case errors.Is(err, store.ErrUsedApprovalToken), errors.Is(err, store.ErrProposalNotPending): + writeError(w, http.StatusConflict, "request already decided") + return + case errors.Is(err, store.ErrInvalidApprovalToken): + writeError(w, http.StatusUnauthorized, "invalid approval token") + return + case errors.Is(err, store.ErrProposalNotFound): + writeError(w, http.StatusNotFound, "request not found") + return + case err != nil: + writeError(w, http.StatusBadRequest, "request decision failed") + return + } + + // Deliver the answer immediately so the visitor is not left waiting on the + // next timer tick. The decision is already durable either way. + s.pumpPortalBridge(r) + + writeJSON(w, http.StatusOK, map[string]any{ + "schema_version": syncmodel.SchemaVersion, + "proposal": record, + }) +} + +// pumpPortalBridge runs one bridge cycle. Failures are logged rather than +// returned: the decision is committed, and the outbox guarantees delivery on a +// later pass. +func (s *Server) pumpPortalBridge(r *http.Request) { + if s.portal == nil || s.portal.requests == nil { + return + } + if err := s.portal.requests.Pump(r.Context()); err != nil { + log.Printf("portal: bridge pump failed: %v", err) + } +} + // 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 diff --git a/apps/server/internal/api/portal_test.go b/apps/server/internal/api/portal_test.go index f8d09c0..8cdcd20 100644 --- a/apps/server/internal/api/portal_test.go +++ b/apps/server/internal/api/portal_test.go @@ -3,6 +3,7 @@ package api import ( "bytes" "encoding/json" + "fmt" "net/http" "path/filepath" "strings" @@ -54,6 +55,10 @@ func newPortalHarness(t *testing.T) (*testHarness, *portal.Store) { Profiles: portalStore, Sink: portalStore, Now: func() time.Time { return portalTestNow }, + }, &portalbridge.RequestBridge{ + Portal: portalStore, + Private: s.store, + Now: func() time.Time { return portalTestNow }, })(s) }) return harness, portalStore @@ -187,3 +192,135 @@ func TestSharingRequiresDeviceAuth(t *testing.T) { t.Errorf("status = %d, want 401 without a device token", status) } } + +// visitorRequestFixture creates a link that accepts requests, submits one +// directly through the portal store, and pumps it into the owner's queue. +func visitorRequestFixture(t *testing.T, h *testHarness, portalStore *portal.Store) visitorRequestDTO { + t.Helper() + ctx := t.Context() + profileID, _ := portal.NewProfileID() + linkToken, _ := portal.NewLinkToken() + if err := portalStore.CreateProfile(ctx, portal.CreateProfileInput{ + ProfileID: profileID, + Token: linkToken, + Passcode: "long-enough-passcode", + Grants: portal.Grants{WakingWindows: true, AllowRequests: true}, + CreatedAt: portalTestNow, + ExpiresAt: portalTestNow.Add(30 * 24 * time.Hour), + }); err != nil { + t.Fatalf("create profile: %v", err) + } + profile, err := portalStore.ResolveLink(ctx, linkToken, portalTestNow) + if err != nil { + t.Fatalf("resolve: %v", err) + } + session, err := portalStore.CreateSession(ctx, profile, portalTestNow) + if err != nil { + t.Fatalf("session: %v", err) + } + if _, err := portalStore.CreateRequest(ctx, profile, session.Session, portal.RequestInput{ + WindowStart: portalTestNow.Add(30 * time.Hour), + WindowEnd: portalTestNow.Add(34 * time.Hour), + ZoneID: "UTC", + DurationMinutes: 60, + Handle: "Sam", + Message: "coffee?", + }, false, portalTestNow); err != nil { + t.Fatalf("create request: %v", err) + } + + // The device must exist before the pump: a visitor has no device of their + // own, so the proposal is filed against an enrolled one. + token := h.registerDevice(t, "desktop") + bridge := portalbridge.RequestBridge{Portal: portalStore, Private: h.st, Now: func() time.Time { return portalTestNow }} + if err := bridge.Pump(ctx); err != nil { + t.Fatalf("pump: %v", err) + } + + status, data := h.request(t, http.MethodGet, "/v1/portal/requests", token, "") + if status != http.StatusOK { + t.Fatalf("list status = %d body = %s", status, data) + } + var listed visitorRequestListResponse + if err := json.Unmarshal(data, &listed); err != nil { + t.Fatalf("decode: %v", err) + } + if len(listed.Requests) != 1 { + t.Fatalf("visitor request count = %d, want 1", len(listed.Requests)) + } + return listed.Requests[0] +} + +func TestVisitorRequestListCarriesOwnerContextAndDisclosure(t *testing.T) { + h, portalStore := newPortalHarness(t) + entry := visitorRequestFixture(t, h, portalStore) + + if entry.Handle != "Sam" || entry.Message != "coffee?" { + t.Errorf("the owner cannot see what was asked: %+v", entry) + } + if entry.DecisionToken == "" { + t.Error("no one-use decision token was issued") + } + // The owner must be told what approving reveals before choosing. + for _, phrase := range []string{"exact time", "never why"} { + if !strings.Contains(entry.Disclosure, phrase) { + t.Errorf("disclosure does not mention %q: %q", phrase, entry.Disclosure) + } + } +} + +func TestVisitorRequestCannotBeDecidedByTheGenericRoute(t *testing.T) { + h, portalStore := newPortalHarness(t) + entry := visitorRequestFixture(t, h, portalStore) + token := h.registerDevice(t, "desktop2") + + body := fmt.Sprintf(`{"decision":"approved","token":%q}`, entry.DecisionToken) + status, data := h.request(t, http.MethodPost, "/v1/proposals/"+entry.ProposalID+"/decision", token, body) + if status != http.StatusConflict { + t.Fatalf("status = %d body = %s, want 409 so the slot rules cannot be skipped", status, data) + } +} + +func TestVisitorRequestApprovalRoundTrip(t *testing.T) { + h, portalStore := newPortalHarness(t) + entry := visitorRequestFixture(t, h, portalStore) + token := h.registerDevice(t, "desktop2") + + outside := fmt.Sprintf(`{"decision":"approved","token":%q,"startAt":%q,"endAt":%q}`, + entry.DecisionToken, + portalTestNow.Add(40*time.Hour).Format(time.RFC3339), + portalTestNow.Add(41*time.Hour).Format(time.RFC3339)) + if status, _ := h.request(t, http.MethodPost, "/v1/portal/requests/"+entry.ProposalID+"/decision", token, outside); status != http.StatusBadRequest { + t.Errorf("a slot outside the window returned %d, want 400", status) + } + + inside := fmt.Sprintf(`{"decision":"approved","token":%q,"startAt":%q,"endAt":%q}`, + entry.DecisionToken, + portalTestNow.Add(31*time.Hour).Format(time.RFC3339), + portalTestNow.Add(32*time.Hour).Format(time.RFC3339)) + status, data := h.request(t, http.MethodPost, "/v1/portal/requests/"+entry.ProposalID+"/decision", token, inside) + if status != http.StatusOK { + t.Fatalf("approval status = %d body = %s", status, data) + } + + // The decision must already have reached the visitor's view. + ids, err := portalStore.ListRequestsForProfile(t.Context(), entry.ProfileID) + if err != nil || len(ids) != 1 { + t.Fatalf("list requests: %v", err) + } + request, err := portalStore.ReadRequest(t.Context(), entry.ProfileID, ids[0]) + if err != nil { + t.Fatalf("read: %v", err) + } + if request.Status != portal.RequestApproved { + t.Errorf("visitor-visible status = %q, want approved", request.Status) + } + if !request.DecidedStart.Equal(portalTestNow.Add(31 * time.Hour).UTC()) { + t.Errorf("the visitor was told %v, not the chosen block", request.DecidedStart) + } + + // The token is one-use, on this route too. + if status, _ := h.request(t, http.MethodPost, "/v1/portal/requests/"+entry.ProposalID+"/decision", token, inside); status != http.StatusConflict { + t.Errorf("replayed decision returned %d, want 409", status) + } +} diff --git a/apps/server/internal/api/server.go b/apps/server/internal/api/server.go index 58730e7..61bc12b 100644 --- a/apps/server/internal/api/server.go +++ b/apps/server/internal/api/server.go @@ -84,6 +84,8 @@ func (s *Server) Handler() http.Handler { 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))) + mux.Handle("GET /v1/portal/requests", s.requireDevice(http.HandlerFunc(s.handleListVisitorRequests))) + mux.Handle("POST /v1/portal/requests/{id}/decision", s.requireDevice(http.HandlerFunc(s.handleDecideVisitorRequest))) } return mux } @@ -283,6 +285,16 @@ func (s *Server) handleProposalDecision(w http.ResponseWriter, r *http.Request) writeDecodeError(w, err) return } + // A visitor request carries an extra obligation the generic route cannot + // discharge: approval must name an exact block inside the requested + // window, and the visitor must be told. Deciding one here would skip both, + // so it is refused rather than silently mishandled. + if existing, lookupErr := s.store.ProposalByID(r.Context(), id); lookupErr == nil && + existing.ActionID == store.ActionVisitorRequest { + writeError(w, http.StatusConflict, "visitor requests are decided through the sharing endpoint") + return + } + status := store.ProposalStatus(req.Decision) record, err := s.store.DecideProposal(r.Context(), id, device.ID, status, req.Token, s.now(), json.RawMessage(`{"source":"api"}`)) switch { diff --git a/apps/server/internal/daemon/server.go b/apps/server/internal/daemon/server.go index 2fd871c..fc404be 100644 --- a/apps/server/internal/daemon/server.go +++ b/apps/server/internal/daemon/server.go @@ -77,9 +77,15 @@ func serve(configPath string, stop <-chan struct{}, ready chan<- struct{}) error Profiles: portalStore, Sink: portalStore, } + requestBridge := &portalbridge.RequestBridge{Portal: portalStore, Private: st} portalHandler, handlerErr := portal.NewHandler(portal.HandlerConfig{ Store: portalStore, PublicOrigin: cfg.Portal.PublicOrigin, + NotifyRequestCreated: func() { + if err := requestBridge.Pump(context.Background()); err != nil { + log.Printf("portal: request bridge pump failed: %v", err) + } + }, }) if handlerErr != nil { return fmt.Errorf("configure portal: %w", handlerErr) @@ -93,13 +99,13 @@ func serve(configPath string, stop <-chan struct{}, ready chan<- struct{}) error // 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) + maintenanceDone := startPortalMaintenance(portalStore, requestBridge, maintenanceStop) defer func() { close(maintenanceStop) <-maintenanceDone }() - options = append(options, api.WithPortal(portalStore, cfg.Portal.PublicOrigin, materializer)) + options = append(options, api.WithPortal(portalStore, cfg.Portal.PublicOrigin, materializer, requestBridge)) publicHandler = portalHandler.Routes() log.Printf("zeitboardd portal enabled at %s/p/", cfg.Portal.PublicOrigin) } @@ -154,24 +160,53 @@ func serve(configPath string, stop <-chan struct{}, ready chan<- struct{}) error } } -const portalMaintenanceInterval = time.Hour +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{} { + // portalBridgeInterval is the recovery path, not the happy path: a + // request is pumped as soon as it is created and a decision as soon as it + // is made. This timer exists so a request that arrived while the bridge + // was failing still reaches the owner without anyone intervening. + portalBridgeInterval = time.Minute +) + +// startPortalMaintenance expires sessions and rate buckets, enforces the +// access-audit retention window, and drains the request outbox in both +// directions. Retention that nothing enforces is not retention, and an outbox +// nothing drains is a queue that silently stops. The returned channel closes +// once the worker has stopped. +func startPortalMaintenance(store *portal.Store, bridge *portalbridge.RequestBridge, stop <-chan struct{}) <-chan struct{} { done := make(chan struct{}) go func() { defer close(done) - ticker := time.NewTicker(portalMaintenanceInterval) - defer ticker.Stop() - for { + sweep := time.NewTicker(portalMaintenanceInterval) + defer sweep.Stop() + pump := time.NewTicker(portalBridgeInterval) + defer pump.Stop() + + drain := func() { + if bridge == nil { + return + } + if err := bridge.Pump(context.Background()); err != nil { + // A failure here leaves requests in their honest `queued` + // state; the next tick retries. + log.Printf("portal: request bridge pump failed: %v", err) + } + } + purge := func() { if err := store.PurgeExpired(context.Background(), time.Now().UTC()); err != nil { log.Printf("portal: maintenance sweep failed: %v", err) } + } + purge() + drain() + for { select { - case <-ticker.C: + case <-sweep.C: + purge() + case <-pump.C: + drain() case <-stop: return } diff --git a/apps/server/internal/portal/assets/pages.gohtml b/apps/server/internal/portal/assets/pages.gohtml index cfc92eb..e7f8f50 100644 --- a/apps/server/internal/portal/assets/pages.gohtml +++ b/apps/server/internal/portal/assets/pages.gohtml @@ -46,6 +46,100 @@

{{.Notice}}

{{end}} +{{if .CanRequest}} +
+

Need a specific time?

+

You can ask for a window that suits you. They decide, and you will see the answer.

+

Ask for a time

+
+{{end}} +{{template "foot" .}}{{end}} + +{{define "request-form"}}{{template "head" .}} +
+

Ask for a time

+

Give the earliest and latest you could meet. They will pick a time inside that window, or decline.

+ {{if .Error}}

{{.Error}}

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

How accurate is this? {{.View.Qualifier}}

+

You can ask for any date. Times far ahead cannot be checked against an estimate, and you will be told when that is the case.

+

{{.View.Notice}}

+
+{{template "foot" .}}{{end}} + +{{define "request-created"}}{{template "head" .}} +
+

Request sent

+

{{.Request.StatusDetail}}

+ {{if .Request.Warning}}

{{.Request.Warning}}

{{end}} +
+ +
+

What you asked for

+

{{.Request.WindowLabel}}

+ {{if .Request.DurationLabel}}

For {{.Request.DurationLabel}}.

{{end}} +
+ +
+

Keep this code

+

It is the only way to check this request from another browser. It is shown once.

+

{{.RecoveryCode}}

+

Check this request

+
+{{template "foot" .}}{{end}} + +{{define "request-status"}}{{template "head" .}} +
+

{{.Request.StatusLabel}}

+

{{.Request.StatusDetail}}

+ {{if .Request.DecidedLabel}}

{{.Request.DecidedLabel}}

{{end}} + {{if .Request.Warning}}

{{.Request.Warning}}

{{end}} +
+ +
+

What you asked for

+

{{.Request.WindowLabel}}

+ {{if .Request.DurationLabel}}

For {{.Request.DurationLabel}}.

{{end}} + {{if .Request.Handle}}

Sent as {{.Request.Handle}}.

{{end}} + {{if .Request.Message}}

“{{.Request.Message}}”

{{end}} +
+ +
+

{{.View.Notice}}

+
+{{template "foot" .}}{{end}} + +{{define "request-claim"}}{{template "head" .}} +
+

Check a request

+

Enter the code you were shown when you sent it.

+
+ + + +
+
+ +
+

{{.View.Notice}}

+
{{template "foot" .}}{{end}} {{define "passcode"}}{{template "head" .}} diff --git a/apps/server/internal/portal/assets/portal.css b/apps/server/internal/portal/assets/portal.css index 5be1a96..3a3a7b7 100644 --- a/apps/server/internal/portal/assets/portal.css +++ b/apps/server/internal/portal/assets/portal.css @@ -208,7 +208,11 @@ label { font-weight: 600; } -input[type="password"] { +input[type="password"], +input[type="text"], +input[type="number"], +input[type="datetime-local"], +textarea { width: 100%; padding: 0.6rem 0.7rem; border: 1px solid var(--line); @@ -218,6 +222,44 @@ input[type="password"] { font: inherit; } +textarea { + resize: vertical; +} + +label + input, +label + textarea { + margin-bottom: 0.85rem; +} + +/* The one-time request code. It must be selectable and unambiguous, so it + uses a monospace face and never wraps mid-token in a way that hides a + hyphen. */ +.code { + padding: 0.6rem 0.7rem; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--canvas); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85rem; + overflow-wrap: anywhere; + user-select: all; +} + +.button-link { + display: inline-block; + margin-top: 0.35rem; + padding: 0.6rem 1.1rem; + border-radius: 8px; + background: var(--button-bg); + color: var(--button-text); + font-weight: 600; + text-decoration: none; +} + +.button-link:hover { + background: var(--button-hover); +} + button { margin-top: 0.9rem; padding: 0.6rem 1.1rem; diff --git a/apps/server/internal/portal/handlers.go b/apps/server/internal/portal/handlers.go index 6004093..89c1833 100644 --- a/apps/server/internal/portal/handlers.go +++ b/apps/server/internal/portal/handlers.go @@ -27,6 +27,18 @@ type pageData struct { Error string FormAction string View AvailabilityView + + // Request fields. Handle and Message inside RequestView are the visitor's + // own words being shown back to them; they never appear on the + // availability page, in a projection DTO, or in an audit row. + Request RequestView + CSRFToken string + RecoveryCode string + ContinueURL string + LinkTokenPath string + MinLocal string + CanRequest bool + RequestsPath string } func (h *Handler) handleStylesheet(w http.ResponseWriter, r *http.Request) { @@ -63,9 +75,11 @@ func (h *Handler) handlePage(w http.ResponseWriter, r *http.Request) { } view := BuildView(snapshot, h.now()) h.renderPage(w, r, http.StatusOK, "dashboard", pageData{ - Title: "Availability", - Refresh: true, - View: view, + Title: "Availability", + Refresh: true, + View: view, + CanRequest: profile.Grants.AllowRequests, + RequestsPath: requestsPath(r.PathValue("linkToken")), }) } diff --git a/apps/server/internal/portal/http.go b/apps/server/internal/portal/http.go index 9bb9af3..25df190 100644 --- a/apps/server/internal/portal/http.go +++ b/apps/server/internal/portal/http.go @@ -34,6 +34,7 @@ type Handler struct { now func() time.Time publicOrigin string resolutionFloor time.Duration + notifyCreated func() } type HandlerConfig struct { @@ -48,6 +49,13 @@ type HandlerConfig struct { // 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 + + // NotifyRequestCreated is called after a visitor request is durably + // stored. It is a bare signal, not a callback into the private store: + // the portal says "there is something to deliver" and the owner side + // decides what that means. Keeping it signal-shaped is what lets the + // portal package stay unable to reach private data. + NotifyRequestCreated func() } func NewHandler(cfg HandlerConfig) (*Handler, error) { @@ -66,7 +74,13 @@ func NewHandler(cfg HandlerConfig) (*Handler, error) { if floor <= 0 { floor = resolutionFloor } - return &Handler{store: cfg.Store, now: now, publicOrigin: origin, resolutionFloor: floor}, nil + return &Handler{ + store: cfg.Store, + now: now, + publicOrigin: origin, + resolutionFloor: floor, + notifyCreated: cfg.NotifyRequestCreated, + }, nil } // Routes returns the public mux. It is mounted only when the portal is @@ -77,6 +91,10 @@ func (h *Handler) Routes() http.Handler { 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)))) + mux.Handle("GET /p/{linkToken}/requests", h.linkChain(h.requireSession(http.HandlerFunc(h.handleRequestForm)))) + mux.Handle("POST /p/{linkToken}/requests", h.linkChain(h.requireOrigin(h.requireSession(http.HandlerFunc(h.handleCreateRequest))))) + mux.Handle("GET /p/{linkToken}/requests/{requestID}", h.linkChain(h.requireSession(http.HandlerFunc(h.handleRequestStatus)))) + mux.Handle("POST /p/{linkToken}/requests/{requestID}/session", h.linkChain(h.requireOrigin(h.requireSession(http.HandlerFunc(h.handleRequestSession))))) return mux } diff --git a/apps/server/internal/portal/outbox.go b/apps/server/internal/portal/outbox.go new file mode 100644 index 0000000..8970002 --- /dev/null +++ b/apps/server/internal/portal/outbox.go @@ -0,0 +1,166 @@ +package portal + +import ( + "context" + "errors" + "fmt" + "time" +) + +// OutboxEntry is one pending handoff to the private side. The portal never +// calls the private store itself; a bridge polls these and reports back. +type OutboxEntry struct { + ID int64 + Kind string + RequestID string + IdempotencyKey string + Attempts int + Request Request +} + +// PendingOutbox returns undelivered handoffs oldest first. +func (s *Store) PendingOutbox(ctx context.Context, limit int) ([]OutboxEntry, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, + `SELECT id, kind, request_id, idempotency_key, attempts + FROM portal_outbox ORDER BY id LIMIT ?`, limit) + if err != nil { + return nil, err + } + var entries []OutboxEntry + for rows.Next() { + var entry OutboxEntry + if err := rows.Scan(&entry.ID, &entry.Kind, &entry.RequestID, &entry.IdempotencyKey, &entry.Attempts); err != nil { + _ = rows.Close() + return nil, err + } + entries = append(entries, entry) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + + // Load each request after the cursor is closed: this store runs on a + // single connection, so querying while rows are open would deadlock. + loaded := make([]OutboxEntry, 0, len(entries)) + for _, entry := range entries { + request, err := s.requestByID(ctx, entry.RequestID) + if err != nil { + if errors.Is(err, ErrRequestNotFound) { + // The request was erased under the bridge. Drop the handoff + // rather than retrying forever. + if _, delErr := s.db.ExecContext(ctx, `DELETE FROM portal_outbox WHERE id = ?`, entry.ID); delErr != nil { + return nil, delErr + } + continue + } + return nil, err + } + entry.Request = request + loaded = append(loaded, entry) + } + return loaded, nil +} + +func (s *Store) requestByID(ctx context.Context, requestID string) (Request, error) { + row := s.db.QueryRowContext(ctx, `SELECT request_id, profile_id, status, + window_start, window_end, zone_id, duration_minutes, beyond_horizon, + decided_start, decided_end, created_at, updated_at, nonce, ciphertext + FROM portal_requests WHERE request_id = ?`, requestID) + return s.scanRequest(row) +} + +// AckOutbox marks a handoff delivered and moves the request from the honest +// `queued` state to `pending`. Both happen in one transaction so a visitor +// never sees "waiting for a decision" for a request the owner never received. +func (s *Store) AckOutbox(ctx context.Context, entryID int64, requestID string, now time.Time) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, `DELETE FROM portal_outbox WHERE id = ?`, entryID); err != nil { + return err + } + // Only a queued request advances. A request already decided by a racing + // status delivery must not be dragged back to pending. + if _, err := tx.ExecContext(ctx, + `UPDATE portal_requests SET status = ?, updated_at = ? WHERE request_id = ? AND status = ?`, + RequestPending, formatTime(now), requestID, RequestQueued); err != nil { + return err + } + return tx.Commit() +} + +// NoteOutboxFailure records an attempt without dropping the handoff. The +// request stays `queued`, which is what the visitor is shown. +func (s *Store) NoteOutboxFailure(ctx context.Context, entryID int64, reason string) error { + // The reason is bounded and stored for the operator; it is never rendered + // to a visitor and never joins the access audit. + if len(reason) > 200 { + reason = reason[:200] + } + _, err := s.db.ExecContext(ctx, + `UPDATE portal_outbox SET attempts = attempts + 1, last_error = ? WHERE id = ?`, reason, entryID) + return err +} + +// ApplyDecision records the owner's answer against a request. It is +// idempotent: replaying the same decision is a no-op, which is what lets the +// private side retry delivery without coordination. +func (s *Store) ApplyDecision(ctx context.Context, requestID, status string, decidedStart, decidedEnd time.Time, now time.Time) error { + switch status { + case RequestApproved, RequestDeclined, RequestClosed: + default: + return fmt.Errorf("unsupported portal request status %q", status) + } + if status == RequestApproved && (decidedStart.IsZero() || !decidedEnd.After(decidedStart)) { + return errors.New("an approved request requires the exact chosen block") + } + result, err := s.db.ExecContext(ctx, + `UPDATE portal_requests + SET status = ?, decided_start = ?, decided_end = ?, updated_at = ? + WHERE request_id = ? AND status IN (?, ?)`, + status, formatTime(decidedStart), formatTime(decidedEnd), formatTime(now), + requestID, RequestQueued, RequestPending) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + // Either the request is gone or it was already decided. Both are + // acceptable outcomes for a retry, so this is not an error. + return nil + } + return nil +} + +// ListRequestsForProfile is the owner-side read used when a link is revoked or +// erased, so open requests can be closed rather than left dangling. +func (s *Store) ListRequestsForProfile(ctx context.Context, profileID string) ([]string, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT request_id FROM portal_requests WHERE profile_id = ? ORDER BY created_at`, profileID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} diff --git a/apps/server/internal/portal/portal_test.go b/apps/server/internal/portal/portal_test.go index ba5cf2f..5444d9b 100644 --- a/apps/server/internal/portal/portal_test.go +++ b/apps/server/internal/portal/portal_test.go @@ -729,10 +729,10 @@ func TestPurgeExpiredEnforcesRetention(t *testing.T) { } } -// TestSessionCSRFTokenRoundTrips guards the synchronizer token minted with -// every session. P5-a has no session-authenticated mutation to spend it on, so -// this keeps it from silently rotting before P5-b needs it. -func TestSessionCSRFTokenRoundTrips(t *testing.T) { +// TestSessionCSRFTokenIsDerivable is the property a server-rendered form +// needs: the token must be recoverable from the session cookie on every page +// render, and must still be unguessable and session-specific. +func TestSessionCSRFTokenIsDerivable(t *testing.T) { h := newHarness(t) ctx := context.Background() now := h.clock.Now() @@ -744,15 +744,21 @@ func TestSessionCSRFTokenRoundTrips(t *testing.T) { if issued.CSRF == "" || issued.CSRF == issued.Session { t.Fatal("session and CSRF tokens must both exist and differ") } - resolved, err := h.store.ResolveSession(ctx, issued.Session, now) - if err != nil { - t.Fatalf("resolve: %v", err) + // Recomputed later, without having kept the plaintext anywhere. + if got := h.store.CSRFToken(issued.Session); got != issued.CSRF { + t.Errorf("CSRF token is not derivable from the session: %q vs %q", got, issued.CSRF) } - if !resolved.MatchesCSRF(issued.CSRF) { - t.Error("the issued CSRF token does not match its session") + if !h.store.MatchesCSRF(issued.Session, issued.CSRF) { + t.Error("the issued CSRF token does not verify") } - if resolved.MatchesCSRF("") || resolved.MatchesCSRF(issued.Session) { - t.Error("CSRF comparison accepted a wrong value") + for name, presented := range map[string]string{ + "empty": "", + "session value": issued.Session, + "other token": h.store.CSRFToken("some-other-session"), + } { + if h.store.MatchesCSRF(issued.Session, presented) { + t.Errorf("CSRF comparison accepted the %s", name) + } } } diff --git a/apps/server/internal/portal/request_handlers.go b/apps/server/internal/portal/request_handlers.go new file mode 100644 index 0000000..3ec078a --- /dev/null +++ b/apps/server/internal/portal/request_handlers.go @@ -0,0 +1,354 @@ +package portal + +import ( + "errors" + "fmt" + "log" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const requestCookieName = "__Host-zb_request" + +// RequestView is the rendered form of a visitor's own request. It contains the +// visitor's own text — which they wrote and may re-read — and never anything +// about the owner beyond the decision itself. +type RequestView struct { + ID string + StatusLabel string + StatusDetail string + WindowLabel string + DurationLabel string + Handle string + Message string + DecidedLabel string + BeyondHorizon bool + Warning string + Open bool +} + +// BeyondHorizonWarning is required on any request reaching past the forecast. +// The owner chose to allow those requests rather than cap them, so the honest +// alternative to refusing is saying plainly that the estimate does not run +// that far. +const BeyondHorizonWarning = "That date is further ahead than this estimate can reach, so there is no availability to check it against. You can still ask, and they can still answer." + +func (h *Handler) handleCreateRequest(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + profile := profileFromContext(ctx) + + if !profile.Grants.AllowRequests { + h.writeGeneric(w, r, http.StatusForbidden) + return + } + if err := r.ParseForm(); err != nil { + h.renderRequestForm(w, r, "That form could not be read. Try again.") + return + } + if !h.store.MatchesCSRF(sessionValueFrom(r), r.PostFormValue("csrf")) { + // A session exists here, so the synchronizer token is available and + // checked in addition to the same-origin attestation. + h.writeGeneric(w, r, http.StatusForbidden) + return + } + + input, err := parseRequestForm(r, profile) + if err != nil { + h.renderRequestForm(w, r, humanRequestError(err)) + return + } + + var horizonEnd time.Time + if snapshot, snapErr := h.store.ReadSnapshot(ctx, profile.ID); snapErr == nil { + horizonEnd = snapshot.HorizonEnd + } + validated, beyondHorizon, err := ValidateRequest(input, h.now(), horizonEnd) + if err != nil { + h.renderRequestForm(w, r, humanRequestError(err)) + return + } + + created, err := h.store.CreateRequest(ctx, profile, sessionValueFrom(r), validated, beyondHorizon, h.now()) + if err != nil { + if errors.Is(err, ErrRequestLimit) || errors.Is(err, ErrRequestInvalid) { + h.renderRequestForm(w, r, humanRequestError(err)) + return + } + log.Printf("portal: request creation failed for profile %s: %v", profile.ID, err) + h.writeGeneric(w, r, http.StatusServiceUnavailable) + return + } + + // Nudge the owner side to deliver now rather than on the next timer tick, + // then re-read so the confirmation reports the state that is actually + // true. If delivery failed, the request stays queued and says so. + if h.notifyCreated != nil { + h.notifyCreated() + } + stored := created.Request + if refreshed, readErr := h.store.ReadRequest(ctx, profile.ID, created.Request.ID); readErr == nil { + stored = refreshed + } + + // The requester secret travels in the fragment, which browsers never send + // to a server and proxies never log. The no-script path below also shows + // it once as a recovery code. + target := requestPath(r.PathValue("linkToken"), created.Request.ID) + "#s=" + url.QueryEscape(created.Secret) + h.renderPage(w, r, http.StatusOK, "request-created", pageData{ + Title: "Request sent", + Request: h.requestView(stored), + RecoveryCode: created.Secret, + ContinueURL: target, + View: AvailabilityView{Notice: NoticeNotMedical}, + LinkTokenPath: requestPath(r.PathValue("linkToken"), created.Request.ID), + }) +} + +// handleRequestSession exchanges the requester secret for a request-scoped +// cookie. It is a POST so the secret never enters a URL the server sees. +func (h *Handler) handleRequestSession(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + profile := profileFromContext(ctx) + requestID := r.PathValue("requestID") + + if err := r.ParseForm(); err != nil { + h.writeGeneric(w, r, http.StatusBadRequest) + return + } + secret := strings.TrimSpace(r.PostFormValue("secret")) + token, err := h.store.AuthorizeRequestSecret(ctx, profile.ID, requestID, secret, h.now()) + if err != nil { + // A wrong secret and an unknown request are the same response, so a + // holder of the shared link cannot enumerate other visitors' requests. + h.writeGeneric(w, r, http.StatusGone) + return + } + http.SetCookie(w, &http.Cookie{ + Name: requestCookieName, + 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, requestPath(r.PathValue("linkToken"), requestID), http.StatusSeeOther) +} + +func (h *Handler) handleRequestStatus(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + profile := profileFromContext(ctx) + requestID := r.PathValue("requestID") + + authorized, err := h.store.AuthorizesRequest(ctx, requestCookieValue(r), profile.ID, requestID, h.now()) + if err != nil || !authorized { + // No proof of authorship: offer the recovery-code form rather than + // confirming or denying that this request exists. + h.renderPage(w, r, http.StatusOK, "request-claim", pageData{ + Title: "Check a request", + FormAction: requestSessionPath(r.PathValue("linkToken"), requestID), + View: AvailabilityView{Notice: NoticeNotMedical}, + LinkTokenPath: requestPath(r.PathValue("linkToken"), requestID), + }) + return + } + + request, err := h.store.ReadRequest(ctx, profile.ID, requestID) + if err != nil { + h.writeGeneric(w, r, http.StatusGone) + return + } + h.renderPage(w, r, http.StatusOK, "request-status", pageData{ + Title: "Your request", + Refresh: true, + Request: h.requestView(request), + View: AvailabilityView{Notice: NoticeNotMedical}, + }) +} + +func (h *Handler) requestView(request Request) RequestView { + location := windowLocation(request.ZoneID) + view := RequestView{ + ID: request.ID, + WindowLabel: describeDay(request.WindowStart, h.now(), location) + ", " + describeRange(request.WindowStart, request.WindowEnd, location), + Handle: request.Handle, + Message: request.Message, + BeyondHorizon: request.BeyondHorizon, + } + if request.DurationMinutes > 0 { + view.DurationLabel = fmt.Sprintf("%d minutes", request.DurationMinutes) + } + if request.BeyondHorizon { + view.Warning = BeyondHorizonWarning + } + switch request.Status { + case RequestQueued: + view.Open = true + view.StatusLabel = "Waiting to be delivered" + // Deliberately not "sent": the portal has the request but has not yet + // confirmed it reached the owner's queue, and saying otherwise would + // be a claim the visitor cannot check. + view.StatusDetail = "Your request is saved and is on its way to them." + case RequestPending: + view.Open = true + view.StatusLabel = "Waiting for an answer" + view.StatusDetail = "They have your request. You will see the answer here." + case RequestApproved: + view.StatusLabel = "Accepted" + view.StatusDetail = "They picked a time inside the window you asked for." + view.DecidedLabel = describeDay(request.DecidedStart, h.now(), location) + ", " + + describeRange(request.DecidedStart, request.DecidedEnd, location) + case RequestDeclined: + // No reason is given, ever. A reason would disclose the owner's sleep + // state, calendar, or health, none of which a visitor is owed. + view.StatusLabel = "Declined" + view.StatusDetail = "They could not take that time." + default: + view.StatusLabel = "Closed" + view.StatusDetail = "This request is no longer open." + } + return view +} + +func parseRequestForm(r *http.Request, profile Profile) (RequestInput, error) { + zoneID := strings.TrimSpace(r.PostFormValue("zone_id")) + if zoneID == "" { + zoneID = "UTC" + } + location, err := time.LoadLocation(zoneID) + if err != nil { + return RequestInput{}, fmt.Errorf("%w: unknown time zone", ErrRequestInvalid) + } + start, err := parseLocalInput(r.PostFormValue("window_start"), location) + if err != nil { + return RequestInput{}, err + } + end, err := parseLocalInput(r.PostFormValue("window_end"), location) + if err != nil { + return RequestInput{}, err + } + duration := 0 + if raw := strings.TrimSpace(r.PostFormValue("duration_minutes")); raw != "" { + parsed, convErr := strconv.Atoi(raw) + if convErr != nil { + return RequestInput{}, fmt.Errorf("%w: length must be a number of minutes", ErrRequestInvalid) + } + duration = parsed + } + _ = profile + return RequestInput{ + WindowStart: start, + WindowEnd: end, + ZoneID: zoneID, + DurationMinutes: duration, + Handle: r.PostFormValue("handle"), + Message: r.PostFormValue("message"), + }, nil +} + +// parseLocalInput reads an HTML datetime-local value in the visitor's chosen +// zone. A local time that does not exist (spring-forward) or is ambiguous +// (fall-back) is rejected rather than silently resolved, because guessing +// which of two instants someone meant is how scheduling bugs are born. +func parseLocalInput(value string, location *time.Location) (time.Time, error) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, fmt.Errorf("%w: a start and end are required", ErrRequestInvalid) + } + layouts := []string{"2006-01-02T15:04", "2006-01-02T15:04:05"} + var parsed time.Time + var err error + for _, layout := range layouts { + parsed, err = time.ParseInLocation(layout, value, location) + if err == nil { + break + } + } + if err != nil { + return time.Time{}, fmt.Errorf("%w: that time could not be read", ErrRequestInvalid) + } + if parsed.Format("2006-01-02T15:04") != value[:min(len(value), 16)] { + return time.Time{}, fmt.Errorf("%w: that local time does not exist on that date", ErrRequestInvalid) + } + return parsed.UTC(), nil +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// humanRequestError surfaces the caller's own mistake and nothing else. The +// wrapped sentinel is stripped so a visitor sees guidance, not an error chain. +func humanRequestError(err error) string { + message := err.Error() + for _, prefix := range []string{ErrRequestInvalid.Error() + ": ", ErrRequestLimit.Error() + ": "} { + if strings.HasPrefix(message, prefix) { + return strings.ToUpper(message[len(prefix):len(prefix)+1]) + message[len(prefix)+1:] + "." + } + } + return "That request could not be accepted." +} + +func (h *Handler) renderRequestForm(w http.ResponseWriter, r *http.Request, message string) { + ctx := r.Context() + profile := profileFromContext(ctx) + snapshot, err := h.store.ReadSnapshot(ctx, profile.ID) + if err != nil { + snapshot = Snapshot{} + } + status := http.StatusOK + if message != "" { + status = http.StatusBadRequest + } + h.renderPage(w, r, status, "request-form", pageData{ + Title: "Ask for a time", + Error: message, + FormAction: requestsPath(r.PathValue("linkToken")), + CSRFToken: h.store.CSRFToken(sessionValueFrom(r)), + View: BuildView(snapshot, h.now()), + MinLocal: h.now().Format("2006-01-02T15:04"), + }) +} + +func (h *Handler) handleRequestForm(w http.ResponseWriter, r *http.Request) { + if !profileFromContext(r.Context()).Grants.AllowRequests { + h.writeGeneric(w, r, http.StatusForbidden) + return + } + h.renderRequestForm(w, r, "") +} + +func sessionValueFrom(r *http.Request) string { + cookie, err := r.Cookie(sessionCookieName) + if err != nil { + return "" + } + return cookie.Value +} + +func requestCookieValue(r *http.Request) string { + cookie, err := r.Cookie(requestCookieName) + if err != nil { + return "" + } + return cookie.Value +} + +func requestsPath(linkToken string) string { + return (&url.URL{Path: "/p/" + linkToken + "/requests"}).String() +} + +func requestPath(linkToken, requestID string) string { + return (&url.URL{Path: "/p/" + linkToken + "/requests/" + requestID}).String() +} + +func requestSessionPath(linkToken, requestID string) string { + return (&url.URL{Path: "/p/" + linkToken + "/requests/" + requestID + "/session"}).String() +} diff --git a/apps/server/internal/portal/requests.go b/apps/server/internal/portal/requests.go new file mode 100644 index 0000000..a6b94a9 --- /dev/null +++ b/apps/server/internal/portal/requests.go @@ -0,0 +1,392 @@ +package portal + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" +) + +// Request lifecycle. `queued` is an honest state, not a transient one: it +// means the portal has durably stored the request but the bridge has not yet +// confirmed the owner's queue received it. Showing "sent" before that +// confirmation would be a lie the visitor cannot check. +const ( + RequestQueued = "queued" + RequestPending = "pending" + RequestApproved = "approved" + RequestDeclined = "declined" + RequestClosed = "closed" +) + +// Limits from docs/portal-design.md section 6 and 8. +const ( + MaxRequestWindow = 8 * time.Hour + MinRequestDuration = 15 + MaxRequestDuration = 480 + MaxHandleRunes = 40 + MaxMessageRunes = 500 + RequestsPerSessionDay = 5 + MaxPendingPerProfile = 20 + + // RequestSessionLifetime bounds a requester's cookie. It is longer than a + // portal session because a visitor should be able to come back and read a + // decision without re-entering anything. + RequestSessionLifetime = 14 * 24 * time.Hour +) + +var ( + ErrRequestInvalid = errors.New("portal request is invalid") + ErrRequestNotFound = errors.New("portal request not found") + ErrRequestLimit = errors.New("portal request limit reached") + ErrRequestNotPending = errors.New("portal request is no longer open") +) + +// RequestInput is the validated visitor submission. +type RequestInput struct { + WindowStart time.Time + WindowEnd time.Time + ZoneID string + DurationMinutes int + Handle string + Message string +} + +// Request is a stored visitor request. Handle and Message are private visitor +// text: they are encrypted at rest, never enter a projection DTO, and never +// reach an access-audit row or a notification title. +type Request struct { + ID string + ProfileID string + Status string + WindowStart time.Time + WindowEnd time.Time + ZoneID string + DurationMinutes int + BeyondHorizon bool + Handle string + Message string + DecidedStart time.Time + DecidedEnd time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type requestSecrets struct { + Handle string `json:"handle"` + Message string `json:"message"` +} + +// ValidateRequest normalizes and bounds a visitor submission. There is +// deliberately no upper date horizon (owner decision 3); a request past the +// forecast horizon is accepted and flagged so the UI can warn rather than +// refuse. What is bounded is the window's *length*, because an eight-hour ask +// is a scheduling request and a three-week ask is not. +func ValidateRequest(input RequestInput, now time.Time, horizonEnd time.Time) (RequestInput, bool, error) { + if input.WindowStart.IsZero() || input.WindowEnd.IsZero() { + return RequestInput{}, false, fmt.Errorf("%w: a start and end are required", ErrRequestInvalid) + } + input.WindowStart = input.WindowStart.UTC() + input.WindowEnd = input.WindowEnd.UTC() + + if !input.WindowEnd.After(input.WindowStart) { + return RequestInput{}, false, fmt.Errorf("%w: the end must be after the start", ErrRequestInvalid) + } + if input.WindowStart.Before(now) { + return RequestInput{}, false, fmt.Errorf("%w: the window has already started", ErrRequestInvalid) + } + window := input.WindowEnd.Sub(input.WindowStart) + if window > MaxRequestWindow { + return RequestInput{}, false, fmt.Errorf("%w: the window may not exceed %d hours", + ErrRequestInvalid, int(MaxRequestWindow.Hours())) + } + + if input.DurationMinutes != 0 { + if input.DurationMinutes < MinRequestDuration || input.DurationMinutes > MaxRequestDuration { + return RequestInput{}, false, fmt.Errorf("%w: length must be between %d and %d minutes", + ErrRequestInvalid, MinRequestDuration, MaxRequestDuration) + } + if time.Duration(input.DurationMinutes)*time.Minute > window { + return RequestInput{}, false, fmt.Errorf("%w: length must fit inside the window", ErrRequestInvalid) + } + } + + if input.ZoneID == "" { + input.ZoneID = "UTC" + } + if _, err := time.LoadLocation(input.ZoneID); err != nil { + return RequestInput{}, false, fmt.Errorf("%w: unknown time zone", ErrRequestInvalid) + } + + input.Handle = sanitizeVisitorText(input.Handle) + input.Message = sanitizeVisitorText(input.Message) + if utf8.RuneCountInString(input.Handle) > MaxHandleRunes { + return RequestInput{}, false, fmt.Errorf("%w: name must be at most %d characters", ErrRequestInvalid, MaxHandleRunes) + } + if utf8.RuneCountInString(input.Message) > MaxMessageRunes { + return RequestInput{}, false, fmt.Errorf("%w: message must be at most %d characters", ErrRequestInvalid, MaxMessageRunes) + } + + beyondHorizon := horizonEnd.IsZero() || input.WindowStart.After(horizonEnd) + return input, beyondHorizon, nil +} + +// sanitizeVisitorText strips control characters. It does not escape anything: +// escaping belongs to the renderer, and pre-escaping here would double-encode +// and corrupt the owner's copy. +func sanitizeVisitorText(value string) string { + var builder strings.Builder + builder.Grow(len(value)) + for _, r := range value { + switch { + case r == '\n' || r == '\t': + builder.WriteRune(' ') + case r < 0x20 || r == 0x7f: + // drop + default: + builder.WriteRune(r) + } + } + return strings.TrimSpace(builder.String()) +} + +// CreatedRequest carries the values shown to the visitor exactly once. +type CreatedRequest struct { + Request Request + + // Secret is the requester's 256-bit proof of authorship. It is placed in + // a URL fragment, never a path or query, so it cannot reach a server log + // or a proxy access log. + Secret string +} + +// CreateRequest stores a validated request and its outbox row in one +// transaction. The outbox is what makes the cross-database handoff safe: +// nothing is lost if the bridge is down, and a retry cannot duplicate the +// owner's proposal. +func (s *Store) CreateRequest(ctx context.Context, profile Profile, sessionValue string, input RequestInput, beyondHorizon bool, now time.Time) (CreatedRequest, error) { + if !profile.Grants.AllowRequests { + return CreatedRequest{}, fmt.Errorf("%w: this link does not accept requests", ErrRequestInvalid) + } + requestID, err := randomToken() + if err != nil { + return CreatedRequest{}, err + } + secret, err := randomToken() + if err != nil { + return CreatedRequest{}, err + } + payload, err := json.Marshal(requestSecrets{Handle: input.Handle, Message: input.Message}) + if err != nil { + return CreatedRequest{}, err + } + nonce := make([]byte, s.aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return CreatedRequest{}, err + } + ciphertext := s.aead.Seal(nil, nonce, payload, requestAAD(requestID, profile.ID)) + sessionHash := hashToken(sessionValue) + secretHash := hashToken(secret) + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return CreatedRequest{}, err + } + defer func() { _ = tx.Rollback() }() + + var pending int + if err := tx.QueryRowContext(ctx, + `SELECT COUNT(1) FROM portal_requests WHERE profile_id = ? AND status IN (?, ?)`, + profile.ID, RequestQueued, RequestPending).Scan(&pending); err != nil { + return CreatedRequest{}, err + } + if pending >= MaxPendingPerProfile { + return CreatedRequest{}, fmt.Errorf("%w: too many requests are already waiting", ErrRequestLimit) + } + + var daily int + if err := tx.QueryRowContext(ctx, + `SELECT COUNT(1) FROM portal_requests WHERE session_hash = ? AND created_at >= ?`, + sessionHash[:], formatTime(now.Add(-24*time.Hour))).Scan(&daily); err != nil { + return CreatedRequest{}, err + } + if daily >= RequestsPerSessionDay { + return CreatedRequest{}, fmt.Errorf("%w: you have reached today's request limit", ErrRequestLimit) + } + + if _, err := tx.ExecContext(ctx, `INSERT INTO portal_requests + (request_id, profile_id, session_hash, secret_hash, window_start, window_end, zone_id, + duration_minutes, beyond_horizon, status, created_at, updated_at, nonce, ciphertext) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + requestID, profile.ID, sessionHash[:], secretHash[:], + formatTime(input.WindowStart), formatTime(input.WindowEnd), input.ZoneID, + input.DurationMinutes, boolToInt(beyondHorizon), RequestQueued, + formatTime(now), formatTime(now), nonce, ciphertext); err != nil { + return CreatedRequest{}, fmt.Errorf("store portal request: %w", err) + } + // The idempotency key is the request id: the bridge may retry forever and + // still create exactly one proposal. + if _, err := tx.ExecContext(ctx, `INSERT INTO portal_outbox + (kind, request_id, idempotency_key, created_at) VALUES (?, ?, ?, ?)`, + outboxProposalSubmit, requestID, requestID, formatTime(now)); err != nil { + return CreatedRequest{}, fmt.Errorf("store portal outbox row: %w", err) + } + if err := tx.Commit(); err != nil { + return CreatedRequest{}, err + } + + return CreatedRequest{ + Secret: secret, + Request: Request{ + ID: requestID, + ProfileID: profile.ID, + Status: RequestQueued, + WindowStart: input.WindowStart, + WindowEnd: input.WindowEnd, + ZoneID: input.ZoneID, + DurationMinutes: input.DurationMinutes, + BeyondHorizon: beyondHorizon, + Handle: input.Handle, + Message: input.Message, + CreatedAt: now, + UpdatedAt: now, + }, + }, nil +} + +const outboxProposalSubmit = "proposal_submit" + +// ReadRequest returns a request scoped to one profile. Callers must already +// have proven the caller may see it; see AuthorizeRequestSecret. +func (s *Store) ReadRequest(ctx context.Context, profileID, requestID string) (Request, error) { + row := s.db.QueryRowContext(ctx, `SELECT request_id, profile_id, status, + window_start, window_end, zone_id, duration_minutes, beyond_horizon, + decided_start, decided_end, created_at, updated_at, nonce, ciphertext + FROM portal_requests WHERE request_id = ? AND profile_id = ?`, requestID, profileID) + return s.scanRequest(row) +} + +func (s *Store) scanRequest(row *sql.Row) (Request, error) { + var ( + request Request + beyondHorizon int + windowStart string + windowEnd string + decidedStart string + decidedEnd string + createdAt string + updatedAt string + nonce, ciphertext []byte + ) + if err := row.Scan(&request.ID, &request.ProfileID, &request.Status, + &windowStart, &windowEnd, &request.ZoneID, &request.DurationMinutes, &beyondHorizon, + &decidedStart, &decidedEnd, &createdAt, &updatedAt, &nonce, &ciphertext); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Request{}, ErrRequestNotFound + } + return Request{}, err + } + plaintext, err := s.aead.Open(nil, nonce, ciphertext, requestAAD(request.ID, request.ProfileID)) + if err != nil { + return Request{}, fmt.Errorf("decrypt portal request: %w", err) + } + var secrets requestSecrets + if err := json.Unmarshal(plaintext, &secrets); err != nil { + return Request{}, err + } + for target, raw := range map[*time.Time]string{ + &request.WindowStart: windowStart, + &request.WindowEnd: windowEnd, + &request.DecidedStart: decidedStart, + &request.DecidedEnd: decidedEnd, + &request.CreatedAt: createdAt, + &request.UpdatedAt: updatedAt, + } { + parsed, err := parseTime(raw) + if err != nil { + return Request{}, err + } + *target = parsed + } + request.BeyondHorizon = beyondHorizon == 1 + request.Handle = secrets.Handle + request.Message = secrets.Message + return request, nil +} + +// AuthorizeRequestSecret exchanges the one-time requester secret for a +// request-scoped session. Knowing the shared link and passcode is not enough +// to read someone else's request, so this is what separates two visitors who +// hold the same link. +func (s *Store) AuthorizeRequestSecret(ctx context.Context, profileID, requestID, secret string, now time.Time) (SessionToken, error) { + var storedHash []byte + err := s.db.QueryRowContext(ctx, + `SELECT secret_hash FROM portal_requests WHERE request_id = ? AND profile_id = ?`, + requestID, profileID).Scan(&storedHash) + if errors.Is(err, sql.ErrNoRows) { + return SessionToken{}, ErrRequestNotFound + } + if err != nil { + return SessionToken{}, err + } + presented := hashToken(secret) + if !constantTimeEqual(presented[:], storedHash) { + return SessionToken{}, ErrRequestNotFound + } + + sessionValue, err := randomToken() + if err != nil { + return SessionToken{}, err + } + sessionHash := hashToken(sessionValue) + expiresAt := now.Add(RequestSessionLifetime) + if _, err := s.db.ExecContext(ctx, + `INSERT INTO portal_request_sessions (session_hash, request_id, profile_id, expires_at) + VALUES (?, ?, ?, ?)`, + sessionHash[:], requestID, profileID, formatTime(expiresAt)); err != nil { + return SessionToken{}, err + } + return SessionToken{Session: sessionValue, ExpiresAt: expiresAt}, nil +} + +// AuthorizesRequest reports whether a request cookie proves authorship of one +// specific request under one specific profile. All three must agree, so a +// cookie for another visitor's request — or for the same request under a +// different link — grants nothing. +func (s *Store) AuthorizesRequest(ctx context.Context, sessionValue, profileID, requestID string, now time.Time) (bool, error) { + if sessionValue == "" || profileID == "" || requestID == "" { + return false, nil + } + sessionHash := hashToken(sessionValue) + var ( + storedRequestID string + storedProfileID string + expiresAtRaw string + ) + err := s.db.QueryRowContext(ctx, + `SELECT request_id, profile_id, expires_at FROM portal_request_sessions WHERE session_hash = ?`, + sessionHash[:]).Scan(&storedRequestID, &storedProfileID, &expiresAtRaw) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + if storedRequestID != requestID || storedProfileID != profileID { + return false, nil + } + expiresAt, err := parseTime(expiresAtRaw) + if err != nil { + return false, err + } + return expiresAt.After(now), nil +} + +func requestAAD(requestID, profileID string) []byte { + return []byte(strings.Join([]string{"portal-request", requestID, profileID}, "\x00")) +} diff --git a/apps/server/internal/portal/requests_test.go b/apps/server/internal/portal/requests_test.go new file mode 100644 index 0000000..153f04a --- /dev/null +++ b/apps/server/internal/portal/requests_test.go @@ -0,0 +1,479 @@ +package portal + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func (h *harness) requestHarness(t *testing.T) (string, Profile, string) { + t.Helper() + token, profile := h.createProfile(Grants{WakingWindows: true, AllowRequests: true}) + h.publish(profile.ID, h.sampleSnapshot()) + recorder := h.loginWithHeaders(token, testPasscode, map[string]string{"Sec-Fetch-Site": "same-origin"}) + if recorder.Code != http.StatusSeeOther { + t.Fatalf("login status = %d", recorder.Code) + } + return token, profile, h.sessionCookie(recorder) +} + +func (h *harness) submitRequest(t *testing.T, token, cookie string, form url.Values) *httptest.ResponseRecorder { + t.Helper() + form.Set("csrf", h.store.CSRFToken(cookie)) + request := httptest.NewRequest(http.MethodPost, "/p/"+token+"/requests", strings.NewReader(form.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("Sec-Fetch-Site", "same-origin") + request.RemoteAddr = "203.0.113.10:5555" + request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: cookie}) + recorder := httptest.NewRecorder() + h.handler.ServeHTTP(recorder, request) + return recorder +} + +func validRequestForm(now time.Time) url.Values { + start := now.Add(30 * time.Hour).UTC() + return url.Values{ + "zone_id": {"UTC"}, + "window_start": {start.Format("2006-01-02T15:04")}, + "window_end": {start.Add(4 * time.Hour).Format("2006-01-02T15:04")}, + "handle": {"Sam"}, + "message": {"Coffee?"}, + } +} + +func TestRequestRequiresTheGrant(t *testing.T) { + h := newHarness(t) + // The default fixture profile has WakingWindows but not AllowRequests. + h.publish(h.profile.ID, h.sampleSnapshot()) + cookie := h.sessionCookie(h.loginWithHeaders(h.token, testPasscode, map[string]string{"Sec-Fetch-Site": "same-origin"})) + + if recorder := h.get("/p/"+h.token+"/requests", cookie); recorder.Code != http.StatusForbidden { + t.Errorf("request form status = %d, want 403 without the grant", recorder.Code) + } + if recorder := h.submitRequest(t, h.token, cookie, validRequestForm(h.clock.Now())); recorder.Code != http.StatusForbidden { + t.Errorf("request POST status = %d, want 403 without the grant", recorder.Code) + } +} + +func TestRequestCreationRequiresCSRF(t *testing.T) { + h := newHarness(t) + token, _, cookie := h.requestHarness(t) + + form := validRequestForm(h.clock.Now()) + form.Set("csrf", "not-the-token") + request := httptest.NewRequest(http.MethodPost, "/p/"+token+"/requests", strings.NewReader(form.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("Sec-Fetch-Site", "same-origin") + request.RemoteAddr = "203.0.113.10:5555" + request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: cookie}) + recorder := httptest.NewRecorder() + h.handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403 for a wrong synchronizer token", recorder.Code) + } +} + +func TestRequestCreationShowsRecoveryCodeOnce(t *testing.T) { + h := newHarness(t) + token, profile, cookie := h.requestHarness(t) + + recorder := h.submitRequest(t, token, cookie, validRequestForm(h.clock.Now())) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String()) + } + body := recorder.Body.String() + if !strings.Contains(body, "Keep this code") { + t.Error("the one-time recovery code was not shown") + } + // A queued request must not claim it has been delivered. + if !strings.Contains(body, "on its way") { + t.Errorf("queued request did not describe itself honestly: %s", body) + } + + ids, err := h.store.ListRequestsForProfile(context.Background(), profile.ID) + if err != nil || len(ids) != 1 { + t.Fatalf("stored request count = %d err = %v", len(ids), err) + } + stored, err := h.store.ReadRequest(context.Background(), profile.ID, ids[0]) + if err != nil { + t.Fatalf("read: %v", err) + } + if stored.Status != RequestQueued { + t.Errorf("status = %q, want %q before the bridge confirms", stored.Status, RequestQueued) + } + if stored.Handle != "Sam" || stored.Message != "Coffee?" { + t.Errorf("visitor text was not preserved: %+v", stored) + } +} + +// TestRequestSecretIsNotInTheURLPath keeps the requester secret out of every +// place a server or proxy would log it. +func TestRequestSecretIsNotInTheURLPath(t *testing.T) { + h := newHarness(t) + token, _, cookie := h.requestHarness(t) + recorder := h.submitRequest(t, token, cookie, validRequestForm(h.clock.Now())) + body := recorder.Body.String() + + // The continue link must carry the secret only after a '#'. + index := strings.Index(body, "/requests/") + if index < 0 { + t.Fatal("no request link was rendered") + } + link := body[index:] + if end := strings.IndexAny(link, `"`); end >= 0 { + link = link[:end] + } + fragment := strings.Index(link, "#") + if fragment < 0 { + t.Fatalf("continue link has no fragment: %q", link) + } + if strings.Contains(link[:fragment], "s=") || strings.Contains(link[:fragment], "secret") { + t.Errorf("the requester secret appears before the fragment: %q", link) + } +} + +func TestRequestValidationRules(t *testing.T) { + now := time.Date(2026, 8, 3, 15, 0, 0, 0, time.UTC) + horizon := now.Add(7 * 24 * time.Hour) + base := func() RequestInput { + return RequestInput{ + WindowStart: now.Add(2 * time.Hour), + WindowEnd: now.Add(6 * time.Hour), + ZoneID: "UTC", + } + } + cases := map[string]func(*RequestInput){ + "end before start": func(i *RequestInput) { i.WindowEnd = i.WindowStart.Add(-time.Hour) }, + "window in past": func(i *RequestInput) { i.WindowStart = now.Add(-time.Hour) }, + "window over 8h": func(i *RequestInput) { i.WindowEnd = i.WindowStart.Add(9 * time.Hour) }, + "duration too low": func(i *RequestInput) { i.DurationMinutes = 5 }, + "duration too high": func(i *RequestInput) { i.DurationMinutes = 600 }, + "duration > window": func(i *RequestInput) { i.DurationMinutes = 300 }, + "unknown zone": func(i *RequestInput) { i.ZoneID = "Mars/Olympus" }, + "handle too long": func(i *RequestInput) { i.Handle = strings.Repeat("x", MaxHandleRunes+1) }, + "message too long": func(i *RequestInput) { i.Message = strings.Repeat("x", MaxMessageRunes+1) }, + } + for name, mutate := range cases { + input := base() + mutate(&input) + if _, _, err := ValidateRequest(input, now, horizon); err == nil { + t.Errorf("%s was accepted", name) + } else if !errors.Is(err, ErrRequestInvalid) { + t.Errorf("%s returned an untyped error: %v", name, err) + } + } + + valid, beyond, err := ValidateRequest(base(), now, horizon) + if err != nil { + t.Fatalf("a valid request was rejected: %v", err) + } + if beyond { + t.Error("a request inside the horizon was flagged beyond it") + } + if valid.ZoneID != "UTC" { + t.Errorf("zone = %q", valid.ZoneID) + } +} + +// TestBeyondHorizonIsAcceptedWithAWarning is owner decision 3: no product cap +// on how far ahead someone may ask, but the infeasibility must be stated. +func TestBeyondHorizonIsAcceptedWithAWarning(t *testing.T) { + now := time.Date(2026, 8, 3, 15, 0, 0, 0, time.UTC) + horizon := now.Add(7 * 24 * time.Hour) + input := RequestInput{ + WindowStart: now.Add(90 * 24 * time.Hour), + WindowEnd: now.Add(90*24*time.Hour + 3*time.Hour), + ZoneID: "UTC", + } + _, beyond, err := ValidateRequest(input, now, horizon) + if err != nil { + t.Fatalf("a far-future request was refused: %v", err) + } + if !beyond { + t.Fatal("a request past the horizon was not flagged") + } + + // And the flag must reach the visitor as an explicit warning. + h := newHarness(t) + token, _, cookie := h.requestHarness(t) + form := validRequestForm(h.clock.Now()) + start := h.clock.Now().Add(120 * 24 * time.Hour).UTC() + form.Set("window_start", start.Format("2006-01-02T15:04")) + form.Set("window_end", start.Add(3*time.Hour).Format("2006-01-02T15:04")) + + body := h.submitRequest(t, token, cookie, form).Body.String() + if !strings.Contains(body, "further ahead than this estimate can reach") { + t.Errorf("no infeasibility warning was shown: %s", body) + } +} + +func TestRequestSanitizesControlCharacters(t *testing.T) { + now := time.Date(2026, 8, 3, 15, 0, 0, 0, time.UTC) + input := RequestInput{ + WindowStart: now.Add(time.Hour), + WindowEnd: now.Add(4 * time.Hour), + ZoneID: "UTC", + Handle: "Sam\x00\x1b[31m", + Message: "line one\nline two\ttabbed", + } + validated, _, err := ValidateRequest(input, now, now.Add(48*time.Hour)) + if err != nil { + t.Fatalf("validate: %v", err) + } + if strings.ContainsAny(validated.Handle, "\x00\x1b") { + t.Errorf("control characters survived: %q", validated.Handle) + } + if strings.ContainsAny(validated.Message, "\n\t") { + t.Errorf("newlines and tabs were not folded: %q", validated.Message) + } + if !strings.Contains(validated.Message, "line one line two tabbed") { + t.Errorf("message content was mangled: %q", validated.Message) + } +} + +func TestRequestPerSessionDailyCap(t *testing.T) { + h := newHarness(t) + token, _, cookie := h.requestHarness(t) + + for i := 0; i < RequestsPerSessionDay; i++ { + if recorder := h.submitRequest(t, token, cookie, validRequestForm(h.clock.Now())); recorder.Code != http.StatusOK { + t.Fatalf("request %d status = %d", i, recorder.Code) + } + } + recorder := h.submitRequest(t, token, cookie, validRequestForm(h.clock.Now())) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status past the daily cap = %d, want 400", recorder.Code) + } + if !strings.Contains(recorder.Body.String(), "request limit") { + t.Errorf("the cap was not explained: %s", recorder.Body.String()) + } +} + +// TestRequestStatusRequiresProofOfAuthorship is the isolation property: +// holding the shared link and passcode is not enough to read someone else's +// request. +func TestRequestStatusRequiresProofOfAuthorship(t *testing.T) { + h := newHarness(t) + token, profile, cookie := h.requestHarness(t) + h.submitRequest(t, token, cookie, validRequestForm(h.clock.Now())) + ids, err := h.store.ListRequestsForProfile(context.Background(), profile.ID) + if err != nil || len(ids) != 1 { + t.Fatalf("setup: %v", err) + } + requestID := ids[0] + + // A second visitor with the same link but no requester cookie. + page := h.get("/p/"+token+"/requests/"+requestID, cookie) + if page.Code != http.StatusOK { + t.Fatalf("status = %d", page.Code) + } + body := page.Body.String() + if !strings.Contains(body, "Enter the code") { + t.Error("an unauthorized reader was not asked for the code") + } + for _, secret := range []string{"Sam", "Coffee?"} { + if strings.Contains(body, secret) { + t.Errorf("another visitor's request text %q was disclosed", secret) + } + } +} + +func TestRequestSecretExchangeGrantsAccess(t *testing.T) { + h := newHarness(t) + ctx := context.Background() + token, profile, cookie := h.requestHarness(t) + + created, err := h.store.CreateRequest(ctx, profile, cookie, RequestInput{ + WindowStart: h.clock.Now().Add(3 * time.Hour), + WindowEnd: h.clock.Now().Add(6 * time.Hour), + ZoneID: "UTC", + Handle: "Ada", + }, false, h.clock.Now()) + if err != nil { + t.Fatalf("create: %v", err) + } + + form := url.Values{"secret": {created.Secret}} + request := httptest.NewRequest(http.MethodPost, + "/p/"+token+"/requests/"+created.Request.ID+"/session", strings.NewReader(form.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("Sec-Fetch-Site", "same-origin") + request.RemoteAddr = "203.0.113.10:5555" + request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: cookie}) + recorder := httptest.NewRecorder() + h.handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusSeeOther { + t.Fatalf("exchange status = %d", recorder.Code) + } + var requestCookie string + for _, c := range recorder.Result().Cookies() { + if c.Name == requestCookieName { + requestCookie = c.Value + } + } + if requestCookie == "" { + t.Fatal("no request cookie was issued") + } + + page := httptest.NewRequest(http.MethodGet, "/p/"+token+"/requests/"+created.Request.ID, nil) + page.RemoteAddr = "203.0.113.10:5555" + page.AddCookie(&http.Cookie{Name: sessionCookieName, Value: cookie}) + page.AddCookie(&http.Cookie{Name: requestCookieName, Value: requestCookie}) + pageRecorder := httptest.NewRecorder() + h.handler.ServeHTTP(pageRecorder, page) + + if !strings.Contains(pageRecorder.Body.String(), "Ada") { + t.Errorf("the author could not read their own request: %s", pageRecorder.Body.String()) + } +} + +func TestWrongRequestSecretIsIndistinguishableFromUnknown(t *testing.T) { + h := newHarness(t) + ctx := context.Background() + token, profile, cookie := h.requestHarness(t) + created, err := h.store.CreateRequest(ctx, profile, cookie, RequestInput{ + WindowStart: h.clock.Now().Add(3 * time.Hour), + WindowEnd: h.clock.Now().Add(6 * time.Hour), + ZoneID: "UTC", + }, false, h.clock.Now()) + if err != nil { + t.Fatalf("create: %v", err) + } + + exchange := func(requestID, secret string) *httptest.ResponseRecorder { + form := url.Values{"secret": {secret}} + request := httptest.NewRequest(http.MethodPost, + "/p/"+token+"/requests/"+requestID+"/session", strings.NewReader(form.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("Sec-Fetch-Site", "same-origin") + request.RemoteAddr = "203.0.113.10:5555" + request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: cookie}) + recorder := httptest.NewRecorder() + h.handler.ServeHTTP(recorder, request) + return recorder + } + + wrongSecret := exchange(created.Request.ID, "definitely-not-the-secret") + unknownRequest := exchange("no-such-request-id", "definitely-not-the-secret") + if wrongSecret.Code != unknownRequest.Code { + t.Errorf("statuses differ: %d vs %d", wrongSecret.Code, unknownRequest.Code) + } + if wrongSecret.Body.String() != unknownRequest.Body.String() { + t.Error("bodies differ, so requests are enumerable") + } +} + +func TestRevocationClosesOpenRequests(t *testing.T) { + h := newHarness(t) + ctx := context.Background() + token, profile, cookie := h.requestHarness(t) + h.submitRequest(t, token, cookie, validRequestForm(h.clock.Now())) + + if err := h.store.RevokeProfile(ctx, profile.ID, h.clock.Now()); err != nil { + t.Fatalf("revoke: %v", err) + } + ids, err := h.store.ListRequestsForProfile(ctx, profile.ID) + if err != nil || len(ids) != 1 { + t.Fatalf("list: %v", err) + } + request, err := h.store.ReadRequest(ctx, profile.ID, ids[0]) + if err != nil { + t.Fatalf("read: %v", err) + } + if request.Status != RequestClosed { + t.Errorf("status after revocation = %q, want %q", request.Status, RequestClosed) + } +} + +func TestDeclinedRequestRevealsNoReason(t *testing.T) { + h := newHarness(t) + ctx := context.Background() + _, profile, cookie := h.requestHarness(t) + created, err := h.store.CreateRequest(ctx, profile, cookie, RequestInput{ + WindowStart: h.clock.Now().Add(3 * time.Hour), + WindowEnd: h.clock.Now().Add(6 * time.Hour), + ZoneID: "UTC", + }, false, h.clock.Now()) + if err != nil { + t.Fatalf("create: %v", err) + } + if err := h.store.ApplyDecision(ctx, created.Request.ID, RequestDeclined, time.Time{}, time.Time{}, h.clock.Now()); err != nil { + t.Fatalf("decline: %v", err) + } + stored, err := h.store.ReadRequest(ctx, profile.ID, created.Request.ID) + if err != nil { + t.Fatalf("read: %v", err) + } + view := h.handlerView(stored) + lowered := strings.ToLower(view.StatusLabel + " " + view.StatusDetail) + for _, forbidden := range []string{"asleep", "sleep", "busy", "calendar", "conflict", "medication", "because"} { + if strings.Contains(lowered, forbidden) { + t.Errorf("a declined request disclosed %q: %q", forbidden, view.StatusDetail) + } + } +} + +// handlerView exposes requestView for assertions without exporting it. +func (h *harness) handlerView(request Request) RequestView { + handler, err := NewHandler(HandlerConfig{ + Store: h.store, + Now: h.clock.Now, + PublicOrigin: testOrigin, + ResolutionFloor: time.Nanosecond, + }) + if err != nil { + h.t.Fatalf("handler: %v", err) + } + return handler.requestView(request) +} + +func TestApplyDecisionIsIdempotent(t *testing.T) { + h := newHarness(t) + ctx := context.Background() + _, profile, cookie := h.requestHarness(t) + created, err := h.store.CreateRequest(ctx, profile, cookie, RequestInput{ + WindowStart: h.clock.Now().Add(3 * time.Hour), + WindowEnd: h.clock.Now().Add(6 * time.Hour), + ZoneID: "UTC", + }, false, h.clock.Now()) + if err != nil { + t.Fatalf("create: %v", err) + } + slotStart := h.clock.Now().Add(4 * time.Hour) + slotEnd := slotStart.Add(time.Hour) + + for i := 0; i < 3; i++ { + if err := h.store.ApplyDecision(ctx, created.Request.ID, RequestApproved, slotStart, slotEnd, h.clock.Now()); err != nil { + t.Fatalf("apply %d: %v", i, err) + } + } + // A later contradicting decision must not overwrite a settled one. + if err := h.store.ApplyDecision(ctx, created.Request.ID, RequestDeclined, time.Time{}, time.Time{}, h.clock.Now()); err != nil { + t.Fatalf("second decision: %v", err) + } + stored, err := h.store.ReadRequest(ctx, profile.ID, created.Request.ID) + if err != nil { + t.Fatalf("read: %v", err) + } + if stored.Status != RequestApproved { + t.Errorf("status = %q, want the first decision to stand", stored.Status) + } + if !stored.DecidedStart.Equal(slotStart.UTC()) { + t.Errorf("decided start = %v, want %v", stored.DecidedStart, slotStart.UTC()) + } +} + +func TestApprovalRequiresAChosenBlock(t *testing.T) { + h := newHarness(t) + err := h.store.ApplyDecision(context.Background(), "any", RequestApproved, time.Time{}, time.Time{}, h.clock.Now()) + if err == nil { + t.Fatal("an approval without a chosen block was accepted") + } +} diff --git a/apps/server/internal/portal/store.go b/apps/server/internal/portal/store.go index 04793b6..f92f4a0 100644 --- a/apps/server/internal/portal/store.go +++ b/apps/server/internal/portal/store.go @@ -14,6 +14,7 @@ import ( "context" "crypto/aes" "crypto/cipher" + "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" @@ -112,8 +113,9 @@ type Snapshot struct { } type Store struct { - db *sql.DB - aead cipher.AEAD + db *sql.DB + aead cipher.AEAD + csrfKey []byte } // Open creates or opens the portal database. rootKey is the daemon data key; @@ -145,7 +147,8 @@ func Open(path string, rootKey []byte) (*Store, error) { return nil, err } db.SetMaxOpenConns(1) - store := &Store{db: db, aead: aead} + csrfKey := sha256.Sum256(append([]byte("zeitboard-portal-csrf\x00"), derived[:]...)) + store := &Store{db: db, aead: aead, csrfKey: csrfKey[:]} if err := store.migrate(context.Background()); err != nil { _ = db.Close() return nil, err @@ -190,7 +193,6 @@ func (s *Store) migrate(ctx context.Context) error { `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 )`, @@ -217,6 +219,48 @@ func (s *Store) migrate(ctx context.Context) error { key BLOB NOT NULL, created_at TEXT NOT NULL )`, + // Visitor requests. handle and message are private visitor text and + // live only inside the encrypted blob; no column holds them in the + // clear, so an index or a SELECT * cannot expose them by accident. + `CREATE TABLE IF NOT EXISTS portal_requests ( + request_id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL REFERENCES portal_profiles(profile_id) ON DELETE CASCADE, + session_hash BLOB NOT NULL, + secret_hash BLOB NOT NULL, + window_start TEXT NOT NULL, + window_end TEXT NOT NULL, + zone_id TEXT NOT NULL, + duration_minutes INTEGER NOT NULL DEFAULT 0, + beyond_horizon INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + decided_start TEXT NOT NULL DEFAULT '', + decided_end TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + nonce BLOB NOT NULL, + ciphertext BLOB NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_portal_requests_profile_status + ON portal_requests(profile_id, status)`, + `CREATE INDEX IF NOT EXISTS idx_portal_requests_session + ON portal_requests(session_hash, created_at)`, + // The transactional outbox. A row here means "durably accepted from + // the visitor, not yet confirmed by the owner's queue". + `CREATE TABLE IF NOT EXISTS portal_outbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + request_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '' + )`, + `CREATE TABLE IF NOT EXISTS portal_request_sessions ( + session_hash BLOB PRIMARY KEY, + request_id TEXT NOT NULL REFERENCES portal_requests(request_id) ON DELETE CASCADE, + profile_id TEXT NOT NULL, + expires_at TEXT NOT NULL + )`, } for _, statement := range statements { if _, err := s.db.ExecContext(ctx, statement); err != nil { @@ -459,6 +503,18 @@ func (s *Store) RevokeProfile(ctx context.Context, profileID string, now time.Ti if _, err := tx.ExecContext(ctx, `DELETE FROM portal_snapshots WHERE profile_id = ?`, profileID); err != nil { return err } + // Open requests are closed rather than deleted: the owner may still have a + // pending proposal referencing one, and a request that silently vanished + // would leave the visitor watching a status that never moves. + if _, err := tx.ExecContext(ctx, + `UPDATE portal_requests SET status = ?, updated_at = ? WHERE profile_id = ? AND status IN (?, ?)`, + RequestClosed, formatTime(now), profileID, RequestQueued, RequestPending); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, + `DELETE FROM portal_request_sessions WHERE profile_id = ?`, profileID); err != nil { + return err + } return tx.Commit() } @@ -604,28 +660,45 @@ func (s *Store) CreateSession(ctx context.Context, profile Profile, now time.Tim 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 { + (session_hash, profile_id, created_at, expires_at) VALUES (?, ?, ?, ?)`, + sessionHash[:], profile.ID, formatTime(now), formatTime(expiresAt)); err != nil { return SessionToken{}, fmt.Errorf("create portal session: %w", err) } - return SessionToken{Session: sessionValue, CSRF: csrfValue, ExpiresAt: expiresAt}, nil + return SessionToken{Session: sessionValue, CSRF: s.CSRFToken(sessionValue), ExpiresAt: expiresAt}, nil +} + +// CSRFToken derives the synchronizer token for a session rather than storing +// it. A server-rendered form has to embed the token on every page render, so +// the plaintext must be recoverable from the session cookie; storing only a +// hash would make it unrecoverable and the mechanism unusable. Deriving it +// under a key held by the server keeps it unguessable without persisting a +// second secret. +func (s *Store) CSRFToken(sessionValue string) string { + if sessionValue == "" { + return "" + } + mac := hmac.New(sha256.New, s.csrfKey) + _, _ = mac.Write([]byte(sessionValue)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +// MatchesCSRF compares a presented synchronizer token in constant time. +func (s *Store) MatchesCSRF(sessionValue, presented string) bool { + if sessionValue == "" || presented == "" { + return false + } + return constantTimeEqual([]byte(s.CSRFToken(sessionValue)), []byte(presented)) } // Session is a resolved, unexpired portal session. type Session struct { ProfileID string - CSRFHash []byte ExpiresAt time.Time } @@ -635,12 +708,12 @@ func (s *Store) ResolveSession(ctx context.Context, sessionValue string, now tim } sessionHash := hashToken(sessionValue) row := s.db.QueryRowContext(ctx, - `SELECT profile_id, csrf_hash, expires_at FROM portal_sessions WHERE session_hash = ?`, sessionHash[:]) + `SELECT profile_id, 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 err := row.Scan(&session.ProfileID, &expiresAtRaw); err != nil { if errors.Is(err, sql.ErrNoRows) { return Session{}, ErrSessionInvalid } @@ -657,13 +730,8 @@ func (s *Store) ResolveSession(ctx context.Context, sessionValue string, now tim 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 constantTimeEqual(left, right []byte) bool { + return subtle.ConstantTimeCompare(left, right) == 1 } func (s *Store) DeleteSession(ctx context.Context, sessionValue string) error { diff --git a/apps/server/internal/portal/view.go b/apps/server/internal/portal/view.go index 9bd7d3a..d6843e3 100644 --- a/apps/server/internal/portal/view.go +++ b/apps/server/internal/portal/view.go @@ -35,6 +35,11 @@ type AvailabilityView struct { Stale bool Unavailable bool ZoneLabel string + + // ZoneIDInput is the raw IANA zone the request form submits, so a + // visitor's chosen times are interpreted in the same zone the windows are + // displayed in rather than silently in UTC. + ZoneIDInput string } type WindowView struct { @@ -48,8 +53,12 @@ type WindowView struct { // later live layer cannot drift apart. func BuildView(snapshot Snapshot, now time.Time) AvailabilityView { view := AvailabilityView{ - Qualifier: Qualifier, - Notice: NoticeNotMedical, + Qualifier: Qualifier, + Notice: NoticeNotMedical, + ZoneIDInput: "UTC", + } + if len(snapshot.Windows) > 0 && snapshot.Windows[0].ZoneID != "" { + view.ZoneIDInput = snapshot.Windows[0].ZoneID } now = now.UTC() diff --git a/apps/server/internal/portalbridge/requests.go b/apps/server/internal/portalbridge/requests.go new file mode 100644 index 0000000..aa31a40 --- /dev/null +++ b/apps/server/internal/portalbridge/requests.go @@ -0,0 +1,140 @@ +package portalbridge + +import ( + "context" + "errors" + "fmt" + "time" + + "non24.app/server/internal/portal" + "non24.app/server/internal/store" +) + +// VisitorProposalTTL bounds how long a visitor's request waits for an answer +// before its decision token expires along with every other proposal. +const VisitorProposalTTL = 7 * 24 * time.Hour + +// RequestBridge carries visitor requests into the owner's proposal queue and +// carries decisions back. It is the only component holding both stores, and it +// is deliberately one-way per direction: the portal never learns anything +// about the private store beyond a status it was handed. +type RequestBridge struct { + Portal *portal.Store + Private *store.Store + Now func() time.Time + + // OwnerDevice attributes portal-created proposals to an enrolled device. + // Proposals are keyed to a creating device, and a visitor has none, so the + // bridge borrows the owner's earliest enrolled device. + OwnerDevice func(ctx context.Context) (string, error) +} + +func (b RequestBridge) now() time.Time { + if b.Now != nil { + return b.Now().UTC() + } + return time.Now().UTC() +} + +// Pump runs one full cycle in both directions. It is safe to call repeatedly; +// every step is idempotent. +func (b RequestBridge) Pump(ctx context.Context) error { + if err := b.submitRequests(ctx); err != nil { + return err + } + return b.deliverDecisions(ctx) +} + +// submitRequests turns queued portal requests into pending private proposals. +// A failure leaves the request in `queued`, which is what the visitor sees: +// "waiting to reach them", not a false "they have it". +func (b RequestBridge) submitRequests(ctx context.Context) error { + entries, err := b.Portal.PendingOutbox(ctx, 50) + if err != nil { + return fmt.Errorf("read portal outbox: %w", err) + } + if len(entries) == 0 { + return nil + } + deviceID, err := b.ownerDevice(ctx) + if err != nil { + // Without an enrolled device there is nowhere to file the proposal. + // Leaving the rows queued is correct: they are not lost, and the + // visitor is not told the owner received something they did not. + return fmt.Errorf("resolve owner device: %w", err) + } + + var failures []error + for _, entry := range entries { + if entry.Kind != "proposal_submit" { + continue + } + now := b.now() + _, err := b.Private.CreateVisitorProposal(ctx, store.VisitorRequestInput{ + PortalRequestID: entry.Request.ID, + ProfileID: entry.Request.ProfileID, + DeviceID: deviceID, + WindowStart: entry.Request.WindowStart, + WindowEnd: entry.Request.WindowEnd, + ZoneID: entry.Request.ZoneID, + DurationMinutes: entry.Request.DurationMinutes, + BeyondHorizon: entry.Request.BeyondHorizon, + Handle: entry.Request.Handle, + Message: entry.Request.Message, + CreatedAt: entry.Request.CreatedAt, + ExpiresAt: now.Add(VisitorProposalTTL), + }) + if err != nil { + if noteErr := b.Portal.NoteOutboxFailure(ctx, entry.ID, err.Error()); noteErr != nil { + return noteErr + } + failures = append(failures, err) + continue + } + if err := b.Portal.AckOutbox(ctx, entry.ID, entry.Request.ID, now); err != nil { + // The proposal exists but the acknowledgement did not land. The + // next pass re-submits, finds the existing proposal by its portal + // request id, and acknowledges then. + return fmt.Errorf("acknowledge portal outbox: %w", err) + } + } + return errors.Join(failures...) +} + +// deliverDecisions applies owner decisions to the portal store exactly once. +func (b RequestBridge) deliverDecisions(ctx context.Context) error { + entries, err := b.Private.PendingPortalStatus(ctx, 50) + if err != nil { + return fmt.Errorf("read portal status outbox: %w", err) + } + for _, entry := range entries { + status := portal.RequestDeclined + if entry.Status == "approved" { + status = portal.RequestApproved + } + if err := b.Portal.ApplyDecision(ctx, entry.PortalRequestID, status, + entry.DecidedStart, entry.DecidedEnd, b.now()); err != nil { + return fmt.Errorf("apply portal decision: %w", err) + } + if err := b.Private.AckPortalStatus(ctx, entry.ID); err != nil { + return fmt.Errorf("acknowledge portal status: %w", err) + } + } + return nil +} + +func (b RequestBridge) ownerDevice(ctx context.Context) (string, error) { + if b.OwnerDevice != nil { + return b.OwnerDevice(ctx) + } + devices, err := b.Private.ListDevices(ctx) + if err != nil { + return "", err + } + for _, device := range devices { + if device.RevokedAt == nil { + return device.ID, nil + } + } + return "", errors.New("no enrolled device is available to receive visitor requests") +} diff --git a/apps/server/internal/portalbridge/requests_test.go b/apps/server/internal/portalbridge/requests_test.go new file mode 100644 index 0000000..f3cf970 --- /dev/null +++ b/apps/server/internal/portalbridge/requests_test.go @@ -0,0 +1,390 @@ +package portalbridge_test + +import ( + "bytes" + "context" + "errors" + "path/filepath" + "testing" + "time" + + "non24.app/core/domain" + "non24.app/server/internal/auth" + "non24.app/server/internal/portal" + "non24.app/server/internal/portalbridge" + "non24.app/server/internal/store" +) + +type bridgeFixture struct { + private *store.Store + portal *portal.Store + bridge portalbridge.RequestBridge + profile portal.Profile + session string + now time.Time +} + +func newBridgeFixture(t *testing.T) *bridgeFixture { + 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, "private.db"), bytes.Repeat([]byte{5}, 32)) + if err != nil { + t.Fatalf("open private: %v", err) + } + t.Cleanup(func() { _ = private.Close() }) + if err := private.RegisterDevice(ctx, "dev_owner", "desktop", auth.HashToken("tok"), now.Add(-time.Hour)); err != nil { + t.Fatalf("register device: %v", err) + } + + portalStore, err := portal.Open(filepath.Join(dir, "portal.db"), bytes.Repeat([]byte{5}, 32)) + if err != nil { + t.Fatalf("open portal: %v", err) + } + t.Cleanup(func() { _ = portalStore.Close() }) + + profileID, _ := portal.NewProfileID() + linkToken, _ := portal.NewLinkToken() + if err := portalStore.CreateProfile(ctx, portal.CreateProfileInput{ + ProfileID: profileID, + Token: linkToken, + Passcode: testPasscode, + Grants: portal.Grants{WakingWindows: true, AllowRequests: true}, + CreatedAt: now, + ExpiresAt: now.Add(30 * 24 * time.Hour), + }); err != nil { + t.Fatalf("create profile: %v", err) + } + profile, err := portalStore.ResolveLink(ctx, linkToken, now) + if err != nil { + t.Fatalf("resolve: %v", err) + } + session, err := portalStore.CreateSession(ctx, profile, now) + if err != nil { + t.Fatalf("session: %v", err) + } + + return &bridgeFixture{ + private: private, + portal: portalStore, + profile: profile, + session: session.Session, + now: now, + bridge: portalbridge.RequestBridge{ + Portal: portalStore, + Private: private, + Now: func() time.Time { return now }, + }, + } +} + +func (f *bridgeFixture) createRequest(t *testing.T, duration int) portal.CreatedRequest { + t.Helper() + created, err := f.portal.CreateRequest(context.Background(), f.profile, f.session, portal.RequestInput{ + WindowStart: f.now.Add(30 * time.Hour), + WindowEnd: f.now.Add(34 * time.Hour), + ZoneID: "America/New_York", + DurationMinutes: duration, + Handle: "Sam", + Message: "coffee?", + }, false, f.now) + if err != nil { + t.Fatalf("create request: %v", err) + } + return created +} + +func (f *bridgeFixture) request(t *testing.T, id string) portal.Request { + t.Helper() + request, err := f.portal.ReadRequest(context.Background(), f.profile.ID, id) + if err != nil { + t.Fatalf("read request: %v", err) + } + return request +} + +// visitorProposal finds the pending proposal the bridge created. +func (f *bridgeFixture) visitorProposal(t *testing.T) store.ProposalRecord { + t.Helper() + page, err := f.private.ListProposalPage(context.Background(), store.ProposalPageCursor{}, 50, f.now) + if err != nil { + t.Fatalf("list proposals: %v", err) + } + for _, record := range page.Records { + if record.ActionID == store.ActionVisitorRequest { + return record + } + } + t.Fatal("no visitor proposal was created") + return store.ProposalRecord{} +} + +func TestRequestReachesTheOwnerQueue(t *testing.T) { + f := newBridgeFixture(t) + created := f.createRequest(t, 60) + + if got := f.request(t, created.Request.ID).Status; got != portal.RequestQueued { + t.Fatalf("status before the pump = %q, want %q", got, portal.RequestQueued) + } + if err := f.bridge.Pump(context.Background()); err != nil { + t.Fatalf("pump: %v", err) + } + if got := f.request(t, created.Request.ID).Status; got != portal.RequestPending { + t.Errorf("status after the pump = %q, want %q", got, portal.RequestPending) + } + + record := f.visitorProposal(t) + payload, err := store.VisitorProposalPayloadOf(record) + if err != nil { + t.Fatalf("decode payload: %v", err) + } + if payload.Origin != "visitor" { + t.Errorf("origin = %q, want visitor", payload.Origin) + } + if payload.PortalRequestID != created.Request.ID { + t.Error("the proposal is not linked to its portal request") + } + // The owner must see what was actually asked, including the visitor's own + // words; that is the point of the request. + if payload.Handle != "Sam" || payload.Message != "coffee?" { + t.Errorf("visitor text did not reach the owner: %+v", payload) + } +} + +// TestBridgeFailureLeavesTheRequestQueued is the honesty property: with no +// enrolled device there is nowhere to file the request, and the visitor must +// not be told it was delivered. +func TestBridgeFailureLeavesTheRequestQueued(t *testing.T) { + f := newBridgeFixture(t) + f.bridge.OwnerDevice = func(context.Context) (string, error) { + return "", errors.New("backend unavailable") + } + created := f.createRequest(t, 0) + + if err := f.bridge.Pump(context.Background()); err == nil { + t.Fatal("a failing bridge reported success") + } + if got := f.request(t, created.Request.ID).Status; got != portal.RequestQueued { + t.Errorf("status = %q, want it to stay %q", got, portal.RequestQueued) + } + + // Recovery: once a device is available the same request goes through. + f.bridge.OwnerDevice = nil + if err := f.bridge.Pump(context.Background()); err != nil { + t.Fatalf("recovery pump: %v", err) + } + if got := f.request(t, created.Request.ID).Status; got != portal.RequestPending { + t.Errorf("status after recovery = %q, want %q", got, portal.RequestPending) + } +} + +// TestBridgeSubmissionIsIdempotent covers the retry that matters: the private +// commit succeeded but the acknowledgement was lost. +func TestBridgeSubmissionIsIdempotent(t *testing.T) { + f := newBridgeFixture(t) + created := f.createRequest(t, 0) + ctx := context.Background() + + for i := 0; i < 4; i++ { + if err := f.bridge.Pump(ctx); err != nil { + t.Fatalf("pump %d: %v", i, err) + } + } + page, err := f.private.ListProposalPage(ctx, store.ProposalPageCursor{}, 50, f.now) + if err != nil { + t.Fatalf("list: %v", err) + } + count := 0 + for _, record := range page.Records { + if record.ActionID == store.ActionVisitorRequest { + count++ + } + } + if count != 1 { + t.Errorf("visitor proposal count = %d, want exactly 1 after repeated pumps", count) + } + _ = created +} + +func TestApprovalMustChooseASlotInsideTheWindow(t *testing.T) { + f := newBridgeFixture(t) + created := f.createRequest(t, 60) + ctx := context.Background() + if err := f.bridge.Pump(ctx); err != nil { + t.Fatalf("pump: %v", err) + } + record := f.visitorProposal(t) + + cases := map[string]store.VisitorSlot{ + "before the window": {StartAt: f.now.Add(20 * time.Hour), EndAt: f.now.Add(21 * time.Hour)}, + "after the window": {StartAt: f.now.Add(40 * time.Hour), EndAt: f.now.Add(41 * time.Hour)}, + "straddling the end": { + StartAt: f.now.Add(33*time.Hour + 30*time.Minute), + EndAt: f.now.Add(34*time.Hour + 30*time.Minute), + }, + "wrong duration": {StartAt: f.now.Add(31 * time.Hour), EndAt: f.now.Add(31*time.Hour + 30*time.Minute)}, + "empty": {}, + "inverted": {StartAt: f.now.Add(32 * time.Hour), EndAt: f.now.Add(31 * time.Hour)}, + } + for name, slot := range cases { + _, err := f.private.DecideVisitorProposal(ctx, record.ID, "dev_owner", + store.ProposalApproved, record.DecisionToken, slot, f.now) + if err == nil { + t.Errorf("%s slot was accepted", name) + continue + } + if !errors.Is(err, store.ErrVisitorSlotOutOfWindow) { + t.Errorf("%s returned an untyped error: %v", name, err) + } + } + + // The valid slot: inside the window and exactly the requested length. + good := store.VisitorSlot{StartAt: f.now.Add(31 * time.Hour), EndAt: f.now.Add(32 * time.Hour)} + if _, err := f.private.DecideVisitorProposal(ctx, record.ID, "dev_owner", + store.ProposalApproved, record.DecisionToken, good, f.now); err != nil { + t.Fatalf("a valid slot was refused: %v", err) + } + + if err := f.bridge.Pump(ctx); err != nil { + t.Fatalf("deliver: %v", err) + } + request := f.request(t, created.Request.ID) + if request.Status != portal.RequestApproved { + t.Errorf("status = %q, want approved", request.Status) + } + if !request.DecidedStart.Equal(good.StartAt.UTC()) || !request.DecidedEnd.Equal(good.EndAt.UTC()) { + t.Errorf("the visitor was told a different block: %v-%v", request.DecidedStart, request.DecidedEnd) + } +} + +// TestDecisionTokenIsOneUse holds the queue's core invariant on the visitor +// path too: two decisions race, and only one can commit. +func TestDecisionTokenIsOneUse(t *testing.T) { + f := newBridgeFixture(t) + f.createRequest(t, 0) + ctx := context.Background() + if err := f.bridge.Pump(ctx); err != nil { + t.Fatalf("pump: %v", err) + } + record := f.visitorProposal(t) + slot := store.VisitorSlot{StartAt: f.now.Add(31 * time.Hour), EndAt: f.now.Add(32 * time.Hour)} + + if _, err := f.private.DecideVisitorProposal(ctx, record.ID, "dev_owner", + store.ProposalApproved, record.DecisionToken, slot, f.now); err != nil { + t.Fatalf("first decision: %v", err) + } + _, err := f.private.DecideVisitorProposal(ctx, record.ID, "dev_owner", + store.ProposalRejected, record.DecisionToken, store.VisitorSlot{}, f.now) + if err == nil { + t.Fatal("the decision token was accepted twice") + } + if !errors.Is(err, store.ErrUsedApprovalToken) && !errors.Is(err, store.ErrProposalNotPending) { + t.Errorf("second decision returned an unexpected error: %v", err) + } +} + +func TestDeclineDeliversWithoutASlot(t *testing.T) { + f := newBridgeFixture(t) + created := f.createRequest(t, 0) + ctx := context.Background() + if err := f.bridge.Pump(ctx); err != nil { + t.Fatalf("pump: %v", err) + } + record := f.visitorProposal(t) + + if _, err := f.private.DecideVisitorProposal(ctx, record.ID, "dev_owner", + store.ProposalRejected, record.DecisionToken, store.VisitorSlot{}, f.now); err != nil { + t.Fatalf("decline: %v", err) + } + if err := f.bridge.Pump(ctx); err != nil { + t.Fatalf("deliver: %v", err) + } + request := f.request(t, created.Request.ID) + if request.Status != portal.RequestDeclined { + t.Errorf("status = %q, want declined", request.Status) + } + if !request.DecidedStart.IsZero() { + t.Error("a declined request carries a chosen block") + } +} + +// TestVisitorProposalsAreNotAnAgentAction is the anti-forgery property: an +// agent must not be able to mint a proposal that presents itself as coming +// from an outside person. +func TestVisitorProposalsAreNotAnAgentAction(t *testing.T) { + for _, agentAction := range []string{"answer_only", "propose_move_task", "propose_place_task", "propose_reminder_shift"} { + if agentAction == store.ActionVisitorRequest { + t.Fatalf("%s is exposed on the agent action surface", agentAction) + } + } + if store.ActionVisitorRequest != "place_visitor_request" { + t.Fatalf("unexpected visitor action id %q", store.ActionVisitorRequest) + } +} + +// TestDecideRejectsNonVisitorProposals keeps the slot rules from being applied +// to — or bypassed on — an ordinary proposal. +func TestDecideRejectsNonVisitorProposals(t *testing.T) { + f := newBridgeFixture(t) + ctx := context.Background() + record, err := f.private.CreateProposal(ctx, store.ProposalInput{ + ID: "proposal-plain", + ActionID: "propose_place_task", + DeviceID: "dev_owner", + CreatedAt: f.now, + ExpiresAt: f.now.Add(time.Hour), + Payload: []byte(`{"kind":"task"}`), + }) + if err != nil { + t.Fatalf("create: %v", err) + } + _, err = f.private.DecideVisitorProposal(ctx, record.ID, "dev_owner", + store.ProposalApproved, record.DecisionToken, store.VisitorSlot{ + StartAt: f.now, EndAt: f.now.Add(time.Hour), + }, f.now) + if !errors.Is(err, store.ErrNotVisitorProposal) { + t.Errorf("error = %v, want ErrNotVisitorProposal", err) + } +} + +// TestVisitorTextNeverReachesTheAvailabilityProjection keeps the request path +// separate from the public projection: a message is for the owner, not for +// every other holder of the link. +func TestVisitorTextNeverReachesTheAvailabilityProjection(t *testing.T) { + f := newBridgeFixture(t) + ctx := context.Background() + f.createRequest(t, 0) + if err := f.bridge.Pump(ctx); err != nil { + t.Fatalf("pump: %v", err) + } + materializer := portalbridge.Materializer{ + Sleep: emptySleep{}, + Profiles: f.portal, + Sink: f.portal, + Now: func() time.Time { return f.now }, + } + if err := materializer.MaterializeAll(ctx); err != nil { + t.Fatalf("materialize: %v", err) + } + snapshot, err := f.portal.ReadSnapshot(ctx, f.profile.ID) + if err != nil { + t.Fatalf("read snapshot: %v", err) + } + for _, window := range snapshot.Windows { + if window.ZoneID == "Sam" || window.ZoneID == "coffee?" { + t.Error("visitor text reached the projection") + } + } + if snapshot.Status == portal.StatusAvailable { + t.Error("an empty history produced availability") + } +} + +// emptySleep stands in for a read model with no history. +type emptySleep struct{} + +func (emptySleep) EffectiveSleepSessions(context.Context) ([]domain.SleepSession, error) { + return nil, nil +} diff --git a/apps/server/internal/store/store.go b/apps/server/internal/store/store.go index 1b20c9d..91d775e 100644 --- a/apps/server/internal/store/store.go +++ b/apps/server/internal/store/store.go @@ -217,6 +217,26 @@ func (s *Store) Migrate(ctx context.Context) error { nonce BLOB NOT NULL, ciphertext BLOB NOT NULL )`, + // The unique portal_request_id is what makes bridge submission + // idempotent: a retry after a lost acknowledgement finds the existing + // proposal instead of creating a second one. + `CREATE TABLE IF NOT EXISTS portal_request_proposals ( + portal_request_id TEXT PRIMARY KEY, + proposal_id TEXT NOT NULL UNIQUE REFERENCES proposals(id), + profile_id TEXT NOT NULL, + created_at TEXT NOT NULL + )`, + // Decisions bound for the portal store. Written inside the same + // transaction as the decision itself, so a decision the visitor never + // learns about cannot happen without losing the decision too. + `CREATE TABLE IF NOT EXISTS portal_status_outbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + portal_request_id TEXT NOT NULL, + status TEXT NOT NULL, + decided_start TEXT NOT NULL DEFAULT '', + decided_end TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL + )`, } for _, statement := range statements { if _, err := s.db.ExecContext(ctx, statement); err != nil { @@ -822,7 +842,17 @@ func proposalIsActiveAt(record ProposalRecord, at time.Time) bool { return record.Status == ProposalPending && record.ExpiresAt.After(at.UTC()) } +// decideHook runs inside the decision transaction, after the proposal row is +// updated and the one-use nonce consumed but before commit. It exists so a +// caller can bind extra state to the same atomic decision — the portal binds +// the visitor-status handoff — without reimplementing the token checks. +type decideHook func(ctx context.Context, tx *sql.Tx, record ProposalRecord, decision ProposalStatus, decidedAt time.Time) error + func (s *Store) DecideProposal(ctx context.Context, proposalID, deviceID string, decision ProposalStatus, token string, decidedAt time.Time, audit json.RawMessage) (ProposalRecord, error) { + return s.decideProposal(ctx, proposalID, deviceID, decision, token, decidedAt, audit, nil) +} + +func (s *Store) decideProposal(ctx context.Context, proposalID, deviceID string, decision ProposalStatus, token string, decidedAt time.Time, audit json.RawMessage, hook decideHook) (ProposalRecord, error) { if decision != ProposalApproved && decision != ProposalRejected { return ProposalRecord{}, errors.New("unsupported proposal decision") } @@ -902,6 +932,11 @@ func (s *Store) DecideProposal(ctx context.Context, proposalID, deviceID string, if err := s.appendAuditTx(ctx, tx, "proposal."+string(decision), proposalID, deviceID, decidedAt, audit); err != nil { return ProposalRecord{}, err } + if hook != nil { + if err := hook(ctx, tx, record, decision, decidedAt); err != nil { + return ProposalRecord{}, err + } + } if err := tx.Commit(); err != nil { return ProposalRecord{}, err } @@ -983,6 +1018,19 @@ func (s *Store) decodeProposal(record ProposalRecord, status, createdAt, updated return record, nil } +// ProposalByID reads one proposal. The returned record carries the decrypted +// payload but no decision token: minting a token is a separate, listed action. +func (s *Store) ProposalByID(ctx context.Context, id string) (ProposalRecord, error) { + record, err := s.scanProposal(s.db.QueryRowContext(ctx, + `SELECT id, action_id, device_id, status, created_at, updated_at, expires_at, nonce, ciphertext FROM proposals WHERE id = ?`, + id, + )) + if errors.Is(err, sql.ErrNoRows) { + return ProposalRecord{}, ErrProposalNotFound + } + return record, err +} + func (s *Store) proposalByIDTx(ctx context.Context, tx *sql.Tx, id string) (ProposalRecord, error) { record, err := s.scanProposal(tx.QueryRowContext(ctx, `SELECT id, action_id, device_id, status, created_at, updated_at, expires_at, nonce, ciphertext FROM proposals WHERE id = ?`, diff --git a/apps/server/internal/store/visitor_requests.go b/apps/server/internal/store/visitor_requests.go new file mode 100644 index 0000000..b603251 --- /dev/null +++ b/apps/server/internal/store/visitor_requests.go @@ -0,0 +1,332 @@ +package store + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +// ActionVisitorRequest is the action id of a proposal that originated from a +// share-link visitor rather than from the user or an agent. +// +// It is deliberately absent from the assistant's action registry. An agent +// must not be able to mint a proposal that presents itself as coming from an +// outside person: that would let a model manufacture social pressure the user +// cannot verify. Only the portal bridge creates these. +const ActionVisitorRequest = "place_visitor_request" + +var ( + ErrVisitorRequestExists = errors.New("visitor request already has a proposal") + ErrVisitorSlotOutOfWindow = errors.New("chosen block is outside the requested window") + ErrNotVisitorProposal = errors.New("proposal did not originate from a visitor") +) + +// VisitorRequestInput is what the portal bridge hands over. Handle and message +// are visitor-authored private text; they are stored inside the proposal's +// encrypted payload so the owner can read them, and they never travel onward +// to a provider, projection, or notification body. +type VisitorRequestInput struct { + PortalRequestID string + ProfileID string + DeviceID string + WindowStart time.Time + WindowEnd time.Time + ZoneID string + DurationMinutes int + BeyondHorizon bool + Handle string + Message string + CreatedAt time.Time + ExpiresAt time.Time +} + +// VisitorProposalPayload is the decrypted proposal body. +type VisitorProposalPayload struct { + ProposalID string `json:"proposalId"` + ActionID string `json:"actionId"` + Origin string `json:"origin"` + PortalRequestID string `json:"portalRequestId"` + ProfileID string `json:"profileId"` + WindowStart time.Time `json:"windowStartAt"` + WindowEnd time.Time `json:"windowEndAt"` + ZoneID string `json:"zoneId"` + DurationMinutes int `json:"durationMinutes,omitempty"` + BeyondHorizon bool `json:"beyondHorizon"` + Handle string `json:"handle,omitempty"` + Message string `json:"message,omitempty"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt time.Time `json:"expiresAt"` +} + +// VisitorSlot is the exact block the owner picked inside the requested window. +type VisitorSlot struct { + StartAt time.Time + EndAt time.Time +} + +// CreateVisitorProposal is idempotent on the portal request id. The bridge may +// retry after any failure, including one where the private commit succeeded +// but the acknowledgement was lost, and still produce exactly one proposal. +func (s *Store) CreateVisitorProposal(ctx context.Context, input VisitorRequestInput) (ProposalRecord, error) { + if input.PortalRequestID == "" || input.ProfileID == "" || input.DeviceID == "" { + return ProposalRecord{}, errors.New("visitor proposal requires a portal request, profile, and device") + } + if !input.WindowEnd.After(input.WindowStart) { + return ProposalRecord{}, errors.New("visitor request window must be non-empty") + } + + if existing, err := s.visitorProposalIDFor(ctx, input.PortalRequestID); err == nil { + record, err := s.ProposalByID(ctx, existing) + if err != nil { + return ProposalRecord{}, err + } + return record, nil + } else if !errors.Is(err, sql.ErrNoRows) { + return ProposalRecord{}, err + } + + proposalID, err := newVisitorProposalID() + if err != nil { + return ProposalRecord{}, err + } + payload, err := json.Marshal(VisitorProposalPayload{ + ProposalID: proposalID, + ActionID: ActionVisitorRequest, + Origin: "visitor", + PortalRequestID: input.PortalRequestID, + ProfileID: input.ProfileID, + WindowStart: input.WindowStart.UTC(), + WindowEnd: input.WindowEnd.UTC(), + ZoneID: input.ZoneID, + DurationMinutes: input.DurationMinutes, + BeyondHorizon: input.BeyondHorizon, + Handle: input.Handle, + Message: input.Message, + CreatedAt: input.CreatedAt.UTC(), + ExpiresAt: input.ExpiresAt.UTC(), + }) + if err != nil { + return ProposalRecord{}, err + } + + record, err := s.CreateProposal(ctx, ProposalInput{ + ID: proposalID, + ActionID: ActionVisitorRequest, + DeviceID: input.DeviceID, + CreatedAt: input.CreatedAt, + ExpiresAt: input.ExpiresAt, + Payload: payload, + // The audit records the origin, never the visitor's text. + Audit: json.RawMessage(`{"source":"portal","event":"proposal_created","origin":"visitor"}`), + }) + if err != nil { + return ProposalRecord{}, err + } + if _, err := s.db.ExecContext(ctx, + `INSERT INTO portal_request_proposals (portal_request_id, proposal_id, profile_id, created_at) + VALUES (?, ?, ?, ?)`, + input.PortalRequestID, proposalID, input.ProfileID, input.CreatedAt.UTC().Format(time.RFC3339Nano)); err != nil { + return ProposalRecord{}, fmt.Errorf("link visitor proposal: %w", err) + } + return record, nil +} + +func (s *Store) visitorProposalIDFor(ctx context.Context, portalRequestID string) (string, error) { + var proposalID string + err := s.db.QueryRowContext(ctx, + `SELECT proposal_id FROM portal_request_proposals WHERE portal_request_id = ?`, + portalRequestID).Scan(&proposalID) + return proposalID, err +} + +// DecideVisitorProposal records the owner's answer. Approval requires an exact +// block inside the requested window: the visitor asked for a range, and the +// owner is the only party who may narrow it to a time. +// +// The decision, the one-use token consumption, and the status handoff row all +// commit together, so a decision the visitor never learns about is impossible +// without also losing the decision itself. +func (s *Store) DecideVisitorProposal( + ctx context.Context, + proposalID, deviceID string, + decision ProposalStatus, + token string, + slot VisitorSlot, + decidedAt time.Time, +) (ProposalRecord, error) { + record, err := s.ProposalByID(ctx, proposalID) + if err != nil { + return ProposalRecord{}, err + } + if record.ActionID != ActionVisitorRequest { + return ProposalRecord{}, ErrNotVisitorProposal + } + var payload VisitorProposalPayload + if err := json.Unmarshal(record.Payload, &payload); err != nil { + return ProposalRecord{}, err + } + + status := statusForDecision(decision) + if decision == ProposalApproved { + if err := validateVisitorSlot(payload, slot); err != nil { + return ProposalRecord{}, err + } + } else { + slot = VisitorSlot{} + } + + audit, err := json.Marshal(map[string]string{ + "source": "portal", + "event": "visitor_request_" + status, + "origin": "visitor", + }) + if err != nil { + return ProposalRecord{}, err + } + + return s.decideProposal(ctx, proposalID, deviceID, decision, token, decidedAt, audit, + func(ctx context.Context, tx *sql.Tx, _ ProposalRecord, _ ProposalStatus, at time.Time) error { + _, err := tx.ExecContext(ctx, `INSERT INTO portal_status_outbox + (portal_request_id, status, decided_start, decided_end, created_at) + VALUES (?, ?, ?, ?, ?)`, + payload.PortalRequestID, status, + formatOptionalTime(slot.StartAt), formatOptionalTime(slot.EndAt), + at.UTC().Format(time.RFC3339Nano)) + return err + }) +} + +// validateVisitorSlot enforces the one substantive rule of approval: the owner +// may pick any block inside the window the visitor asked for, and nothing +// outside it. A counteroffer needs a new request, not a silently moved time. +func validateVisitorSlot(payload VisitorProposalPayload, slot VisitorSlot) error { + if slot.StartAt.IsZero() || slot.EndAt.IsZero() { + return fmt.Errorf("%w: approval requires a chosen block", ErrVisitorSlotOutOfWindow) + } + start := slot.StartAt.UTC() + end := slot.EndAt.UTC() + if !end.After(start) { + return fmt.Errorf("%w: the block must end after it starts", ErrVisitorSlotOutOfWindow) + } + if start.Before(payload.WindowStart) || end.After(payload.WindowEnd) { + return ErrVisitorSlotOutOfWindow + } + if payload.DurationMinutes > 0 { + // A supplied duration must be preserved: the visitor asked for a + // meeting of that length, and shortening it silently changes the ask. + wanted := time.Duration(payload.DurationMinutes) * time.Minute + if end.Sub(start) != wanted { + return fmt.Errorf("%w: the block must be exactly %d minutes", ErrVisitorSlotOutOfWindow, payload.DurationMinutes) + } + } + return nil +} + +func statusForDecision(decision ProposalStatus) string { + if decision == ProposalApproved { + return "approved" + } + return "declined" +} + +// PortalStatusEntry is one undelivered decision bound for the portal store. +type PortalStatusEntry struct { + ID int64 + PortalRequestID string + Status string + DecidedStart time.Time + DecidedEnd time.Time +} + +// PendingPortalStatus lists decisions the portal has not yet applied. +func (s *Store) PendingPortalStatus(ctx context.Context, limit int) ([]PortalStatusEntry, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, + `SELECT id, portal_request_id, status, decided_start, decided_end + FROM portal_status_outbox ORDER BY id LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var entries []PortalStatusEntry + for rows.Next() { + var ( + entry PortalStatusEntry + startRaw string + endRaw string + parseError error + ) + if err := rows.Scan(&entry.ID, &entry.PortalRequestID, &entry.Status, &startRaw, &endRaw); err != nil { + return nil, err + } + if entry.DecidedStart, parseError = parseOptionalTime(startRaw); parseError != nil { + return nil, parseError + } + if entry.DecidedEnd, parseError = parseOptionalTime(endRaw); parseError != nil { + return nil, parseError + } + entries = append(entries, entry) + } + return entries, rows.Err() +} + +// AckPortalStatus removes a delivered decision. +func (s *Store) AckPortalStatus(ctx context.Context, entryID int64) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM portal_status_outbox WHERE id = ?`, entryID) + return err +} + +// VisitorProposalPayloadOf decodes a visitor proposal for the owner UI. +func VisitorProposalPayloadOf(record ProposalRecord) (VisitorProposalPayload, error) { + if record.ActionID != ActionVisitorRequest { + return VisitorProposalPayload{}, ErrNotVisitorProposal + } + var payload VisitorProposalPayload + if err := json.Unmarshal(record.Payload, &payload); err != nil { + return VisitorProposalPayload{}, err + } + return payload, nil +} + +func newVisitorProposalID() (string, error) { + raw := make([]byte, 16) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return "visitor-" + hexString(raw), nil +} + +func hexString(raw []byte) string { + const digits = "0123456789abcdef" + out := make([]byte, 0, len(raw)*2) + for _, b := range raw { + out = append(out, digits[b>>4], digits[b&0x0f]) + } + return string(out) +} + +func formatOptionalTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339Nano) +} + +func parseOptionalTime(value string) (time.Time, error) { + if strings.TrimSpace(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/docs/decisions/0030-visitor-time-requests.md b/docs/decisions/0030-visitor-time-requests.md new file mode 100644 index 0000000..42c9886 --- /dev/null +++ b/docs/decisions/0030-visitor-time-requests.md @@ -0,0 +1,134 @@ +# ADR 0030: Visitor time requests through a transactional outbox + +- Status: accepted +- Date: 2026-07-31 +- Implements slice P5-b of [`portal-design.md`](../portal-design.md). +- Builds on [ADR-0029](0029-availability-portal-foundation.md) (split store and + projection firewall) and [ADR-0016](0016-proposal-queue.md) (the one-use + decision token every proposal is decided with). +- Messaging threads (P5-c) and the live layer (P5-d) remain unimplemented. + +## Context + +ADR-0029 made the portal read-only: a visitor could see broad likely-awake +windows and nothing else. The portal's actual purpose is two-way — someone +with the link should be able to *ask* for a time — and that is the first thing +in the project that lets an outsider write into the user's world. + +Two problems make this harder than an ordinary form. + +The stores are deliberately separate, so "store the request and file it in the +owner's queue" spans two databases and cannot be one transaction. Any naive +implementation either loses requests when the second write fails or tells the +visitor their request was delivered when it was not. + +And the visitor and the owner want different things from a decision. The +visitor asks for a *range*; the owner needs to answer with a *time*. Approving +"some point in this four-hour window" is not a usable answer to either party. + +## Decision + +1. **A transactional outbox, and `queued` as an honest visible state.** The + portal stores the request and an outbox row in one transaction. A bridge + turns outbox rows into pending private proposals and acknowledges them, + which is the only thing that moves a request from `queued` to `pending`. + Until then the visitor is told "saved and on its way", not "sent". A status + the visitor cannot verify must not be claimed on their behalf. + +2. **Submission is idempotent on the portal request id.** A unique + `portal_request_proposals` row means a retry after a lost acknowledgement + finds the existing proposal instead of creating a second one. This is the + failure that actually happens — the private commit succeeds and the ack is + lost — and it must not produce two asks in the owner's queue. + +3. **Decision and delivery commit together.** The owner's decision, the + consumption of the one-use token, the audit row, and the row that will tell + the visitor are written in a single private transaction. A decision the + visitor never learns about cannot happen without also losing the decision. + +4. **Approval names an exact block inside the requested window.** The chosen + block must lie within the window, and when the visitor supplied a duration + it must be exactly that long. A shorter block silently changes the ask; a + block outside the window is a counteroffer, which v1 does not have — that + needs a new request, not a moved time. + +5. **The generic decision route refuses visitor proposals.** `POST + /v1/proposals/{id}/decision` cannot discharge the slot obligation or the + delivery obligation, so it returns 409 and points at the sharing endpoint. + Leaving it able to decide a visitor request would have been a silent way to + skip both rules. + +6. **Visitor proposals are not an agent action.** `place_visitor_request` is + absent from the assistant's action registry, and only the bridge creates + one. An agent that could mint a visitor-origin proposal could manufacture + social pressure from a person who does not exist, and the user would have no + way to tell the difference. + +7. **Requester authorship is a separate secret from the link.** Creating a + request returns a 256-bit requester secret delivered in a URL *fragment*, + which browsers never send to a server and proxies never log, plus a + one-time recovery code for the no-script path. Exchanging it yields a + cookie scoped to one request under one profile. Holding the shared link and + passcode is therefore not enough to read another visitor's request; a wrong + secret and an unknown request return identical responses. + +8. **A declined request carries no reason, ever.** The visitor learns the time + did not work. Any reason would disclose sleep state, calendar contents, or + health, none of which a link holder is owed. An approval does reveal the + exact chosen block — that is necessary coordination information, and the + owner is told so before they choose rather than after. + +9. **Visitor text is private, in both directions.** `handle` and `message` are + encrypted at rest in the portal store, encrypted again inside the private + proposal payload, shown to the owner because judging a request requires + them, and never placed in an availability projection, an access-audit row, + or a log line. The product does not claim it "never accepts names" — it + accepts them and protects them. + +10. **No product cap on how far ahead someone may ask.** Owner decision 3. A + window past the forecast horizon is accepted, flagged, and shown with an + explicit statement that the estimate cannot reach that far. What *is* + bounded is the window's length: an eight-hour ask is a scheduling request + and a three-week ask is not. + +11. **Local input that does not exist is rejected, not guessed.** A + `datetime-local` value is parsed in the visitor's stated zone, and a time + that falls in a spring-forward gap is refused. Silently resolving an + ambiguous local time is how scheduling bugs are born. + +12. **Revocation closes open requests rather than deleting them.** The owner + may still hold a pending proposal referencing one, and a request that + silently vanished would leave the visitor watching a status that never + moves. + +## Consequences + +- The bridge is pumped when a request is created, when a decision is made, and + every minute as a recovery path. The timer is not the happy path; it exists + so a request that arrived while the bridge was failing still lands without + anyone intervening. +- Because the portal package must not reach the private store, request + creation signals the owner side through a bare `func()` rather than calling + it. The signal carries no data, which is what keeps the import boundary from + ADR-0029 intact. +- A visitor proposal is filed against an enrolled device, because proposals are + keyed to a creating device and a visitor has none. With no enrolled device + the request stays `queued` — correct, and visibly so. +- CSRF tokens are now derived from the session under a server key rather than + stored as a hash. ADR-0029 stored only a hash, which a server-rendered form + can never embed; that mechanism could not have worked. +- The owner's decision surface exists as an API. The desktop dialog that picks + a block on the calendar is not built, so today an owner decides through the + API rather than the UI. + +## Residual risk + +- A visitor can still send unwanted text within the caps. The owner sees it, + which is the point; per-link disabling and revocation are the remedies. +- Approval discloses the chosen time to whoever holds the requester cookie or + the recovery code, including anyone the visitor shared them with. +- Request rate limits are per portal session and per profile. A determined + visitor with several sessions is bounded by the per-profile pending cap + rather than the per-session daily cap. +- The exposure gate in `portal-design.md` section 12 is still not satisfied; + `portal.enabled` remains false by default and no independent review has run. diff --git a/docs/phase-goals.md b/docs/phase-goals.md index b24c00d..43ae164 100644 --- a/docs/phase-goals.md +++ b/docs/phase-goals.md @@ -58,11 +58,14 @@ 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 — partially closed 2026-07-31.** ADR-0029 delivers P5-a: the split +**Gap 5 — largely 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, +availability page, all behind a default-false `portal.enabled`. ADR-0030 +delivers P5-b: visitor time requests reaching the owner's proposal queue +through a transactional outbox, decided with the same one-use tokens as every +other proposal, with approval naming an exact block inside the requested +window. What remains is threads (P5-c), the live layer (P5-d), the desktop +dialog for choosing a block, and 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. @@ -204,14 +207,16 @@ 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. +Status: **P5-a and P5-b delivered 2026-07-31** via +[ADR-0029](decisions/0029-availability-portal-foundation.md) and +[ADR-0030](decisions/0030-visitor-time-requests.md) — separate portal store +with an import-enforced boundary, allowlisted materializer, security +middleware, passcode gate, read-only availability page, owner link CRUD, and +visitor time requests that round-trip through a transactional outbox into the +ADR-0016 queue and back to a visitor-visible status. P5-c (messaging threads) +and P5-d (SSE/live layer, audit UI, red-team pass) remain, as does the desktop +dialog for choosing a block; today the owner decides through the API. The +portal stays disabled by default and unexposed. Implementation-ready design: [`portal-design.md`](portal-design.md) (projection firewall, hashed link tokens with uniform 410s, origin- diff --git a/docs/portal-design.md b/docs/portal-design.md index 04a7f7c..6628251 100644 --- a/docs/portal-design.md +++ b/docs/portal-design.md @@ -1,8 +1,10 @@ # Availability portal design (phase 5) -> 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 +> Security and product contract for the public-facing portal. Slices P5-a and +> P5-b are implemented as of 2026-07-31 +> ([ADR-0029](decisions/0029-availability-portal-foundation.md), +> [ADR-0030](decisions/0030-visitor-time-requests.md)); P5-c and 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. @@ -82,12 +84,17 @@ GET /p/{linkToken} passcode or dashboard HTML POST /p/{linkToken}/session verify passcode, issue session GET /p/{linkToken}/availability allowlisted projection JSON GET /p/{linkToken}/events authenticated SSE +GET /p/{linkToken}/requests request form POST /p/{linkToken}/requests create request -POST /p/{linkToken}/request-session exchange requester secret -GET /p/{linkToken}/requests/{publicID} status/thread DTO +GET /p/{linkToken}/requests/{publicID} status DTO or recovery-code form +POST /p/{linkToken}/requests/{publicID}/session exchange requester secret POST /p/{linkToken}/requests/{publicID}/messages ``` +The delivered requester exchange is nested under its request rather than the +flat `/request-session` in the original sketch, so the route itself names the +request the secret belongs to. `/events` and `/messages` are P5-d and P5-c. + Middleware order is request-size cap, security headers, source throttling, link resolution, passcode-session gate, CSRF/origin gate for mutation, then handler. The passcode-session endpoint sits after link resolution and throttling but @@ -316,7 +323,7 @@ only one can commit. | Slice | Scope | Acceptance spine | |---|---|---| | 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-b **(delivered 2026-07-31, ADR-0030)** | 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 | @@ -324,10 +331,9 @@ 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. +The transactional outbox in section 2 is implemented in both directions as of +P5-b. Section 7's messaging threads and the second requester exchange remain +P5-c; SSE and the audit UI remain P5-d. ## 12. Exposure gate diff --git a/docs/privacy.md b/docs/privacy.md index dbf2e2c..f7a9e94 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -193,8 +193,33 @@ 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. +from the portal database. Open requests are closed rather than deleted, so +nobody is left watching a status that never moves. What a recipient already +saw, screenshotted, or remembers cannot be recalled. + +### Asking for a time + +When a link allows it, a recipient can ask for a window that suits them and +optionally give a name and a short note. That text is theirs, and it is +private: encrypted in the portal database, encrypted again inside the owner's +copy, shown to the owner because judging a request requires it, and never +placed in the availability page, the shared projection, an access-log row, or +a notification title. The product does not claim it never accepts names — it +accepts them and protects them. + +Until the request has actually reached the owner's queue it is shown as +"saved and on its way", never as sent. Claiming delivery the visitor cannot +verify would be a small lie with real consequences for someone waiting. + +Approving a request tells the requester the exact time chosen. That is +necessary to meet, and the owner is told so before choosing. Declining tells +them only that the time did not work — never a reason, because any reason +would disclose sleep, calendar, or health. + +Anyone who creates a request receives a one-time code proving they wrote it. +Holding the shared link and its passcode is not enough to read someone else's +request: several people can hold the same link and still cannot see each +other's asks. ## Agent connectors diff --git a/docs/roadmap.md b/docs/roadmap.md index 25842e4..5bf460e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -216,9 +216,16 @@ M-E's separately reviewed local agent projection. 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 + `/p/` route exists when it is off. *P5-b delivered (ADR-0030):* visitor + time requests reach the owner's queue through a transactional outbox with + a stable idempotency key, become proposals with origin `visitor` decided + by the same one-use tokens, and approval must name an exact block inside + the requested window; the decision returns to a visitor-visible status. + A request stays honestly `queued` until the owner's queue confirms it, a + decline carries no reason, and the requester secret travels only in a URL + fragment. **Next:** P5-c threads, P5-d the live layer and audit UI, and + the desktop dialog for choosing a block — today the owner decides through + the API. 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 diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 7e063c8..acaf0e6 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -203,10 +203,14 @@ exposure is prohibited until every item in section 12 of 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. +What is implemented ([ADR-0029](decisions/0029-availability-portal-foundation.md) +and [ADR-0030](decisions/0030-visitor-time-requests.md)): share links that show +broad likely-awake windows to someone holding the link and its passcode, and — +when the link grants it — visitor requests for a specific time that land in the +owner's approval queue and return a decision to the requester. Messaging +threads and the live-updating dashboard are not implemented, and neither is the +desktop dialog for choosing a block: an owner currently decides visitor +requests through `/v1/portal/requests/{id}/decision`. 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. @@ -234,8 +238,15 @@ When enabled: 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. +sessions stop working, the shared windows are deleted from the portal database, +and open requests are closed rather than left waiting. + +A visitor request is stored durably before it reaches the owner's queue and is +shown as "on its way" until the queue confirms it. If the daemon has no +enrolled device, requests accumulate in that honest queued state and are +delivered once one exists — nothing is lost and nobody is told otherwise. The +daemon retries delivery every minute in addition to pumping on each request and +decision. ## Network And Telemetry diff --git a/docs/threat-model.md b/docs/threat-model.md index afc4f64..22d5af9 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -57,6 +57,14 @@ availability. 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. +15. Visitor request → **owner proposal queue**, the first boundary in the + system an outsider may write across (ADR-0030). It is crossed only by a + transactional outbox with a stable idempotency key, never by a direct call: + the portal stores a request durably and a bridge files exactly one pending + proposal, which is decided with the same one-use token as every other + proposal. Approval must name an exact block inside the requested window. + `place_visitor_request` is absent from the agent action registry, so an + agent cannot forge a proposal that appears to come from a real person. ## Adversaries @@ -189,6 +197,16 @@ independently. No independent review has run against this surface, so exposure r prohibited: `portal.enabled` is false by default and the section 12 exposure gate in `portal-design.md` is not yet satisfied. +Visitor requests add their own accepted residuals. A visitor may send unwanted text +within the caps; the owner sees it, which is the point, and per-link disabling and +revocation are the remedies. Approval discloses the chosen time to whoever holds the +requester cookie or the one-time code, including anyone the visitor passed it to. +Request limits are per portal session and per profile, so a visitor with several +sessions is bounded by the per-profile pending cap rather than the per-session daily +one. A visitor proposal is filed against an enrolled device because proposals are keyed +to a creating device and a visitor has none; with no enrolled device the request stays +visibly queued. + ## Security verification - Sync: assert authentication is required, transport is TLS, and replayed messages are @@ -215,7 +233,13 @@ prohibited: `portal.enabled` is false by default and the section 12 exposure gat `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. + exists when the portal is disabled; assert a visitor request round-trips to a decision + and back to a visitor-visible status, that a failing bridge leaves it queued rather + than claiming delivery, that repeated pumps create exactly one proposal, that approval + outside the requested window or of the wrong duration is refused, that the generic + decision route refuses visitor proposals, that a decision token is one-use, that a + visitor holding the link but not the requester secret cannot read another's request, + and that a decline discloses no reason. - 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 164e322..b6fc9ba 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -432,6 +432,85 @@ Not yet verified, and gating exposure: an independent security review operator TLS/reverse-proxy runbook with log-token redaction (item 3). Requests, messaging, and the live SSE layer are P5-b through P5-d and do not exist. +## Visitor time requests (ADR-0030) + +Verified 2026-07-31 for slice P5-b, all against loopback servers and +in-process handlers. The portal remains unexposed. + +**Round trip.** A request submitted through the public form is stored, reaches +the owner's queue as a proposal with origin `visitor`, is decided with a +one-use token, and the decision appears on the visitor's status page with the +exact block the owner chose. The proposal payload carries the visitor's handle +and message, because judging a request requires them. + +**Honest queued state.** With the bridge unable to file the request — no +enrolled device — the pump reports failure, the request stays `queued`, and +the visitor is shown "saved and on its way", never "sent". Once a device +exists the same request goes through without resubmission. + +**Idempotency.** Four consecutive pumps over one request produce exactly one +proposal, which is the failure that actually occurs: the private commit +succeeds and the acknowledgement is lost. `ApplyDecision` is idempotent in the +other direction too — three replays of the same approval are a no-op, and a +later contradicting decision does not overwrite a settled one. + +**Exact-slot rule.** Approval is refused with a typed error for a block before +the window, after it, straddling its end, of the wrong duration when one was +requested, empty, or inverted; a block inside the window with the exact +requested length is accepted. Over the API the same cases are 400, and the +approved block reaches the visitor unchanged. + +**One-use tokens and route guards.** A replayed decision token is refused on +the visitor route. The generic `/v1/proposals/{id}/decision` returns 409 for a +visitor proposal, so the slot and delivery obligations cannot be skipped by +choosing the other endpoint. `DecideVisitorProposal` refuses a non-visitor +proposal. `place_visitor_request` is absent from the assistant action +registry, so an agent cannot mint a proposal that appears to come from an +outside person. + +**Requester isolation.** A visitor holding the shared link and passcode but no +requester cookie sees the recovery-code form, not another visitor's request, +and none of that request's text appears in the response. A wrong secret and an +unknown request id produce identical status and body. Exchanging the correct +secret yields a request-scoped cookie that renders the author's own request. + +**Validation.** Rejected with typed errors: end before start, a window already +started, a window over eight hours, durations below 15 or above 480 minutes, +a duration longer than its window, an unknown zone, and handle or message past +their rune caps. Control characters are stripped and newlines and tabs folded +without mangling the text. A window past the forecast horizon is accepted, +flagged, and rendered with the explicit infeasibility warning — owner decision +3, no product cap on how far ahead someone may ask. + +**Caps and revocation.** The per-session daily cap admits five requests and +explains the sixth refusal. Revoking a link closes its open requests rather +than deleting them, so a visitor is not left watching a status that never +moves. A declined request's rendered text is asserted free of "asleep", +"sleep", "busy", "calendar", "conflict", "medication", and "because". + +**Browser verification.** The whole visitor path was driven in a real browser: +passcode gate, dashboard, request form, submission, the one-time recovery +code, and the status page. The requester secret appears only after the `#` in +the continue link, never in the path or query. The "now" badge, moved from a +CSS pseudo-element into markup during P5-a, is confirmed present in the +accessibility tree. + +**A P5-a defect this slice exposed.** ADR-0029 stored only a hash of the +synchronizer token, which a server-rendered form can never embed — the +mechanism could not have worked. CSRF tokens are now derived from the session +value under a server-held key, so the plaintext is recoverable on every render +while staying unguessable. The test asserts derivability, verification, and +rejection of the session value itself. + +Measured suite results: `portal`, `portalbridge`, `api`, `config`, `daemon`, +`store`, `readmodel`, `sync`, `assistant`, and `mcp` pass; `gofmt` and +`go vet` are clean; Linux and Windows server builds succeed. + +Not implemented, and therefore not verified: messaging threads (P5-c), the SSE +live layer and audit UI (P5-d), and the desktop dialog for choosing a block on +the calendar — an owner decides through the API today. The exposure gate is +unchanged and unmet. + ## Environment limitations - Emulator semantics and screenshots were reviewed, but a full manual TalkBack