Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 153 additions & 1 deletion apps/server/internal/api/portal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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,
}
}
}
Expand Down Expand Up @@ -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
Expand Down
137 changes: 137 additions & 0 deletions apps/server/internal/api/portal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
12 changes: 12 additions & 0 deletions apps/server/internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading