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}}
+
+
+
+{{template "foot" .}}{{end}}
+
+{{define "request-claim"}}{{template "head" .}}
+
+
+
{{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