From ecaee68b34ec6466c28199d401b1fc6ac4dee768 Mon Sep 17 00:00:00 2001 From: welli <17185653+wellivea1@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:41:22 -0400 Subject: [PATCH] Phase 5b: visitor time requests through a transactional outbox (ADR-0030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Someone holding a share link can now ask for a time. The request lands in the owner's existing proposal queue and the decision comes back to a visitor-visible status. This is the first boundary in the project an outsider may write across, so the mechanics matter more than the feature. The stores are separate by design, so filing a request spans two databases and cannot be one transaction. A transactional outbox with a stable idempotency key carries it: the portal stores the request durably, a bridge files exactly one proposal, and only the acknowledgement moves the request from `queued` to `pending`. Until then the visitor is told "saved and on its way", never "sent" — a status they cannot verify is not one to claim on their behalf. Four consecutive pumps still produce one proposal, which is the failure that actually happens: the private commit succeeds and the ack is lost. The visitor asks for a range; the owner must answer with a time. Approval therefore names an exact block inside the requested window, of exactly the requested length when one was given. A shorter block silently changes the ask and a block outside the window is a counteroffer, which v1 does not have. The generic decision route now returns 409 for a visitor proposal rather than quietly skipping both the slot rule and the delivery. place_visitor_request is deliberately absent from the assistant action registry. An agent able to mint a visitor-origin proposal could manufacture social pressure from a person who does not exist. A decline carries no reason, ever: any reason would disclose sleep, calendar, or health. An approval does reveal the chosen block, and the owner is told that before choosing rather than after. Authorship is a separate secret from the link, delivered in a URL fragment that browsers never send and proxies never log, with a one-time recovery code for the no-script path. Holding the shared link and passcode is not enough to read another visitor's request; a wrong secret and an unknown request return identical responses. Also fixes a P5-a defect this slice exposed: CSRF tokens were stored only as a hash, which a server-rendered form can never embed, so the mechanism could not have worked. They are now derived from the session under a server-held key. Co-Authored-By: Claude Opus 5 --- apps/server/internal/api/portal.go | 154 +++++- apps/server/internal/api/portal_test.go | 137 +++++ apps/server/internal/api/server.go | 12 + apps/server/internal/daemon/server.go | 59 ++- .../internal/portal/assets/pages.gohtml | 94 ++++ apps/server/internal/portal/assets/portal.css | 44 +- apps/server/internal/portal/handlers.go | 20 +- apps/server/internal/portal/http.go | 20 +- apps/server/internal/portal/outbox.go | 166 ++++++ apps/server/internal/portal/portal_test.go | 28 +- .../internal/portal/request_handlers.go | 354 +++++++++++++ apps/server/internal/portal/requests.go | 392 ++++++++++++++ apps/server/internal/portal/requests_test.go | 479 ++++++++++++++++++ apps/server/internal/portal/store.go | 112 +++- apps/server/internal/portal/view.go | 13 +- apps/server/internal/portalbridge/requests.go | 140 +++++ .../internal/portalbridge/requests_test.go | 390 ++++++++++++++ apps/server/internal/store/store.go | 48 ++ .../server/internal/store/visitor_requests.go | 332 ++++++++++++ docs/decisions/0030-visitor-time-requests.md | 134 +++++ docs/phase-goals.md | 29 +- docs/portal-design.md | 26 +- docs/privacy.md | 29 +- docs/roadmap.md | 13 +- docs/self-hosting.md | 23 +- docs/threat-model.md | 26 +- docs/verification.md | 79 +++ 27 files changed, 3266 insertions(+), 87 deletions(-) create mode 100644 apps/server/internal/portal/outbox.go create mode 100644 apps/server/internal/portal/request_handlers.go create mode 100644 apps/server/internal/portal/requests.go create mode 100644 apps/server/internal/portal/requests_test.go create mode 100644 apps/server/internal/portalbridge/requests.go create mode 100644 apps/server/internal/portalbridge/requests_test.go create mode 100644 apps/server/internal/store/visitor_requests.go create mode 100644 docs/decisions/0030-visitor-time-requests.md 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