oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1# Meet slice 1: meetings, links, sign-in — Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** A member signs in, makes a meeting with a sealed title and an optional start time, gets its link once, and an outsider who follows that link signs in through it and lands on a holding page that says the room is the next slice.
6
7**Architecture:** One rastrillo app (`amadan.net/oficina/meet`), chi router, SQLite through gorm, server-rendered `html/template`, no JavaScript beyond the vendored rastrillo shims. Sign-in is rastrillo/auth magic links gated by an allowlist, with one exception: a browser that has just followed a live meeting link may sign in with any address, and a `pending_joins` row written before the mail goes out is what lets the emailed link admit that address anywhere. Titles are sealed with AES-GCM under HKDF purpose keys from a DEK in platform config. Standing (member, guest, none) is computed per request, never stored.
8
9**Tech Stack:** Go 1.25, `amadan.net/rastrillo/rastrillo v0.26.0` (auth, sessions, csrf, migrate, ui, mail, db), `github.com/go-chi/chi/v5`, gorm + modernc sqlite (via rastrillo/db), `golang.org/x/crypto/hkdf`.
10
11**Spec:** `docs/superpowers/specs/2026-09-07-meet-design.md` (revision 8). This plan implements §12 item 1 and the parts of §2, §3, §7 and §13 it names. Read §2.1, §2.3, §2.5, §3.1, §3.2, §3.3, §7.1, §7.3 and the slice-1 lines of §13 before starting.
12
13## Global Constraints
14
15- Module path `amadan.net/oficina/meet`; framework `amadan.net/rastrillo/rastrillo v0.26.0` (not the old `github.com/carlosframework` path Docs and Calendar still use).
16- `CGO_ENABLED=0` everywhere; `make ci` (vet, gofmt, tests, migration check) is the gate and must be green at the end of every task.
17- Every screen in this slice works with JavaScript disabled (spec §9); no new script files.
18- Never bind a request body onto a gorm model: explicit values plus `.Select` allowlists (AGENTS.md).
19- Destructive actions have their own confirm page rendered with `ui/confirm-form` and a POST behind `csrf.Protect` (AGENTS.md, spec §3.1).
20- A link token is stored as SHA-256 hex and returned once in the response that minted it (ruling 16).
21- Titles and outsider addresses never reach the database in plaintext (spec §0.1, §2.1); the label for HKDF is `oficina/meet:`.
22- Sign-in refusals from Meet's own gate redirect to `/signin?sent=1`, byte-identical to success (spec §3.2 "Responses").
23- User-facing strings written in this slice are drafts pending the copy review (spec §10); they are collected in Task 8 for one sitting.
24- Commits use the house form and end with `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`.
25- Run every command from the worktree root `/home/paulca/amadan.net/oficina/meet/.claude/worktrees/slice-1` with `GOCACHE=$TMPDIR/gocache` exported (the default Go cache is read-only in the sandbox).
26
27---
28
29## File structure
30
31| File | Responsibility |
32|---|---|
33| `cmd/calls/main.go` | Reads env and the `-env` flag into `meet.Config`, builds the app, serves. |
34| `internal/calls/config.go` | `Config`, `resolve` (env, origin, instance key, DEK, mailer, allowlist), all startup refusals. |
35| `internal/calls/seal/seal.go`, `seal/fingerprint.go` | AES-GCM sealing and keyed fingerprints under HKDF purpose keys. Copied from calendar with Meet's label. |
36| `internal/calls/models.go` | gorm structs: `Person`, `Meeting`, `MeetingLink`, `GuestGrant`, `PendingJoin`. |
37| `internal/calls/migrations/0001_init.sql`, `schema.sql` | The five tables. `migrations.go` merges sessions and auth schemas first. |
38| `internal/calls/ids.go` | `newSqid`, `newLinkToken`, `hashLinkToken`. |
39| `internal/calls/store.go` | All database access: persons, meetings, links, grants, pending joins. Sealing happens here and nowhere else. |
40| `internal/calls/standing.go` | `Standing` type, `standingFor`, the allowlist. |
41| `internal/calls/auth.go` | Instance key, mailer, `gateBegin` with the pending-join exception and budgets, `Authorize`, `/continue`, `/welcome`, the sign-in page, `currentPerson` middleware. |
42| `internal/calls/budget.go` | Sliding-window counters for outsider sign-in attempts. |
43| `internal/calls/joincookie.go` | The signed pending-join cookie. |
44| `internal/calls/handlers_meetings.go` | List, create, link-once page, replace-link and end-meeting confirm pages and POSTs. |
45| `internal/calls/handlers_join.go` | `GET /j/{token}` and the holding page `GET /m/{sqid}/join`. |
46| `internal/calls/app.go` | `App(d, cfg)`: migrate, wire auth, router with the three tiers, middleware. |
47| `internal/calls/render.go` | Template tree with `ui.Funcs` and `ui.Templates()`, `render`, `ErrorPage`. |
48| `internal/calls/templates/*.html` | `layout`, `index`, `link_once`, `confirm`, `signin`, `welcome`, `join`, `errors`. |
49| `internal/callstest/*_test.go` | Harness (`newApp`, `signIn`, recording mailer, clock), one test file per task. |
50| `Makefile` | `release` stamps `BuildVersion`; `version-check`. |
51| `docs/superpowers/copy/2026-09-08-slice-1-strings.md` | Every user-facing string in the slice, for the copy review. |
52
53---
54
55### Task 1: Config, sealer, refusals, tooling
56
57**Files:**
58- Create: `internal/calls/config.go`, `internal/calls/seal/seal.go`, `internal/calls/seal/fingerprint.go`, `internal/calls/seal/seal_test.go`
59- Modify: `cmd/calls/main.go`, `internal/calls/app.go`, `Makefile`, `go.mod`
60- Test: `internal/callstest/config_test.go`
61
62**Interfaces:**
63- Produces: `meet.Config{Env, Origin, InstanceKey, DEK, AllowedAddresses []string, Mailer mail.Sender, RelayConfigured bool, Zone string, Logger}`; `meet.App(d *db.DB, cfg Config) (*http.ServeMux, error)`; `seal.New(dek []byte) (*Sealer, error)`, `(*Sealer).SealString(purpose, s) ([]byte, error)`, `(*Sealer).OpenString(purpose string, ct []byte) (string, error)`, `(*Sealer).Fingerprint(purpose, value string) (string, error)`; constants `seal.PurposeMeetingTitle = "meeting.title"`, `seal.PurposePendingAddress = "pending.address"`; `meet.EnvDevelopment = "development"`.
64
65- [ ] **Step 1: Copy the sealer with Meet's label**
66
67Create `internal/calls/seal/seal.go`:
68
69```go
70// Package seal is the app's only encryption surface.
71//
72// The one rule (spec §0.1): a meeting's title is sealed at rest, and
73// the database never holds a plaintext title or an outsider's address.
74// The DEK arrives as platform config and is never written to the
75// database it decrypts.
76//
77// Lifted from calendar's seal package, itself lifted from sheets', with
78// only the label changed. Calendar names the third app as the trigger
79// to extract this into estilo or rastrillo; Meet is that app, and the
80// extraction is a task on the plan, not a change here.
81package seal
82
83import (
84 "crypto/aes"
85 "crypto/cipher"
86 "crypto/rand"
87 "crypto/sha256"
88 "errors"
89 "hash"
90 "io"
91
92 "golang.org/x/crypto/hkdf"
93)
94
95// label is mixed into every derived key. Changing it makes every
96// existing sealed value unreadable, so it is a constant, not config.
97const label = "oficina/meet:"
98
99const (
100 PurposeMeetingTitle = "meeting.title"
101 PurposePendingAddress = "pending.address"
102)
103
104func sha256New() hash.Hash { return sha256.New() }
105
106type Sealer struct{ dek []byte }
107
108// New refuses anything but a 32-byte key and keeps its own copy.
109func New(dek []byte) (*Sealer, error) {
110 if len(dek) != 32 {
111 return nil, errors.New("seal: the DEK must be exactly 32 bytes")
112 }
113 k := make([]byte, 32)
114 copy(k, dek)
115 return &Sealer{dek: k}, nil
116}
117
118func (s *Sealer) purposeKey(purpose string) ([]byte, error) {
119 key := make([]byte, 32)
120 r := hkdf.New(sha256New, s.dek, nil, []byte(label+purpose))
121 if _, err := io.ReadFull(r, key); err != nil {
122 return nil, err
123 }
124 return key, nil
125}
126
127func (s *Sealer) aeadFor(purpose string) (cipher.AEAD, error) {
128 key, err := s.purposeKey(purpose)
129 if err != nil {
130 return nil, err
131 }
132 block, err := aes.NewCipher(key)
133 if err != nil {
134 return nil, err
135 }
136 return cipher.NewGCM(block)
137}
138
139// Seal returns nonce || ciphertext.
140func (s *Sealer) Seal(purpose string, plaintext []byte) ([]byte, error) {
141 aead, err := s.aeadFor(purpose)
142 if err != nil {
143 return nil, err
144 }
145 nonce := make([]byte, aead.NonceSize())
146 if _, err := rand.Read(nonce); err != nil {
147 return nil, err
148 }
149 return aead.Seal(nonce, nonce, plaintext, nil), nil
150}
151
152func (s *Sealer) Open(purpose string, ct []byte) ([]byte, error) {
153 aead, err := s.aeadFor(purpose)
154 if err != nil {
155 return nil, err
156 }
157 if len(ct) < aead.NonceSize() {
158 return nil, errors.New("seal: ciphertext too short")
159 }
160 nonce, body := ct[:aead.NonceSize()], ct[aead.NonceSize():]
161 return aead.Open(nil, nonce, body, nil)
162}
163
164// SealString seals a string; an empty string seals to nil so an
165// optional column stays NULL.
166func (s *Sealer) SealString(purpose, plaintext string) ([]byte, error) {
167 if plaintext == "" {
168 return nil, nil
169 }
170 return s.Seal(purpose, []byte(plaintext))
171}
172
173func (s *Sealer) OpenString(purpose string, ct []byte) (string, error) {
174 if len(ct) == 0 {
175 return "", nil
176 }
177 b, err := s.Open(purpose, ct)
178 if err != nil {
179 return "", err
180 }
181 return string(b), nil
182}
183```
184
185Create `internal/calls/seal/fingerprint.go`:
186
187```go
188package seal
189
190import (
191 "crypto/hmac"
192 "encoding/hex"
193)
194
195// Fingerprint is a keyed, stable digest: equal values give equal
196// fingerprints and nothing else can be learned from one. An address is
197// enumerable, so a plain hash of it is the address in another alphabet;
198// HMAC under a purpose key is not.
199func (s *Sealer) Fingerprint(purpose, value string) (string, error) {
200 key, err := s.purposeKey(purpose)
201 if err != nil {
202 return "", err
203 }
204 mac := hmac.New(sha256New, key)
205 mac.Write([]byte(value))
206 return hex.EncodeToString(mac.Sum(nil)), nil
207}
208```
209
210- [ ] **Step 2: Write the sealer test**
211
212Create `internal/calls/seal/seal_test.go`:
213
214```go
215package seal
216
217import (
218 "bytes"
219 "testing"
220)
221
222func TestSealRoundTripsAndDiffersPerPurpose(t *testing.T) {
223 s, err := New(bytes.Repeat([]byte{7}, 32))
224 if err != nil {
225 t.Fatal(err)
226 }
227 ct, err := s.SealString(PurposeMeetingTitle, "Board meeting")
228 if err != nil {
229 t.Fatal(err)
230 }
231 if bytes.Contains(ct, []byte("Board")) {
232 t.Fatal("ciphertext contains plaintext")
233 }
234 got, err := s.OpenString(PurposeMeetingTitle, ct)
235 if err != nil || got != "Board meeting" {
236 t.Fatalf("open: %q, %v", got, err)
237 }
238 if _, err := s.OpenString(PurposePendingAddress, ct); err == nil {
239 t.Fatal("a ciphertext opened under the wrong purpose")
240 }
241}
242
243func TestFingerprintIsStableAndKeyed(t *testing.T) {
244 a, _ := New(bytes.Repeat([]byte{1}, 32))
245 b, _ := New(bytes.Repeat([]byte{2}, 32))
246 x1, _ := a.Fingerprint(PurposePendingAddress, "x@example.com")
247 x2, _ := a.Fingerprint(PurposePendingAddress, "x@example.com")
248 y, _ := b.Fingerprint(PurposePendingAddress, "x@example.com")
249 if x1 != x2 {
250 t.Fatal("fingerprint is not stable")
251 }
252 if x1 == y {
253 t.Fatal("fingerprint does not depend on the key")
254 }
255}
256
257func TestNewRefusesWrongKeyLength(t *testing.T) {
258 if _, err := New([]byte("short")); err == nil {
259 t.Fatal("a 5-byte key was accepted")
260 }
261}
262```
263
264- [ ] **Step 3: Run the sealer tests**
265
266Run: `go get golang.org/x/crypto@latest && go test ./internal/calls/seal/ -v`
267Expected: PASS (three tests).
268
269- [ ] **Step 4: Write the config**
270
271Create `internal/calls/config.go`:
272
273```go
274package meet
275
276import (
277 "encoding/base64"
278 "fmt"
279 "log/slog"
280 "strings"
281 "time"
282
283 "amadan.net/rastrillo/rastrillo/mail"
284)
285
286// EnvDevelopment is the one Env value that relaxes startup refusals.
287// Only the -env flag can select it (cmd/calls/main.go); an environment
288// variable cannot, so a misconfigured production box fails loudly
289// rather than quietly running with a zero key.
290const EnvDevelopment = "development"
291
292// Config is everything the app needs to start. Every field that is a
293// secret or a boundary is a startup refusal when it is missing outside
294// development, per spec §7.1.
295type Config struct {
296 // Env is "development" or anything else (treated as production).
297 Env string
298 // Origin is the app's external origin, scheme included
299 // ("https://calls.oficina.works"). CALLS_ORIGIN.
300 Origin string
301 // InstanceKey seals rastrillo/auth's pending blob and signs the
302 // pending-join cookie. CALLS_INSTANCE_KEY.
303 InstanceKey string
304 // DEK is the base64 data encryption key, exactly 32 bytes decoded.
305 // CALLS_DEK, set with `carlos secrets set`.
306 DEK string
307 // AllowedAddresses is the team: the addresses that are members.
308 // CALLS_ALLOWED_ADDRESSES, comma-separated.
309 AllowedAddresses []string
310 // Mailer delivers magic links. Nil outside development is refused;
311 // nil in development logs the link instead of sending it.
312 Mailer mail.Sender
313 // RelayConfigured is whether CARLOS_RELAY is present. Slice 1 only
314 // reads its presence for the holding page's floor sentence; slice 3
315 // parses it.
316 RelayConfigured bool
317 // Zone is the IANA zone start times are entered and shown in.
318 // CALLS_ZONE, default UTC. Per-person zones are deliberately not in
319 // version one.
320 Zone string
321 Logger *slog.Logger
322}
323
324func (cfg Config) development() bool { return cfg.Env == EnvDevelopment }
325
326const devInstanceKey = "meet-development-instance-key-not-a-secret"
327
328var devDEK = make([]byte, 32)
329
330// resolved is Config after every refusal has been checked and every
331// default applied.
332type resolved struct {
333 Config
334 instanceKey string
335 dek []byte
336 mailer mail.Sender
337 allow allowlist
338 zone *time.Location
339}
340
341func resolve(cfg Config) (resolved, error) {
342 if cfg.Logger == nil {
343 cfg.Logger = slog.Default()
344 }
345 out := resolved{Config: cfg}
346
347 if strings.TrimSpace(cfg.Origin) == "" {
348 if !cfg.development() {
349 return out, fmt.Errorf("calls: Config.Origin is empty and Env is %q, not %q; set $CALLS_ORIGIN", cfg.Env, EnvDevelopment)
350 }
351 out.Origin = "http://localhost:8080"
352 }
353
354 key := strings.TrimSpace(cfg.InstanceKey)
355 switch {
356 case key != "":
357 out.instanceKey = key
358 case cfg.development():
359 out.instanceKey = devInstanceKey
360 default:
361 return out, fmt.Errorf("calls: Config.InstanceKey is empty and Env is %q, not %q; refusing to start with no seal for the sign-in link and the join cookie; set $CALLS_INSTANCE_KEY", cfg.Env, EnvDevelopment)
362 }
363
364 raw := strings.TrimSpace(cfg.DEK)
365 switch {
366 case raw == "" && cfg.development():
367 out.dek = devDEK
368 case raw == "":
369 return out, fmt.Errorf("calls: Config.DEK is empty and Env is %q, not %q; a meeting's title is sealed at rest and a boot without a key would write plaintext into a replicated file; set $CALLS_DEK to 32 base64 bytes with `carlos secrets set`", cfg.Env, EnvDevelopment)
370 default:
371 b, err := base64.StdEncoding.DecodeString(raw)
372 if err != nil {
373 return out, fmt.Errorf("calls: $CALLS_DEK is not valid base64: %w", err)
374 }
375 if len(b) != 32 {
376 return out, fmt.Errorf("calls: $CALLS_DEK decodes to %d bytes, want exactly 32", len(b))
377 }
378 out.dek = b
379 }
380
381 switch {
382 case cfg.Mailer != nil:
383 out.mailer = cfg.Mailer
384 case cfg.development():
385 out.mailer = mail.Logged(cfg.Logger)
386 default:
387 return out, fmt.Errorf("calls: mail is not configured (set $CALLS_SMTP_HOST and $CALLS_SMTP_FROM) and Env is %q, not %q; a sign-in that cannot send its link is not sign-in", cfg.Env, EnvDevelopment)
388 }
389
390 list, err := resolveAllowlist(cfg)
391 if err != nil {
392 return out, err
393 }
394 out.allow = list
395
396 zone := strings.TrimSpace(cfg.Zone)
397 if zone == "" {
398 zone = "UTC"
399 }
400 loc, err := time.LoadLocation(zone)
401 if err != nil {
402 return out, fmt.Errorf("calls: $CALLS_ZONE %q is not an IANA zone: %w", zone, err)
403 }
404 out.zone = loc
405 out.Zone = zone
406 return out, nil
407}
408```
409
410- [ ] **Step 5: Write the allowlist in standing.go (the config test needs it)**
411
412Create `internal/calls/standing.go` with only the allowlist for now; Task 3 adds `Standing`:
413
414```go
415package meet
416
417import (
418 "fmt"
419 "strings"
420)
421
422func normalizeAddress(address string) string {
423 return strings.ToLower(strings.TrimSpace(address))
424}
425
426// allowlist is the team in lone shape (spec §7.1): the addresses that
427// are members. A nil map admits everyone, which is allowed only in
428// development.
429type allowlist struct {
430 addrs map[string]struct{}
431}
432
433func (a allowlist) allows(address string) bool {
434 if a.addrs == nil {
435 return true
436 }
437 _, ok := a.addrs[normalizeAddress(address)]
438 return ok
439}
440
441func resolveAllowlist(cfg Config) (allowlist, error) {
442 addrs := make(map[string]struct{})
443 for _, addr := range cfg.AllowedAddresses {
444 addr = normalizeAddress(addr)
445 if addr == "" {
446 continue
447 }
448 addrs[addr] = struct{}{}
449 }
450 if len(addrs) > 0 {
451 return allowlist{addrs: addrs}, nil
452 }
453 if cfg.development() {
454 return allowlist{}, nil
455 }
456 return allowlist{}, fmt.Errorf("calls: $CALLS_ALLOWED_ADDRESSES is empty and Env is %q, not %q; refusing to start with no team", cfg.Env, EnvDevelopment)
457}
458```
459
460- [ ] **Step 6: Change `App` to take `Config`, and `main.go` to build it**
461
462Replace `internal/calls/app.go` entirely:
463
464```go
465package meet
466
467import (
468 "context"
469 "log/slog"
470 "net/http"
471
472 "github.com/go-chi/chi/v5"
473
474 "amadan.net/rastrillo/rastrillo/csrf"
475 "amadan.net/rastrillo/rastrillo/db"
476 "amadan.net/rastrillo/rastrillo/migrate"
477
478 "amadan.net/oficina/meet/internal/calls/seal"
479)
480
481// app holds what every handler needs.
482type app struct {
483 db *db.DB
484 cfg resolved
485 sealer *seal.Sealer
486 logger *slog.Logger
487}
488
489// App wires the whole app: refusals, schema, router, static files. It
490// returns a *http.ServeMux because rastrillo.Options.Mux is typed that
491// way; the chi router mounts inside it.
492func App(d *db.DB, cfg Config) (*http.ServeMux, error) {
493 rc, err := resolve(cfg)
494 if err != nil {
495 return nil, err
496 }
497 sealer, err := seal.New(rc.dek)
498 if err != nil {
499 return nil, err
500 }
501 migrated, err := migrate.Apply(context.Background(), d, BootSchema)
502 if err != nil {
503 return nil, err
504 }
505 rc.Logger.Info("migrate", "applied", migrated.Applied,
506 "skipped", migrated.Skipped, "adopted", migrated.Adopted)
507
508 a := &app{db: d, cfg: rc, sealer: sealer, logger: rc.Logger}
509
510 r := chi.NewRouter()
511 r.Use(csrf.Protect(rc.Origin))
512 r.Get("/", a.index)
513
514 mux := http.NewServeMux()
515 mux.Handle("GET /static/", assets.Handler())
516 mux.Handle("/", r)
517 return mux, nil
518}
519```
520
521Delete the `app` struct from `internal/calls/handlers.go`, leaving only `index`:
522
523```go
524package meet
525
526import "net/http"
527
528func (a *app) index(w http.ResponseWriter, r *http.Request) {
529 render(w, "index", nil)
530}
531```
532
533Replace `cmd/calls/main.go`:
534
535```go
536package main
537
538import (
539 "flag"
540 "log/slog"
541 "os"
542 "strings"
543
544 "amadan.net/rastrillo/rastrillo"
545 "amadan.net/rastrillo/rastrillo/db"
546 "amadan.net/rastrillo/rastrillo/mail"
547
548 "amadan.net/oficina/meet/internal/calls"
549)
550
551func main() {
552 logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
553
554 // Only the flag can select development: an env var that could would
555 // let a misconfigured box relax every refusal (config.go).
556 env := flag.String("env", "", "set to development to relax startup refusals")
557 flag.Parse()
558
559 opts, err := rastrillo.Resolve(rastrillo.Options{DBPath: "calls.db", Logger: logger})
560 if err != nil {
561 logger.Error("resolve activation", "err", err)
562 os.Exit(1)
563 }
564
565 d, err := db.Open(opts.DBPath, logger)
566 if err != nil {
567 logger.Error("open database", "err", err)
568 os.Exit(1)
569 }
570 defer d.Close()
571
572 var mailer mail.Sender
573 if os.Getenv("CALLS_SMTP_HOST") != "" && os.Getenv("CALLS_SMTP_FROM") != "" {
574 mailer = mail.FromEnv("MEET", logger)
575 }
576
577 cfg := meet.Config{
578 Env: *env,
579 Origin: os.Getenv("CALLS_ORIGIN"),
580 InstanceKey: os.Getenv("CALLS_INSTANCE_KEY"),
581 DEK: os.Getenv("CALLS_DEK"),
582 AllowedAddresses: strings.Split(os.Getenv("CALLS_ALLOWED_ADDRESSES"), ","),
583 Mailer: mailer,
584 RelayConfigured: os.Getenv("CARLOS_RELAY") != "",
585 Zone: os.Getenv("CALLS_ZONE"),
586 Logger: logger,
587 }
588
589 mux, err := meet.App(d, cfg)
590 if err != nil {
591 logger.Error("build app", "err", err)
592 os.Exit(1)
593 }
594
595 opts.Mux = mux
596 opts.DBPath = ""
597 if err := rastrillo.Serve(opts); err != nil {
598 logger.Error("serve failed", "err", err)
599 os.Exit(1)
600 }
601}
602```
603
604- [ ] **Step 7: Update the harness and write the config test**
605
606Replace `newApp` in `internal/callstest/harness_test.go` so it passes a development `Config`:
607
608```go
609func newApp(t *testing.T) http.Handler {
610 t.Helper()
611 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
612 d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
613 if err != nil {
614 t.Fatal(err)
615 }
616 t.Cleanup(func() { d.Close() })
617 h, err := meet.App(d, meet.Config{Env: meet.EnvDevelopment, Origin: testOrigin, Logger: logger})
618 if err != nil {
619 t.Fatal(err)
620 }
621 return h
622}
623```
624
625Create `internal/callstest/config_test.go`:
626
627```go
628package callstest
629
630import (
631 "io"
632 "log/slog"
633 "path/filepath"
634 "strings"
635 "testing"
636
637 "amadan.net/rastrillo/rastrillo/db"
638
639 "amadan.net/oficina/meet/internal/calls"
640)
641
642func openDB(t *testing.T) *db.DB {
643 t.Helper()
644 d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), slog.New(slog.NewTextHandler(io.Discard, nil)))
645 if err != nil {
646 t.Fatal(err)
647 }
648 t.Cleanup(func() { d.Close() })
649 return d
650}
651
652func TestProductionRefusesToStartWithoutSecrets(t *testing.T) {
653 cases := map[string]meet.Config{
654 "no origin": {Env: "production"},
655 "no key": {Env: "production", Origin: "https://x.test"},
656 "no dek": {Env: "production", Origin: "https://x.test", InstanceKey: "k"},
657 "no mailer": {Env: "production", Origin: "https://x.test", InstanceKey: "k", DEK: strings.Repeat("A", 44)},
658 "no allowlist": {Env: "production", Origin: "https://x.test", InstanceKey: "k", DEK: strings.Repeat("A", 44), Mailer: nopMailer{}},
659 }
660 for name, cfg := range cases {
661 if _, err := meet.App(openDB(t), cfg); err == nil {
662 t.Errorf("%s: production started without it", name)
663 }
664 }
665}
666
667func TestDevelopmentStartsWithNothing(t *testing.T) {
668 if _, err := meet.App(openDB(t), meet.Config{Env: meet.EnvDevelopment}); err != nil {
669 t.Fatalf("development refused: %v", err)
670 }
671}
672
673func TestBadDEKIsRefusedEvenInDevelopment(t *testing.T) {
674 _, err := meet.App(openDB(t), meet.Config{Env: meet.EnvDevelopment, DEK: "not base64!"})
675 if err == nil || !strings.Contains(err.Error(), "base64") {
676 t.Fatalf("want a base64 refusal, got %v", err)
677 }
678}
679```
680
681Add `nopMailer` to `harness_test.go`:
682
683```go
684type nopMailer struct{}
685
686func (nopMailer) Send(ctx context.Context, to, subject, body string) error { return nil }
687```
688
689- [ ] **Step 8: Run the tests**
690
691Run: `go test ./... 2>&1 | tail -8`
692Expected: PASS for `internal/calls/seal` and `internal/callstest` (index tests still pass; the config tests pass).
693
694- [ ] **Step 9: Tool directive and release stamping**
695
696Run: `go get -tool amadan.net/rastrillo/rastrillo/cmd/rastrillo@v0.26.0 && go mod tidy`
697
698Replace the `release` target in `Makefile` and add `version-check`, copying slides:
699
700```make
701# What the running binary reports from GET /api/version. An unstamped
702# binary answers "dev" from every build, so the platform's deploy
703# verification can never disagree with itself. Stamping is what gives
704# that check the ability to fail. Outside a repository, or before the
705# first commit, name it yourself: make release VERSION=v0.1.0
706VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null)
707
708.PHONY: build release version-check test vet fmt-check migration-check ci
709
710release: version-check
711 @mkdir -p releases
712 CGO_ENABLED=0 GOOS=$(RELEASE_GOOS) GOARCH=$(RELEASE_GOARCH) \
713 go build -ldflags="-s -w -X 'amadan.net/rastrillo/rastrillo.BuildVersion=$(VERSION)'" \
714 -o $(RELEASE_BIN) ./cmd/$(APP)
715 @echo
716 @echo "built $(RELEASE_BIN) reporting version $(VERSION)"
717 @echo "ship it with:"
718 @echo " carlos ship -app $(APP) -target $(RELEASE_GOOS)-$(RELEASE_GOARCH) $(RELEASE_BIN)"
719
720version-check:
721 @if [ -z "$(VERSION)" ]; then \
722 echo "make release: no version to stamp."; \
723 echo " git describe found nothing here: no repository, or no commit yet."; \
724 echo " Commit, or name it: make release VERSION=v0.1.0"; \
725 exit 1; \
726 fi
727 @case "$(VERSION)" in *-dirty) \
728 echo "make release: the tree is dirty, so $(VERSION) does not name what you are about to ship."; \
729 echo " Commit first, or name it: make release VERSION=v0.1.0-wip"; \
730 exit 1;; \
731 esac
732```
733
734Remove the old `.PHONY` line and the old `release:` target.
735
736- [ ] **Step 10: Run the gate and commit**
737
738Run: `make ci 2>&1 | tail -5`
739Expected: vet, gofmt, tests and migration check all pass.
740
741```bash
742git add -A
743git commit -m "config: refusals, sealer, release stamping
744
745Config with every startup refusal from spec 7.1, development selected
746only by the -env flag; the sealer lifted from calendar with Meet's
747label; make release stamps BuildVersion and refuses a dirty tree.
748
749Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
750```
751
752---
753
754### Task 2: Models and the migration
755
756**Files:**
757- Modify: `internal/calls/models.go`, `internal/calls/migrations.go`
758- Delete: `internal/calls/migrations/0001_init.sql`
759- Create: `internal/calls/migrations/0001_init.sql` (new content), regenerate `schema.sql`
760- Test: `internal/callstest/schema_test.go`
761
762**Interfaces:**
763- Produces: gorm structs `Person{ID int64; Email string; Name string; CreatedAt time.Time}`, `Meeting{ID int64; Sqid string; TitleSealed []byte; StartsAt *time.Time; CreatedBy string; CreatedAt time.Time; EndedAt *time.Time; EndedBy string}`, `MeetingLink{ID int64; MeetingID int64; TokenHash string; CreatedBy string; CreatedAt time.Time; RevokedAt *time.Time}`, `GuestGrant{ID int64; MeetingID int64; PersonID int64; GrantedAt, ExpiresAt time.Time; AdmittedAt *time.Time; AdmittedBy string}`, `PendingJoin{ID int64; MeetingID int64; LinkID int64; AddressFingerprint string; CreatedAt, ExpiresAt time.Time}`; `BootSchema = migrate.Merge(sessions.Schema, rauth.Schema, Schema)`.
764
765- [ ] **Step 1: Write the models**
766
767Replace `internal/calls/models.go`:
768
769```go
770package meet
771
772import "time"
773
774// Models is every model the schema generator manages. Keep it in step
775// with the structs below: `rastrillo migration generate` reads it, and
776// `rastrillo migration check` fails CI when it and the migrations
777// disagree.
778var Models = []any{
779 &Person{},
780 &Meeting{},
781 &MeetingLink{},
782 &GuestGrant{},
783 &PendingJoin{},
784}
785
786// Person is a signed-in address. Standing (member, guest, none) is
787// never a column; it is computed per request (spec §2.3).
788type Person struct {
789 ID int64
790 Email string `gorm:"uniqueIndex;not null"`
791 Name string `gorm:"not null"`
792 CreatedAt time.Time
793}
794
795// Meeting is a room that may be entered. Its title is sealed; its
796// times are plaintext because the list sorts on them.
797type Meeting struct {
798 ID int64
799 Sqid string `gorm:"uniqueIndex;not null"`
800 TitleSealed []byte `gorm:"column:title_sealed"`
801 StartsAt *time.Time
802 CreatedBy string `gorm:"not null"`
803 CreatedAt time.Time
804 EndedAt *time.Time
805 EndedBy string
806}
807
808// MeetingLink holds the SHA-256 of a token that was shown once
809// (ruling 16). Several may exist per meeting so a revoked one can be
810// replaced.
811type MeetingLink struct {
812 ID int64
813 MeetingID int64 `gorm:"index;not null"`
814 TokenHash string `gorm:"uniqueIndex;not null"`
815 CreatedBy string `gorm:"not null"`
816 CreatedAt time.Time
817 RevokedAt *time.Time
818}
819
820// GuestGrant is a non-member's entry authority for one meeting. One
821// row per person per meeting. AdmittedAt and AdmittedBy are an audit of
822// the first admission (slice 2) and confer nothing.
823type GuestGrant struct {
824 ID int64
825 MeetingID int64 `gorm:"uniqueIndex:idx_grant_meeting_person;not null"`
826 PersonID int64 `gorm:"uniqueIndex:idx_grant_meeting_person;not null"`
827 GrantedAt time.Time
828 ExpiresAt time.Time `gorm:"index"`
829 AdmittedAt *time.Time
830 AdmittedBy string
831}
832
833// PendingJoin is written before a magic link is mailed to someone who
834// followed a live meeting link, so the emailed link can admit them in
835// any browser and /continue can find their destination. The address is
836// a keyed fingerprint, never plaintext.
837type PendingJoin struct {
838 ID int64
839 MeetingID int64 `gorm:"not null"`
840 LinkID int64 `gorm:"not null"`
841 AddressFingerprint string `gorm:"index;not null"`
842 CreatedAt time.Time
843 ExpiresAt time.Time
844}
845```
846
847- [ ] **Step 2: Replace the scaffold migration**
848
849Nothing has shipped, so the scaffold's `notes` migration is replaced rather than appended to.
850
851Run: `rm internal/calls/migrations/0001_init.sql internal/calls/migrations/schema.sql && go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration generate 2>&1 | tail -5`
852
853Open the generated `internal/calls/migrations/0001_*.sql` and check it creates exactly `persons`, `meetings`, `meeting_links`, `guest_grants`, `pending_joins` with the indexes named above. If the generator named the file other than `0001_init.sql`, rename it to `0001_init.sql` and regenerate `schema.sql` with the same command.
854
855- [ ] **Step 3: Merge the sessions and auth schemas**
856
857In `internal/calls/migrations.go`, change the merge line and imports:
858
859```go
860import (
861 "embed"
862
863 rauth "amadan.net/rastrillo/rastrillo/auth"
864 "amadan.net/rastrillo/rastrillo/migrate"
865 "amadan.net/rastrillo/rastrillo/sessions"
866)
867
868//go:embed migrations/*.sql
869var migrationFS embed.FS
870
871var Schema = migrate.MustFromFS(migrationFS, "meet")
872
873// Apply order: sessions before auth (auth's second migration reads the
874// sessions table), Meet's own last.
875var BootSchema = migrate.Merge(sessions.Schema, rauth.Schema, Schema)
876```
877
878- [ ] **Step 4: Write the schema test**
879
880Create `internal/callstest/schema_test.go`:
881
882```go
883package callstest
884
885import (
886 "testing"
887
888 "amadan.net/oficina/meet/internal/calls"
889)
890
891func TestBootCreatesEveryTable(t *testing.T) {
892 d := openDB(t)
893 if _, err := meet.App(d, meet.Config{Env: meet.EnvDevelopment}); err != nil {
894 t.Fatal(err)
895 }
896 for _, table := range []string{"persons", "meetings", "meeting_links", "guest_grants", "pending_joins", "sessions", "auth_links"} {
897 var n int
898 if err := d.G.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&n).Error; err != nil {
899 t.Fatal(err)
900 }
901 if n != 1 {
902 t.Errorf("table %s missing after boot", table)
903 }
904 }
905}
906```
907
908- [ ] **Step 5: Run the gate and commit**
909
910Run: `make ci 2>&1 | tail -5`
911Expected: green, including `migration check`.
912
913```bash
914git add -A
915git commit -m "schema: persons, meetings, links, grants, pending joins
916
917The five tables of spec 2.1 replace the scaffold's notes; sessions and
918auth schemas apply first.
919
920Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
921```
922
923---
924
925### Task 3: Ids, store and standing
926
927**Files:**
928- Create: `internal/calls/ids.go`, `internal/calls/store.go`
929- Modify: `internal/calls/standing.go`
930- Test: `internal/calls/ids_test.go`, `internal/callstest/store_test.go`
931
932**Interfaces:**
933- Produces:
934 - `newSqid() (string, error)`, `newLinkToken() (token, hash string, err error)`, `hashLinkToken(token string) string`
935 - `type store struct{ g *gorm.DB; sealer *seal.Sealer; now func() time.Time }`
936 - `(*store).ensurePerson(ctx, address string) (Person, error)`
937 - `(*store).createMeeting(ctx, title string, startsAt *time.Time, actor string) (Meeting, token string, err error)` — creates the meeting and its first link in one transaction, returns the token once
938 - `(*store).listMeetings(ctx) ([]meetingView, error)` with `type meetingView struct{ Meeting; Title string; LinkPresent bool }`
939 - `(*store).meetingBySqid(ctx, sqid string) (Meeting, bool, error)`; `(*store).title(m Meeting) string`
940 - `(*store).liveLink(ctx, token string) (MeetingLink, Meeting, bool, error)` — one `false` for unknown, revoked, ended
941 - `(*store).liveLinkByID(ctx, linkID int64) (MeetingLink, Meeting, bool, error)`
942 - `(*store).replaceLink(ctx, meetingID int64, actor string) (token string, err error)`
943 - `(*store).endMeeting(ctx, meetingID int64, actor string) error`
944 - `(*store).grantFor(ctx, meetingID, personID int64) (GuestGrant, bool, error)`; `(*store).mintGrant(ctx, m Meeting, personID int64) (GuestGrant, error)` (idempotent; expiry per ruling 4)
945 - `(*store).writePending(ctx, address string, link MeetingLink) error`; `(*store).pendingLive(ctx, address string) (bool, error)`; `(*store).latestPending(ctx, address string) (PendingJoin, bool, error)`; `(*store).deletePending(ctx, address string) error`
946 - `type Standing int` with `StandingNone, StandingGuest, StandingMember`; `(*app).standingFor(ctx, p Person, m Meeting) (Standing, error)`
947 - `const grantLife = 2 * time.Hour`, `const pendingLife = 15 * time.Minute`
948
949- [ ] **Step 1: Ids**
950
951Create `internal/calls/ids.go`:
952
953```go
954package meet
955
956import (
957 "crypto/rand"
958 "crypto/sha256"
959 "encoding/base64"
960 "encoding/hex"
961 "fmt"
962)
963
964// sqidAlphabet is Docs' and Slides' alphabet, character for character:
965// no 0/O, no 1/l/I, so a link read aloud survives.
966const sqidAlphabet = "23456789abcdefghjkmnpqrstuvwxyz"
967const sqidLength = 10
968const maxSqidAttempts = 5
969
970func newSqid() (string, error) {
971 raw := make([]byte, sqidLength)
972 if _, err := rand.Read(raw); err != nil {
973 return "", fmt.Errorf("calls: generate sqid: %w", err)
974 }
975 out := make([]byte, sqidLength)
976 for i, b := range raw {
977 out[i] = sqidAlphabet[int(b)%len(sqidAlphabet)]
978 }
979 return string(out), nil
980}
981
982const linkTokenBytes = 32
983
984// newLinkToken mints a token and its hash. The token is returned
985// exactly once, to be shown exactly once (ruling 16).
986func newLinkToken() (token, hash string, err error) {
987 buf := make([]byte, linkTokenBytes)
988 if _, err := rand.Read(buf); err != nil {
989 return "", "", err
990 }
991 token = base64.RawURLEncoding.EncodeToString(buf)
992 return token, hashLinkToken(token), nil
993}
994
995func hashLinkToken(token string) string {
996 sum := sha256.Sum256([]byte(token))
997 return hex.EncodeToString(sum[:])
998}
999```
1000
1001Create `internal/calls/ids_test.go`:
1002
1003```go
1004package meet
1005
1006import (
1007 "strings"
1008 "testing"
1009)
1010
1011func TestSqidUsesOnlyTheReadableAlphabet(t *testing.T) {
1012 for i := 0; i < 50; i++ {
1013 s, err := newSqid()
1014 if err != nil {
1015 t.Fatal(err)
1016 }
1017 if len(s) != sqidLength || strings.Trim(s, sqidAlphabet) != "" {
1018 t.Fatalf("bad sqid %q", s)
1019 }
1020 }
1021}
1022
1023func TestLinkTokenHashIsSHA256Hex(t *testing.T) {
1024 tok, hash, err := newLinkToken()
1025 if err != nil {
1026 t.Fatal(err)
1027 }
1028 if len(hash) != 64 || hashLinkToken(tok) != hash {
1029 t.Fatalf("hash %q does not match token", hash)
1030 }
1031}
1032```
1033
1034- [ ] **Step 2: Store**
1035
1036Create `internal/calls/store.go`:
1037
1038```go
1039package meet
1040
1041import (
1042 "context"
1043 "errors"
1044 "fmt"
1045 "strings"
1046 "time"
1047
1048 "gorm.io/gorm"
1049 "gorm.io/gorm/clause"
1050
1051 "amadan.net/oficina/meet/internal/calls/seal"
1052)
1053
1054// grantLife is ruling 4: a guest grant expires two hours after the
1055// meeting's start, or two hours after creation when there is no start.
1056const grantLife = 2 * time.Hour
1057
1058// pendingLife matches the pending-join cookie: fifteen minutes.
1059const pendingLife = 15 * time.Minute
1060
1061// store is every database access in the app. Sealing happens here and
1062// nowhere else, so a handler never holds a plaintext title it did not
1063// get from the store.
1064type store struct {
1065 g *gorm.DB
1066 sealer *seal.Sealer
1067 now func() time.Time
1068}
1069
1070func actorFor(p Person) string { return fmt.Sprintf("person:%d", p.ID) }
1071
1072// ensurePerson reads first and writes only when the read is empty:
1073// this runs on every signed-in request, and SQLite has one writer.
1074func (s *store) ensurePerson(ctx context.Context, address string) (Person, error) {
1075 email := normalizeAddress(address)
1076 if email == "" {
1077 return Person{}, errors.New("calls: cannot map an empty address to a person")
1078 }
1079 var found Person
1080 err := s.g.WithContext(ctx).Where("email = ?", email).Take(&found).Error
1081 switch {
1082 case err == nil:
1083 return found, nil
1084 case !errors.Is(err, gorm.ErrRecordNotFound):
1085 return Person{}, fmt.Errorf("calls: load person: %w", err)
1086 }
1087 name := email
1088 if at := strings.Index(email, "@"); at > 0 {
1089 name = email[:at]
1090 }
1091 created := Person{Email: email, Name: name, CreatedAt: s.now().UTC()}
1092 if res := s.g.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&created); res.Error != nil {
1093 return Person{}, fmt.Errorf("calls: create person: %w", res.Error)
1094 }
1095 if err := s.g.WithContext(ctx).Where("email = ?", email).Take(&found).Error; err != nil {
1096 return Person{}, fmt.Errorf("calls: load person: %w", err)
1097 }
1098 return found, nil
1099}
1100
1101type meetingView struct {
1102 Meeting
1103 Title string
1104 LinkPresent bool
1105}
1106
1107func (s *store) title(m Meeting) string {
1108 t, err := s.sealer.OpenString(seal.PurposeMeetingTitle, m.TitleSealed)
1109 if err != nil {
1110 return "(title unreadable)"
1111 }
1112 return t
1113}
1114
1115// createMeeting creates the meeting and its first link in one
1116// transaction and returns the token, the only time it exists in
1117// plaintext on the server side.
1118func (s *store) createMeeting(ctx context.Context, title string, startsAt *time.Time, actor string) (Meeting, string, error) {
1119 sealed, err := s.sealer.SealString(seal.PurposeMeetingTitle, title)
1120 if err != nil {
1121 return Meeting{}, "", err
1122 }
1123 token, hash, err := newLinkToken()
1124 if err != nil {
1125 return Meeting{}, "", err
1126 }
1127 var m Meeting
1128 err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
1129 for attempt := 0; ; attempt++ {
1130 sqid, err := newSqid()
1131 if err != nil {
1132 return err
1133 }
1134 m = Meeting{Sqid: sqid, TitleSealed: sealed, StartsAt: startsAt, CreatedBy: actor, CreatedAt: s.now().UTC()}
1135 res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&m)
1136 if res.Error != nil {
1137 return res.Error
1138 }
1139 if res.RowsAffected == 1 {
1140 break
1141 }
1142 if attempt+1 >= maxSqidAttempts {
1143 return errors.New("calls: could not mint a unique sqid")
1144 }
1145 }
1146 return tx.Create(&MeetingLink{MeetingID: m.ID, TokenHash: hash, CreatedBy: actor, CreatedAt: s.now().UTC()}).Error
1147 })
1148 if err != nil {
1149 return Meeting{}, "", err
1150 }
1151 return m, token, nil
1152}
1153
1154func (s *store) listMeetings(ctx context.Context) ([]meetingView, error) {
1155 var ms []Meeting
1156 if err := s.g.WithContext(ctx).Order("ended_at IS NOT NULL, starts_at IS NULL, starts_at ASC, created_at DESC").Find(&ms).Error; err != nil {
1157 return nil, err
1158 }
1159 out := make([]meetingView, 0, len(ms))
1160 for _, m := range ms {
1161 var n int64
1162 if err := s.g.WithContext(ctx).Model(&MeetingLink{}).Where("meeting_id = ? AND revoked_at IS NULL", m.ID).Count(&n).Error; err != nil {
1163 return nil, err
1164 }
1165 out = append(out, meetingView{Meeting: m, Title: s.title(m), LinkPresent: n > 0})
1166 }
1167 return out, nil
1168}
1169
1170func (s *store) meetingBySqid(ctx context.Context, sqid string) (Meeting, bool, error) {
1171 var m Meeting
1172 err := s.g.WithContext(ctx).Where("sqid = ?", sqid).Take(&m).Error
1173 if errors.Is(err, gorm.ErrRecordNotFound) {
1174 return Meeting{}, false, nil
1175 }
1176 return m, err == nil, err
1177}
1178
1179// liveLink answers one false for a token that never existed, one that
1180// was revoked, and one whose meeting has ended (spec §3.2's one 404).
1181func (s *store) liveLink(ctx context.Context, token string) (MeetingLink, Meeting, bool, error) {
1182 if token == "" {
1183 return MeetingLink{}, Meeting{}, false, nil
1184 }
1185 var l MeetingLink
1186 err := s.g.WithContext(ctx).Where("token_hash = ?", hashLinkToken(token)).Take(&l).Error
1187 if errors.Is(err, gorm.ErrRecordNotFound) {
1188 return MeetingLink{}, Meeting{}, false, nil
1189 }
1190 if err != nil {
1191 return MeetingLink{}, Meeting{}, false, err
1192 }
1193 return s.liveLinkRow(ctx, l)
1194}
1195
1196func (s *store) liveLinkByID(ctx context.Context, id int64) (MeetingLink, Meeting, bool, error) {
1197 var l MeetingLink
1198 err := s.g.WithContext(ctx).Where("id = ?", id).Take(&l).Error
1199 if errors.Is(err, gorm.ErrRecordNotFound) {
1200 return MeetingLink{}, Meeting{}, false, nil
1201 }
1202 if err != nil {
1203 return MeetingLink{}, Meeting{}, false, err
1204 }
1205 return s.liveLinkRow(ctx, l)
1206}
1207
1208func (s *store) liveLinkRow(ctx context.Context, l MeetingLink) (MeetingLink, Meeting, bool, error) {
1209 if l.RevokedAt != nil {
1210 return MeetingLink{}, Meeting{}, false, nil
1211 }
1212 var m Meeting
1213 if err := s.g.WithContext(ctx).Where("id = ?", l.MeetingID).Take(&m).Error; err != nil {
1214 return MeetingLink{}, Meeting{}, false, err
1215 }
1216 if m.EndedAt != nil {
1217 return MeetingLink{}, Meeting{}, false, nil
1218 }
1219 return l, m, true, nil
1220}
1221
1222// replaceLink revokes every live link for the meeting and mints one
1223// new one, in one transaction, returning the new token once.
1224func (s *store) replaceLink(ctx context.Context, meetingID int64, actor string) (string, error) {
1225 token, hash, err := newLinkToken()
1226 if err != nil {
1227 return "", err
1228 }
1229 now := s.now().UTC()
1230 err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
1231 if err := tx.Model(&MeetingLink{}).Where("meeting_id = ? AND revoked_at IS NULL", meetingID).Update("revoked_at", now).Error; err != nil {
1232 return err
1233 }
1234 return tx.Create(&MeetingLink{MeetingID: meetingID, TokenHash: hash, CreatedBy: actor, CreatedAt: now}).Error
1235 })
1236 if err != nil {
1237 return "", err
1238 }
1239 return token, nil
1240}
1241
1242// endMeeting sets ended_at and revokes every link.
1243func (s *store) endMeeting(ctx context.Context, meetingID int64, actor string) error {
1244 now := s.now().UTC()
1245 return s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
1246 res := tx.Model(&Meeting{}).Where("id = ? AND ended_at IS NULL", meetingID).
1247 Select("ended_at", "ended_by").Updates(map[string]any{"ended_at": now, "ended_by": actor})
1248 if res.Error != nil {
1249 return res.Error
1250 }
1251 return tx.Model(&MeetingLink{}).Where("meeting_id = ? AND revoked_at IS NULL", meetingID).Update("revoked_at", now).Error
1252 })
1253}
1254
1255func (s *store) grantFor(ctx context.Context, meetingID, personID int64) (GuestGrant, bool, error) {
1256 var g GuestGrant
1257 err := s.g.WithContext(ctx).Where("meeting_id = ? AND person_id = ?", meetingID, personID).Take(&g).Error
1258 if errors.Is(err, gorm.ErrRecordNotFound) {
1259 return GuestGrant{}, false, nil
1260 }
1261 return g, err == nil, err
1262}
1263
1264// mintGrant is idempotent: following the link twice touches nothing.
1265func (s *store) mintGrant(ctx context.Context, m Meeting, personID int64) (GuestGrant, error) {
1266 if g, ok, err := s.grantFor(ctx, m.ID, personID); err != nil || ok {
1267 return g, err
1268 }
1269 now := s.now().UTC()
1270 base := now
1271 if m.StartsAt != nil {
1272 base = m.StartsAt.UTC()
1273 }
1274 g := GuestGrant{MeetingID: m.ID, PersonID: personID, GrantedAt: now, ExpiresAt: base.Add(grantLife)}
1275 if res := s.g.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&g); res.Error != nil {
1276 return GuestGrant{}, res.Error
1277 }
1278 g, _, err := s.grantFor(ctx, m.ID, personID)
1279 return g, err
1280}
1281
1282func (s *store) fingerprint(address string) (string, error) {
1283 return s.sealer.Fingerprint(seal.PurposePendingAddress, normalizeAddress(address))
1284}
1285
1286func (s *store) writePending(ctx context.Context, address string, link MeetingLink) error {
1287 fp, err := s.fingerprint(address)
1288 if err != nil {
1289 return err
1290 }
1291 now := s.now().UTC()
1292 return s.g.WithContext(ctx).Create(&PendingJoin{
1293 MeetingID: link.MeetingID, LinkID: link.ID, AddressFingerprint: fp,
1294 CreatedAt: now, ExpiresAt: now.Add(pendingLife),
1295 }).Error
1296}
1297
1298// pendingLive is the Authorize half: does this address hold an
1299// unexpired pending row whose link is still live?
1300func (s *store) pendingLive(ctx context.Context, address string) (bool, error) {
1301 p, ok, err := s.latestPending(ctx, address)
1302 if err != nil || !ok {
1303 return false, err
1304 }
1305 _, _, live, err := s.liveLinkByID(ctx, p.LinkID)
1306 return live, err
1307}
1308
1309func (s *store) latestPending(ctx context.Context, address string) (PendingJoin, bool, error) {
1310 fp, err := s.fingerprint(address)
1311 if err != nil {
1312 return PendingJoin{}, false, err
1313 }
1314 var p PendingJoin
1315 err = s.g.WithContext(ctx).Where("address_fingerprint = ? AND expires_at > ?", fp, s.now().UTC()).
1316 Order("created_at DESC").Take(&p).Error
1317 if errors.Is(err, gorm.ErrRecordNotFound) {
1318 return PendingJoin{}, false, nil
1319 }
1320 return p, err == nil, err
1321}
1322
1323func (s *store) deletePending(ctx context.Context, address string) error {
1324 fp, err := s.fingerprint(address)
1325 if err != nil {
1326 return err
1327 }
1328 return s.g.WithContext(ctx).Where("address_fingerprint = ?", fp).Delete(&PendingJoin{}).Error
1329}
1330```
1331
1332- [ ] **Step 3: Standing**
1333
1334Append to `internal/calls/standing.go`:
1335
1336```go
1337// Standing is a person's entry authority toward a meeting (spec §2.3).
1338// Computed, never stored.
1339type Standing int
1340
1341const (
1342 StandingNone Standing = iota
1343 StandingGuest
1344 StandingMember
1345)
1346
1347func (s Standing) String() string {
1348 switch s {
1349 case StandingMember:
1350 return "member"
1351 case StandingGuest:
1352 return "guest"
1353 }
1354 return "none"
1355}
1356
1357// isMember is the lone-shape roster read: the allowlist.
1358func (a *app) isMember(p Person) bool {
1359 return a.cfg.allow.addrs != nil && a.cfg.allow.allows(p.Email)
1360}
1361
1362func (a *app) standingFor(ctx context.Context, p Person, m Meeting) (Standing, error) {
1363 if a.isMember(p) {
1364 return StandingMember, nil
1365 }
1366 g, ok, err := a.store.grantFor(ctx, m.ID, p.ID)
1367 if err != nil {
1368 return StandingNone, err
1369 }
1370 if ok && g.ExpiresAt.After(a.store.now()) {
1371 return StandingGuest, nil
1372 }
1373 return StandingNone, nil
1374}
1375```
1376
1377Note `isMember` returns false for everyone when the allowlist is nil (development with no addresses): a development instance with no team has no members, which is what makes the guest tests below possible. Add the import `"context"` to the file.
1378
1379Add `store *store` to the `app` struct in `app.go` and construct it: after `sealer` is built, `st := &store{g: d.G, sealer: sealer, now: time.Now}` and `a := &app{db: d, cfg: rc, sealer: sealer, store: st, logger: rc.Logger}`. Also add a package-level test hook the harness can set:
1380
1381```go
1382// SetClockForTest replaces the store's clock; the harness uses it to
1383// expire grants and pending rows without sleeping.
1384func (a *app) SetClockForTest(now func() time.Time) { a.store.now = now }
1385```
1386
1387Since the harness only holds an `http.Handler`, expose the hook differently: make `App` return the mux and also register the `*app` in a package variable the test package can reach through an exported helper:
1388
1389```go
1390// NewForTest builds the app and returns the handler and a clock setter.
1391// Tests only; production uses App.
1392func NewForTest(d *db.DB, cfg Config) (*http.ServeMux, func(func() time.Time), error) {
1393 mux, a, err := build(d, cfg)
1394 if err != nil {
1395 return nil, nil, err
1396 }
1397 return mux, a.SetClockForTest, nil
1398}
1399```
1400
1401and refactor `App` into `build(d, cfg) (*http.ServeMux, *app, error)` with `App` calling it and discarding the `*app`.
1402
1403- [ ] **Step 4: Harness upgrade**
1404
1405In `internal/callstest/harness_test.go` replace `newApp` with:
1406
1407```go
1408type harness struct {
1409 h http.Handler
1410 db *db.DB
1411 mail *recordingMailer
1412 clock func(func() time.Time)
1413 now time.Time
1414}
1415
1416func (x *harness) advance(d time.Duration) {
1417 x.now = x.now.Add(d)
1418 now := x.now
1419 x.clock(func() time.Time { return now })
1420}
1421
1422type recordingMailer struct {
1423 mu sync.Mutex
1424 to []string
1425 links []string
1426}
1427
1428var magicLink = regexp.MustCompile(`https?://\S+/auth/verify\?token=\S+`)
1429
1430func (m *recordingMailer) Send(ctx context.Context, to, subject, body string) error {
1431 m.mu.Lock()
1432 defer m.mu.Unlock()
1433 m.to = append(m.to, to)
1434 m.links = append(m.links, magicLink.FindString(body))
1435 return nil
1436}
1437
1438func (m *recordingMailer) last() (string, string) {
1439 m.mu.Lock()
1440 defer m.mu.Unlock()
1441 if len(m.to) == 0 {
1442 return "", ""
1443 }
1444 return m.to[len(m.to)-1], m.links[len(m.links)-1]
1445}
1446
1447func (m *recordingMailer) count() int {
1448 m.mu.Lock()
1449 defer m.mu.Unlock()
1450 return len(m.to)
1451}
1452
1453func newHarness(t *testing.T, members ...string) *harness {
1454 t.Helper()
1455 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1456 d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
1457 if err != nil {
1458 t.Fatal(err)
1459 }
1460 t.Cleanup(func() { d.Close() })
1461 rec := &recordingMailer{}
1462 mux, setClock, err := meet.NewForTest(d, meet.Config{
1463 Env: meet.EnvDevelopment, Origin: testOrigin, AllowedAddresses: members, Mailer: rec, Logger: logger,
1464 })
1465 if err != nil {
1466 t.Fatal(err)
1467 }
1468 x := &harness{h: mux, db: d, mail: rec, clock: setClock, now: time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC)}
1469 x.advance(0)
1470 return x
1471}
1472
1473func newApp(t *testing.T) http.Handler { return newHarness(t).h }
1474```
1475
1476Keep `get` and `post`; add cookie-carrying variants used from Task 4 on:
1477
1478```go
1479type client struct {
1480 t *testing.T
1481 x *harness
1482 cookies map[string]*http.Cookie
1483}
1484
1485func (x *harness) browser(t *testing.T) *client {
1486 return &client{t: t, x: x, cookies: map[string]*http.Cookie{}}
1487}
1488
1489func (c *client) do(req *http.Request) *httptest.ResponseRecorder {
1490 for _, ck := range c.cookies {
1491 req.AddCookie(ck)
1492 }
1493 rec := httptest.NewRecorder()
1494 c.x.h.ServeHTTP(rec, req)
1495 for _, ck := range rec.Result().Cookies() {
1496 if ck.MaxAge < 0 {
1497 delete(c.cookies, ck.Name)
1498 continue
1499 }
1500 c.cookies[ck.Name] = ck
1501 }
1502 return rec
1503}
1504
1505func (c *client) get(target string) *httptest.ResponseRecorder {
1506 return c.do(httptest.NewRequest(http.MethodGet, target, nil))
1507}
1508
1509func (c *client) post(target string, form url.Values) *httptest.ResponseRecorder {
1510 req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode()))
1511 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
1512 req.Header.Set("Origin", testOrigin)
1513 return c.do(req)
1514}
1515```
1516
1517- [ ] **Step 5: Store tests through a test-only accessor**
1518
1519The store is unexported; test it through `meet` package tests. Create `internal/calls/store_test.go`:
1520
1521```go
1522package meet
1523
1524import (
1525 "bytes"
1526 "context"
1527 "io"
1528 "log/slog"
1529 "path/filepath"
1530 "testing"
1531 "time"
1532
1533 "amadan.net/rastrillo/rastrillo/db"
1534 "amadan.net/rastrillo/rastrillo/migrate"
1535
1536 "amadan.net/oficina/meet/internal/calls/seal"
1537)
1538
1539func testStore(t *testing.T) (*store, *time.Time) {
1540 t.Helper()
1541 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1542 d, err := db.Open(filepath.Join(t.TempDir(), "s.db"), logger)
1543 if err != nil {
1544 t.Fatal(err)
1545 }
1546 t.Cleanup(func() { d.Close() })
1547 if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil {
1548 t.Fatal(err)
1549 }
1550 sl, _ := seal.New(bytes.Repeat([]byte{9}, 32))
1551 now := time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC)
1552 return &store{g: d.G, sealer: sl, now: func() time.Time { return now }}, &now
1553}
1554
1555func TestCreateMeetingSealsTitleAndMintsOneLiveLink(t *testing.T) {
1556 s, _ := testStore(t)
1557 ctx := context.Background()
1558 m, token, err := s.createMeeting(ctx, "Board meeting", nil, "person:1")
1559 if err != nil {
1560 t.Fatal(err)
1561 }
1562 if bytes.Contains(m.TitleSealed, []byte("Board")) {
1563 t.Fatal("title stored in plaintext")
1564 }
1565 if s.title(m) != "Board meeting" {
1566 t.Fatalf("title opened as %q", s.title(m))
1567 }
1568 l, m2, ok, err := s.liveLink(ctx, token)
1569 if err != nil || !ok || m2.ID != m.ID || l.MeetingID != m.ID {
1570 t.Fatalf("live link not found: ok=%v err=%v", ok, err)
1571 }
1572 if _, _, ok, _ := s.liveLink(ctx, "nope"); ok {
1573 t.Fatal("unknown token answered live")
1574 }
1575}
1576
1577func TestReplaceLinkRevokesTheOldOne(t *testing.T) {
1578 s, _ := testStore(t)
1579 ctx := context.Background()
1580 m, old, _ := s.createMeeting(ctx, "x", nil, "person:1")
1581 fresh, err := s.replaceLink(ctx, m.ID, "person:1")
1582 if err != nil {
1583 t.Fatal(err)
1584 }
1585 if _, _, ok, _ := s.liveLink(ctx, old); ok {
1586 t.Fatal("old link still live")
1587 }
1588 if _, _, ok, _ := s.liveLink(ctx, fresh); !ok {
1589 t.Fatal("new link not live")
1590 }
1591}
1592
1593func TestEndMeetingKillsItsLinks(t *testing.T) {
1594 s, _ := testStore(t)
1595 ctx := context.Background()
1596 m, tok, _ := s.createMeeting(ctx, "x", nil, "person:1")
1597 if err := s.endMeeting(ctx, m.ID, "person:1"); err != nil {
1598 t.Fatal(err)
1599 }
1600 if _, _, ok, _ := s.liveLink(ctx, tok); ok {
1601 t.Fatal("link live after end")
1602 }
1603}
1604
1605func TestGrantExpiresTwoHoursAfterStartOrCreation(t *testing.T) {
1606 s, now := testStore(t)
1607 ctx := context.Background()
1608 start := now.Add(3 * time.Hour)
1609 scheduled, _, _ := s.createMeeting(ctx, "later", &start, "person:1")
1610 immediate, _, _ := s.createMeeting(ctx, "now", nil, "person:1")
1611 g1, _ := s.mintGrant(ctx, scheduled, 7)
1612 g2, _ := s.mintGrant(ctx, immediate, 7)
1613 if !g1.ExpiresAt.Equal(start.Add(grantLife)) {
1614 t.Errorf("scheduled grant expires %v, want %v", g1.ExpiresAt, start.Add(grantLife))
1615 }
1616 if !g2.ExpiresAt.Equal(now.Add(grantLife)) {
1617 t.Errorf("immediate grant expires %v, want %v", g2.ExpiresAt, now.Add(grantLife))
1618 }
1619 again, _ := s.mintGrant(ctx, immediate, 7)
1620 if again.ID != g2.ID {
1621 t.Fatal("minting twice made a second grant")
1622 }
1623}
1624
1625func TestPendingIsKeyedFingerprintAndFollowsItsLink(t *testing.T) {
1626 s, now := testStore(t)
1627 ctx := context.Background()
1628 m, tok, _ := s.createMeeting(ctx, "x", nil, "person:1")
1629 l, _, _, _ := s.liveLink(ctx, tok)
1630 if err := s.writePending(ctx, "Guest@Example.com", l); err != nil {
1631 t.Fatal(err)
1632 }
1633 var raw []byte
1634 if err := s.g.Raw("SELECT address_fingerprint FROM pending_joins").Scan(&raw).Error; err != nil {
1635 t.Fatal(err)
1636 }
1637 if bytes.Contains(bytes.ToLower(raw), []byte("guest@")) {
1638 t.Fatal("address stored in plaintext")
1639 }
1640 if live, _ := s.pendingLive(ctx, "guest@example.com"); !live {
1641 t.Fatal("pending not live")
1642 }
1643 if _, err := s.replaceLink(ctx, m.ID, "person:1"); err != nil {
1644 t.Fatal(err)
1645 }
1646 if live, _ := s.pendingLive(ctx, "guest@example.com"); live {
1647 t.Fatal("pending still live after its link was replaced")
1648 }
1649 *now = now.Add(pendingLife + time.Minute)
1650 if _, ok, _ := s.latestPending(ctx, "guest@example.com"); ok {
1651 t.Fatal("expired pending returned")
1652 }
1653}
1654```
1655
1656- [ ] **Step 6: Run the gate and commit**
1657
1658Run: `make ci 2>&1 | tail -5`
1659Expected: green.
1660
1661```bash
1662git add -A
1663git commit -m "store: meetings, links, grants, pending joins, standing
1664
1665Sealed titles, one-404 link lookup, replace and end in transactions,
1666idempotent grants expiring per ruling 4, keyed-fingerprint pending
1667rows bound to their link, standing computed per request.
1668
1669Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
1670```
1671
1672---
1673
1674### Task 4: Sign-in, the template tree, and the person middleware
1675
1676**Files:**
1677- Create: `internal/calls/auth.go`, `internal/calls/templates/signin.html`, `internal/calls/templates/errors.html`, `internal/calls/templates/welcome.html`
1678- Modify: `internal/calls/render.go`, `internal/calls/templates/layout.html`, `internal/calls/app.go`, `internal/calls/handlers.go`
1679- Test: `internal/callstest/signin_test.go`
1680
1681**Interfaces:**
1682- Produces: routes `GET /signin`, `POST /signin` (gated), `GET /auth/callback`, `GET /auth/verify`, `POST /signout`, `GET /continue`, `GET /welcome`; middleware `(*app).requirePerson` and `currentPerson(r) (Person, bool)`; `ErrorPage(w, r, status, ref)`; `render(w, name, data)` with `ui` functions available; harness `signIn(c *client, address string)` that completes the emailed link and leaves the session cookie on the client.
1683- Consumes: `store.ensurePerson`, `allowlist.allows`, `store.pendingLive` (from Task 3; the pending exception itself is Task 6, but `Authorize` is written once here).
1684
1685- [ ] **Step 1: Template tree with ui functions**
1686
1687Replace `internal/calls/render.go`:
1688
1689```go
1690package meet
1691
1692import (
1693 "bytes"
1694 "embed"
1695 "html/template"
1696 "net/http"
1697
1698 "amadan.net/rastrillo/rastrillo"
1699 "amadan.net/rastrillo/rastrillo/ui"
1700
1701 "amadan.net/oficina/meet/internal/calls/icons"
1702)
1703
1704//go:embed templates static
1705var appFS embed.FS
1706
1707var assets = rastrillo.NewAssets(appFS)
1708
1709var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join"}
1710
1711var pages = map[string]*template.Template{}
1712
1713func init() {
1714 for _, name := range pageNames {
1715 pages[name] = buildPage(name)
1716 }
1717}
1718
1719// buildPage parses rastrillo's ui partials first, then the layout and
1720// the page, so a page can call confirm-form, field, empty-state and the
1721// rest by name with a dict.
1722func buildPage(name string) *template.Template {
1723 return template.Must(
1724 template.Must(template.New("layout").
1725 Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
1726 Funcs(template.FuncMap{"asset": assets.Path}).
1727 ParseFS(ui.Templates(), "*.html")).
1728 ParseFS(appFS, "templates/layout.html", "templates/"+name+".html"))
1729}
1730
1731func render(w http.ResponseWriter, name string, data any) {
1732 var buf bytes.Buffer
1733 if err := pages[name].ExecuteTemplate(&buf, "layout", data); err != nil {
1734 http.Error(w, "something went wrong", http.StatusInternalServerError)
1735 return
1736 }
1737 buf.WriteTo(w)
1738}
1739
1740// ErrorPage renders the one error template with the status and the
1741// reference rastrillo minted for the log line.
1742func ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
1743 w.WriteHeader(status)
1744 render(w, "errors", map[string]any{"Status": status, "Ref": ref})
1745}
1746```
1747
1748Replace `internal/calls/templates/layout.html`:
1749
1750```html
1751{{define "layout"}}<!doctype html>
1752<html lang="en">
1753<head>
1754<meta charset="utf-8">
1755<meta name="viewport" content="width=device-width, initial-scale=1">
1756<title>{{block "title" .}}Meet{{end}}</title>
1757<link rel="stylesheet" href="{{asset "static/tokens.css"}}">
1758<script defer src="{{asset "static/rastrillo.js"}}"></script>
1759{{iconAssets}}
1760<script defer src="{{asset "static/select.js"}}"></script>
1761</head>
1762<body>
1763<main>
1764<div rst-page>
1765{{template "content" .}}
1766</div>
1767</main>
1768</body>
1769</html>
1770{{end}}
1771```
1772
1773Create `internal/calls/templates/errors.html`:
1774
1775```html
1776{{define "title"}}Something went wrong{{end}}
1777{{define "content"}}
1778{{template "page-header" dict "Title" (printf "%d" .Status)}}
1779<p>That did not work.{{if .Ref}} Reference {{.Ref}}.{{end}}</p>
1780<p><a href="/">Back to meetings</a></p>
1781{{end}}
1782```
1783
1784Create `internal/calls/templates/signin.html`:
1785
1786```html
1787{{define "title"}}Sign in{{end}}
1788{{define "content"}}
1789{{template "page-header" dict "Title" "Sign in to Meet"}}
1790{{if .Sent}}
1791<p rst-notice>If that address can sign in here, a link is on its way. Open it on this device to continue.</p>
1792{{else}}
1793{{if .Error}}<p rst-notice role="alert">{{.Error}}</p>{{end}}
1794<form rst-form method="post" action="/signin">
1795 {{template "field" dict "ID" "address" "Name" "address" "Label" "Email address" "Type" "email" "Required" true "Autocomplete" "email" "Autofocus" true}}
1796 <div rst-form-actions><button type="submit" rst-btn="primary lg">Send me a sign-in link</button></div>
1797</form>
1798{{end}}
1799{{end}}
1800```
1801
1802Create `internal/calls/templates/welcome.html`:
1803
1804```html
1805{{define "title"}}Signed in{{end}}
1806{{define "content"}}
1807{{template "page-header" dict "Title" "You're signed in"}}
1808<p>You're signed in as {{.Email}}. This team's Meet has no meetings for you. If someone sent you a meeting link, open it again now.</p>
1809<form method="post" action="/signout"><button type="submit" rst-btn="ghost">Sign out</button></form>
1810{{end}}
1811```
1812
1813Replace `internal/calls/templates/index.html` with a placeholder Task 5 fills:
1814
1815```html
1816{{define "title"}}Meetings{{end}}
1817{{define "content"}}
1818{{template "page-header" dict "Title" "Meetings"}}
1819{{end}}
1820```
1821
1822Create empty placeholders so `init` parses (Task 5 and 7 replace them): `internal/calls/templates/link_once.html`, `confirm.html`, `join.html`, each containing:
1823
1824```html
1825{{define "title"}}Meet{{end}}
1826{{define "content"}}{{end}}
1827```
1828
1829- [ ] **Step 2: Auth wiring**
1830
1831Create `internal/calls/auth.go`:
1832
1833```go
1834package meet
1835
1836import (
1837 "context"
1838 "database/sql"
1839 "net/http"
1840 "net/url"
1841 "strings"
1842
1843 rauth "amadan.net/rastrillo/rastrillo/auth"
1844 "amadan.net/rastrillo/rastrillo/sessions"
1845)
1846
1847const signinPath = "/signin"
1848const sentLocation = signinPath + "?sent=1"
1849
1850// newAuthPlugin wires rastrillo/auth. Authorize is Meet's one departure
1851// from Docs (spec §3.2): an address is admitted at the emailed link when
1852// it is on the allowlist, or when it holds an unexpired pending-join row
1853// whose link is still live. The row was written before the mail went
1854// out, so the click is admitted in whichever browser it lands.
1855func (a *app) newAuthPlugin(writer *sql.DB) (*rauth.Auth, error) {
1856 return rauth.New(rauth.Config{
1857 DB: writer,
1858 Origin: a.cfg.Origin,
1859 InstanceKey: a.cfg.instanceKey,
1860 Mailer: a.cfg.mailer,
1861 Authorize: a.authorize,
1862 SigninPath: signinPath,
1863 SignedInPath: "/continue",
1864 Logger: a.logger,
1865 })
1866}
1867
1868func (a *app) authorize(address string) bool {
1869 if a.cfg.allow.allows(address) {
1870 return true
1871 }
1872 live, err := a.store.pendingLive(context.Background(), address)
1873 if err != nil {
1874 a.logger.Error("calls: authorize: pending lookup", "err", err)
1875 return false
1876 }
1877 return live
1878}
1879
1880// gateBegin sits in front of rastrillo's Begin. The guarantee: every
1881// answer is byte-identical, 303 Location: /signin?sent=1, whatever the
1882// address's standing, so the endpoint is not a membership oracle. Task 6
1883// adds the pending-join exception in front of the allowlist check.
1884func (a *app) gateBegin(next http.HandlerFunc) http.HandlerFunc {
1885 return func(w http.ResponseWriter, r *http.Request) {
1886 _ = r.ParseForm()
1887 address := r.FormValue("address")
1888 if !a.mayRequestLink(w, r, address) {
1889 http.Redirect(w, r, sentLocation, http.StatusSeeOther)
1890 return
1891 }
1892 if r.Form == nil {
1893 r.Form = url.Values{}
1894 }
1895 r.Form.Set("force", "1")
1896 next(&sameAnswer{ResponseWriter: w, app: a}, r)
1897 }
1898}
1899
1900// mayRequestLink decides whether Begin is reached. Task 6 extends it.
1901func (a *app) mayRequestLink(w http.ResponseWriter, r *http.Request, address string) bool {
1902 return a.cfg.allow.allows(address)
1903}
1904
1905// sameAnswer rewrites rastrillo's own refusal redirects to the sent
1906// location. It hooks WriteHeader because http.Redirect sets Location
1907// and then calls WriteHeader, so the Location is visible in time.
1908type sameAnswer struct {
1909 http.ResponseWriter
1910 app *app
1911}
1912
1913func (s *sameAnswer) WriteHeader(status int) {
1914 if loc := s.Header().Get("Location"); loc != "" && loc != sentLocation && strings.HasPrefix(loc, signinPath+"?err=") {
1915 s.app.logger.Warn("calls: sign-in refused; answering as if sent", "outcome", loc)
1916 s.Header().Set("Location", sentLocation)
1917 }
1918 s.ResponseWriter.WriteHeader(status)
1919}
1920
1921type personCtxKey struct{}
1922
1923// requirePerson runs behind RequireSession: it maps the session subject
1924// (the verified address; sessions.UserID would return 0, false) to a
1925// persons row, creating it on first sign-in.
1926func (a *app) requirePerson(next http.Handler) http.Handler {
1927 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1928 s, ok := sessions.Current(r)
1929 if !ok || s.Subject == "" {
1930 a.logger.Error("calls: session with no subject reached requirePerson", "path", r.URL.Path)
1931 ErrorPage(w, r, http.StatusInternalServerError, "")
1932 return
1933 }
1934 p, err := a.store.ensurePerson(r.Context(), s.Subject)
1935 if err != nil {
1936 a.logger.Error("calls: ensure person", "err", err)
1937 ErrorPage(w, r, http.StatusInternalServerError, "")
1938 return
1939 }
1940 next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), personCtxKey{}, p)))
1941 })
1942}
1943
1944func currentPerson(r *http.Request) (Person, bool) {
1945 p, ok := r.Context().Value(personCtxKey{}).(Person)
1946 return p, ok
1947}
1948
1949// requireMember is spec §2.5's first row: current member standing.
1950func (a *app) requireMember(next http.Handler) http.Handler {
1951 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1952 p, ok := currentPerson(r)
1953 if !ok || !a.isMember(p) {
1954 ErrorPage(w, r, http.StatusNotFound, "")
1955 return
1956 }
1957 next.ServeHTTP(w, r)
1958 })
1959}
1960
1961func (a *app) signin(w http.ResponseWriter, r *http.Request) {
1962 data := map[string]any{"Sent": r.URL.Query().Get("sent") == "1"}
1963 switch r.URL.Query().Get("err") {
1964 case "address":
1965 data["Error"] = "That does not look like an email address."
1966 case "rate":
1967 data["Error"] = "Too many sign-in attempts just now. Wait a few minutes and try again."
1968 case "expired":
1969 data["Error"] = "That sign-in link has expired. Ask for a new one."
1970 case "":
1971 default:
1972 data["Error"] = "Sign-in did not work. Try again."
1973 }
1974 render(w, "signin", data)
1975}
1976
1977// continueSignedIn is SignedInPath. Task 6 adds the pending-join branch
1978// in front; this slice's first version routes members to the list and
1979// everyone else to /welcome.
1980func (a *app) continueSignedIn(w http.ResponseWriter, r *http.Request) {
1981 p, ok := currentPerson(r)
1982 if !ok {
1983 ErrorPage(w, r, http.StatusInternalServerError, "")
1984 return
1985 }
1986 if dest, handled := a.continuePending(w, r, p); handled {
1987 http.Redirect(w, r, dest, http.StatusSeeOther)
1988 return
1989 }
1990 if a.isMember(p) {
1991 http.Redirect(w, r, "/", http.StatusSeeOther)
1992 return
1993 }
1994 http.Redirect(w, r, "/welcome", http.StatusSeeOther)
1995}
1996
1997// continuePending is filled in by Task 6.
1998func (a *app) continuePending(w http.ResponseWriter, r *http.Request, p Person) (string, bool) {
1999 return "", false
2000}
2001
2002func (a *app) welcome(w http.ResponseWriter, r *http.Request) {
2003 p, _ := currentPerson(r)
2004 render(w, "welcome", map[string]any{"Email": p.Email})
2005}
2006```
2007
2008- [ ] **Step 3: Router with the three tiers**
2009
2010Replace the router section of `build` in `app.go` (after `a := &app{...}`):
2011
2012```go
2013 writer, err := d.G.DB()
2014 if err != nil {
2015 return nil, nil, err
2016 }
2017 authPlugin, err := a.newAuthPlugin(writer)
2018 if err != nil {
2019 return nil, nil, err
2020 }
2021
2022 r := chi.NewRouter()
2023 r.Use(csrf.Protect(rc.Origin))
2024
2025 // Inside CSRF, outside the session: sign-in and, in Task 6, /j/.
2026 r.Get(signinPath, a.signin)
2027 r.Post(signinPath, a.gateBegin(authPlugin.Begin))
2028 r.Get("/auth/callback", authPlugin.Callback)
2029 r.Get("/auth/verify", authPlugin.Verify)
2030 r.Post("/signout", authPlugin.Signout)
2031
2032 // Signed in, with a persons row.
2033 r.Group(func(r chi.Router) {
2034 r.Use(authPlugin.RequireSession)
2035 r.Use(a.requirePerson)
2036 r.Get("/continue", a.continueSignedIn)
2037 r.Get("/welcome", a.welcome)
2038 r.Group(func(r chi.Router) {
2039 r.Use(a.requireMember)
2040 r.Get("/", a.index)
2041 })
2042 })
2043
2044 mux := http.NewServeMux()
2045 mux.Handle("GET /static/", assets.Handler())
2046 mux.Handle("/", r)
2047 return mux, a, nil
2048```
2049
2050- [ ] **Step 4: Harness sign-in helper and tests**
2051
2052Add to `harness_test.go`:
2053
2054```go
2055// signIn asks for a magic link as address and follows it in this
2056// client, leaving the session cookie behind. It returns the Location of
2057// the post-sign-in redirect.
2058func (c *client) signIn(address string) string {
2059 c.t.Helper()
2060 rec := c.post("/signin", url.Values{"address": {address}})
2061 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" {
2062 c.t.Fatalf("POST /signin: %d %s", rec.Code, rec.Header().Get("Location"))
2063 }
2064 to, link := c.x.mail.last()
2065 if to != address || link == "" {
2066 c.t.Fatalf("no magic link mailed to %s (last to %q)", address, to)
2067 }
2068 return c.follow(link)
2069}
2070
2071// follow opens an emailed link in this client and returns where it led.
2072func (c *client) follow(link string) string {
2073 c.t.Helper()
2074 u, err := url.Parse(link)
2075 if err != nil {
2076 c.t.Fatal(err)
2077 }
2078 rec := c.get(u.RequestURI())
2079 if rec.Code != http.StatusSeeOther {
2080 c.t.Fatalf("GET %s: %d\n%s", u.RequestURI(), rec.Code, rec.Body.String())
2081 }
2082 loc := rec.Header().Get("Location")
2083 rec = c.get(loc)
2084 if rec.Code == http.StatusSeeOther {
2085 return rec.Header().Get("Location")
2086 }
2087 return loc
2088}
2089```
2090
2091Create `internal/callstest/signin_test.go`:
2092
2093```go
2094package callstest
2095
2096import (
2097 "net/http"
2098 "net/url"
2099 "strings"
2100 "testing"
2101)
2102
2103func TestMemberSignsInAndLandsOnTheList(t *testing.T) {
2104 x := newHarness(t, "paul@example.com")
2105 c := x.browser(t)
2106 if dest := c.signIn("paul@example.com"); dest != "/" {
2107 t.Fatalf("member landed on %s, want /", dest)
2108 }
2109 if rec := c.get("/"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Meetings") {
2110 t.Fatalf("GET / after sign-in: %d", rec.Code)
2111 }
2112}
2113
2114func TestOutsiderWithoutALinkGetsNoMailAndTheSameAnswer(t *testing.T) {
2115 x := newHarness(t, "paul@example.com")
2116 c := x.browser(t)
2117 rec := c.post("/signin", url.Values{"address": {"stranger@example.com"}})
2118 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" {
2119 t.Fatalf("refusal differs from success: %d %s", rec.Code, rec.Header().Get("Location"))
2120 }
2121 if x.mail.count() != 0 {
2122 t.Fatal("mail was sent to a non-member with no link")
2123 }
2124}
2125
2126func TestSignedOutIsRedirectedToSignin(t *testing.T) {
2127 x := newHarness(t, "paul@example.com")
2128 rec := x.browser(t).get("/")
2129 if rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/signin") {
2130 t.Fatalf("GET / signed out: %d %s", rec.Code, rec.Header().Get("Location"))
2131 }
2132}
2133
2134func TestNonMemberSessionSeesWelcomeNotTheList(t *testing.T) {
2135 // Development with no allowlist admits every address at sign-in but
2136 // makes nobody a member; that is the shape the guest tests rely on.
2137 x := newHarness(t)
2138 c := x.browser(t)
2139 if dest := c.signIn("anyone@example.com"); dest != "/welcome" {
2140 t.Fatalf("non-member landed on %s, want /welcome", dest)
2141 }
2142 if rec := c.get("/"); rec.Code != http.StatusNotFound {
2143 t.Fatalf("non-member GET /: %d, want 404", rec.Code)
2144 }
2145 if rec := c.get("/welcome"); !strings.Contains(rec.Body.String(), "anyone@example.com") {
2146 t.Fatal("welcome page does not name the address")
2147 }
2148}
2149```
2150
2151- [ ] **Step 5: Run the gate and commit**
2152
2153Run: `make ci 2>&1 | tail -5`
2154Expected: green. If `TestIndexRenders` from the scaffold now fails because `/` requires a session, replace its body with `newHarness(t, "paul@example.com").browser(t)` signing in first, and drop the "this is a rastrillo app" assertion for "Meetings".
2155
2156```bash
2157git add -A
2158git commit -m "auth: magic-link sign-in, persons, continue and welcome
2159
2160rastrillo/auth behind Docs' gate with Slides' same-answer wrapper,
2161Authorize admitting allowlist or a live pending row, SignedInPath at
2162/continue, requirePerson and requireMember middleware, the template
2163tree with rastrillo's ui partials.
2164
2165Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
2166```
2167
2168---
2169
2170### Task 5: Meetings: list, create, link once, replace, end
2171
2172**Files:**
2173- Create: `internal/calls/handlers_meetings.go`
2174- Modify: `internal/calls/templates/index.html`, `link_once.html`, `confirm.html`, `internal/calls/app.go`, `internal/calls/handlers.go` (delete `index` from it)
2175- Test: `internal/callstest/meetings_test.go`
2176
2177**Interfaces:**
2178- Produces: routes `GET /` (list with create form), `POST /meetings`, `GET /meetings/{sqid}/links/replace`, `POST /meetings/{sqid}/links`, `GET /meetings/{sqid}/end`, `POST /meetings/{sqid}/end`; `(*app).linkURL(token) string`; `(*app).parseStart(form) (*time.Time, string)`.
2179
2180- [ ] **Step 1: Handlers**
2181
2182Create `internal/calls/handlers_meetings.go`:
2183
2184```go
2185package meet
2186
2187import (
2188 "net/http"
2189 "net/url"
2190 "strings"
2191 "time"
2192
2193 "github.com/go-chi/chi/v5"
2194)
2195
2196const maxTitleLen = 200
2197
2198// linkURL is built from configuration, never from the Host header.
2199func (a *app) linkURL(token string) string {
2200 return a.cfg.Origin + "/j/" + token
2201}
2202
2203// parseStart reads the optional start time from the form in the
2204// instance zone (config.go). Returns a message when it is malformed.
2205func (a *app) parseStart(form url.Values) (*time.Time, string) {
2206 raw := strings.TrimSpace(form.Get("starts_at"))
2207 if raw == "" {
2208 return nil, ""
2209 }
2210 t, err := time.ParseInLocation("2006-01-02T15:04", raw, a.cfg.zone)
2211 if err != nil {
2212 return nil, "Enter the start as a date and time, or leave it blank to start now."
2213 }
2214 u := t.UTC()
2215 return &u, ""
2216}
2217
2218func (a *app) fmtStart(t *time.Time) string {
2219 if t == nil {
2220 return ""
2221 }
2222 return t.In(a.cfg.zone).Format("Mon 2 Jan 2006, 15:04") + " " + a.cfg.Zone
2223}
2224
2225func (a *app) index(w http.ResponseWriter, r *http.Request) {
2226 a.renderIndex(w, r, "", "")
2227}
2228
2229func (a *app) renderIndex(w http.ResponseWriter, r *http.Request, titleErr, startErr string) {
2230 views, err := a.store.listMeetings(r.Context())
2231 if err != nil {
2232 a.logger.Error("calls: list meetings", "err", err)
2233 ErrorPage(w, r, http.StatusInternalServerError, "")
2234 return
2235 }
2236 type row struct {
2237 Sqid, Title, Starts string
2238 Ended, LinkPresent bool
2239 }
2240 var upcoming, ended []row
2241 for _, v := range views {
2242 rw := row{Sqid: v.Sqid, Title: v.Title, Starts: a.fmtStart(v.StartsAt), Ended: v.EndedAt != nil, LinkPresent: v.LinkPresent}
2243 if rw.Ended {
2244 ended = append(ended, rw)
2245 } else {
2246 upcoming = append(upcoming, rw)
2247 }
2248 }
2249 render(w, "index", map[string]any{
2250 "Upcoming": upcoming, "Ended": ended, "Zone": a.cfg.Zone,
2251 "TitleError": titleErr, "StartError": startErr,
2252 })
2253}
2254
2255func (a *app) createMeeting(w http.ResponseWriter, r *http.Request) {
2256 _ = r.ParseForm()
2257 title := strings.TrimSpace(r.FormValue("title"))
2258 var titleErr string
2259 switch {
2260 case title == "":
2261 titleErr = "Give the meeting a title."
2262 case len(title) > maxTitleLen:
2263 titleErr = "Keep the title under 200 characters."
2264 }
2265 start, startErr := a.parseStart(r.Form)
2266 if titleErr != "" || startErr != "" {
2267 w.WriteHeader(http.StatusUnprocessableEntity)
2268 a.renderIndex(w, r, titleErr, startErr)
2269 return
2270 }
2271 p, _ := currentPerson(r)
2272 m, token, err := a.store.createMeeting(r.Context(), title, start, actorFor(p))
2273 if err != nil {
2274 a.logger.Error("calls: create meeting", "err", err)
2275 ErrorPage(w, r, http.StatusInternalServerError, "")
2276 return
2277 }
2278 a.renderLinkOnce(w, m, token, false)
2279}
2280
2281// renderLinkOnce answers with a page, not a redirect: a token in a
2282// query string lands in the edge's access logs (Sheets' ruling), and
2283// the page is the one time the token exists in plaintext.
2284func (a *app) renderLinkOnce(w http.ResponseWriter, m Meeting, token string, replaced bool) {
2285 render(w, "link_once", map[string]any{
2286 "Sqid": m.Sqid, "Title": a.store.title(m), "Starts": a.fmtStart(m.StartsAt),
2287 "Link": a.linkURL(token), "Replaced": replaced,
2288 })
2289}
2290
2291// meetingForMember loads the meeting in the URL or answers 404.
2292func (a *app) meetingForMember(w http.ResponseWriter, r *http.Request) (Meeting, bool) {
2293 m, ok, err := a.store.meetingBySqid(r.Context(), chi.URLParam(r, "sqid"))
2294 if err != nil {
2295 a.logger.Error("calls: load meeting", "err", err)
2296 ErrorPage(w, r, http.StatusInternalServerError, "")
2297 return Meeting{}, false
2298 }
2299 if !ok {
2300 ErrorPage(w, r, http.StatusNotFound, "")
2301 return Meeting{}, false
2302 }
2303 return m, true
2304}
2305
2306func (a *app) confirmReplaceLink(w http.ResponseWriter, r *http.Request) {
2307 m, ok := a.meetingForMember(w, r)
2308 if !ok {
2309 return
2310 }
2311 render(w, "confirm", map[string]any{
2312 "Title": "Replace the link?",
2313 "Body": "Everyone holding the current link for \"" + a.store.title(m) + "\" will get a not-found page. You will see the new link once.",
2314 "Action": "/meetings/" + m.Sqid + "/links", "Label": "Replace the link",
2315 "CancelHref": "/", "Sqid": m.Sqid,
2316 })
2317}
2318
2319func (a *app) replaceLink(w http.ResponseWriter, r *http.Request) {
2320 m, ok := a.meetingForMember(w, r)
2321 if !ok {
2322 return
2323 }
2324 if m.EndedAt != nil {
2325 ErrorPage(w, r, http.StatusNotFound, "")
2326 return
2327 }
2328 p, _ := currentPerson(r)
2329 token, err := a.store.replaceLink(r.Context(), m.ID, actorFor(p))
2330 if err != nil {
2331 a.logger.Error("calls: replace link", "err", err)
2332 ErrorPage(w, r, http.StatusInternalServerError, "")
2333 return
2334 }
2335 a.renderLinkOnce(w, m, token, true)
2336}
2337
2338func (a *app) confirmEnd(w http.ResponseWriter, r *http.Request) {
2339 m, ok := a.meetingForMember(w, r)
2340 if !ok {
2341 return
2342 }
2343 render(w, "confirm", map[string]any{
2344 "Title": "End the meeting?",
2345 "Body": "\"" + a.store.title(m) + "\" will close for everyone and its link will stop working.",
2346 "Action": "/meetings/" + m.Sqid + "/end", "Label": "End the meeting",
2347 "CancelHref": "/", "Sqid": m.Sqid,
2348 })
2349}
2350
2351func (a *app) endMeeting(w http.ResponseWriter, r *http.Request) {
2352 m, ok := a.meetingForMember(w, r)
2353 if !ok {
2354 return
2355 }
2356 p, _ := currentPerson(r)
2357 if err := a.store.endMeeting(r.Context(), m.ID, actorFor(p)); err != nil {
2358 a.logger.Error("calls: end meeting", "err", err)
2359 ErrorPage(w, r, http.StatusInternalServerError, "")
2360 return
2361 }
2362 http.Redirect(w, r, "/", http.StatusSeeOther)
2363}
2364```
2365
2366Delete `internal/calls/handlers.go` (its `index` now lives above).
2367
2368- [ ] **Step 2: Templates**
2369
2370Replace `internal/calls/templates/index.html`:
2371
2372```html
2373{{define "title"}}Meetings{{end}}
2374{{define "content"}}
2375{{template "page-header" dict "Title" "Meetings"}}
2376<section>
2377<h2>New meeting</h2>
2378<form rst-form method="post" action="/meetings">
2379 {{template "field" dict "ID" "title" "Name" "title" "Label" "Title" "Required" true "Maxlength" "200" "Error" .TitleError "Autofocus" true}}
2380 {{template "field" dict "ID" "starts_at" "Name" "starts_at" "Label" (printf "Starts (%s), or leave blank to start now" .Zone) "Type" "datetime-local" "Error" .StartError}}
2381 <div rst-form-actions><button type="submit" rst-btn="primary lg">Create meeting</button></div>
2382</form>
2383</section>
2384<section>
2385<h2>Upcoming</h2>
2386{{if .Upcoming}}
2387<div rst-list>
2388{{range .Upcoming}}
2389<article>
2390 <h3><a href="/m/{{.Sqid}}/join">{{.Title}}</a></h3>
2391 <p>{{if .Starts}}Starts {{.Starts}}{{else}}Starts now{{end}}{{if not .LinkPresent}} · no live link{{end}}</p>
2392 <p><a href="/meetings/{{.Sqid}}/links/replace">Replace the link</a> · <a href="/meetings/{{.Sqid}}/end">End</a></p>
2393</article>
2394{{end}}
2395</div>
2396{{else}}
2397{{template "empty-state" dict "Body" "No meetings yet. Make one above and share its link."}}
2398{{end}}
2399</section>
2400{{if .Ended}}
2401<details>
2402<summary>Ended meetings ({{len .Ended}})</summary>
2403<div rst-list>
2404{{range .Ended}}<article><h3>{{.Title}}</h3><p>Ended</p></article>{{end}}
2405</div>
2406</details>
2407{{end}}
2408{{end}}
2409```
2410
2411Replace `internal/calls/templates/link_once.html`:
2412
2413```html
2414{{define "title"}}Your meeting link{{end}}
2415{{define "content"}}
2416{{template "back-nav" dict "Href" "/" "Label" "Meetings"}}
2417{{template "page-header" dict "Title" .Title}}
2418{{if .Starts}}<p>Starts {{.Starts}}.</p>{{end}}
2419<p>{{if .Replaced}}The old link has stopped working. {{end}}This is the only time this link is shown. Copy it now and send it to the people you are meeting.</p>
2420<p><input rst-input readonly value="{{.Link}}" size="80" aria-label="Meeting link"></p>
2421<p><a href="/m/{{.Sqid}}/join" rst-btn="primary">Go to the meeting</a></p>
2422{{end}}
2423```
2424
2425Replace `internal/calls/templates/confirm.html`:
2426
2427```html
2428{{define "title"}}{{.Title}}{{end}}
2429{{define "content"}}
2430{{template "back-nav" dict "Href" .CancelHref "Label" "Meetings"}}
2431{{template "page-header" dict "Title" .Title}}
2432<div rst-box>
2433 <p>{{.Body}}</p>
2434 {{template "confirm-form" dict "Action" .Action "Label" .Label "Danger" true "CancelHref" .CancelHref "CancelLabel" "Cancel"}}
2435</div>
2436{{end}}
2437```
2438
2439- [ ] **Step 3: Routes**
2440
2441In `app.go`, inside the `requireMember` group replace `r.Get("/", a.index)` with:
2442
2443```go
2444 r.Get("/", a.index)
2445 r.Post("/meetings", a.createMeeting)
2446 r.Get("/meetings/{sqid}/links/replace", a.confirmReplaceLink)
2447 r.Post("/meetings/{sqid}/links", a.replaceLink)
2448 r.Get("/meetings/{sqid}/end", a.confirmEnd)
2449 r.Post("/meetings/{sqid}/end", a.endMeeting)
2450```
2451
2452- [ ] **Step 4: Tests, including the one rule**
2453
2454Create `internal/callstest/meetings_test.go`:
2455
2456```go
2457package callstest
2458
2459import (
2460 "bytes"
2461 "net/http"
2462 "net/url"
2463 "os"
2464 "path/filepath"
2465 "regexp"
2466 "strings"
2467 "testing"
2468)
2469
2470var linkInPage = regexp.MustCompile(`http://app\.test/j/([A-Za-z0-9_-]{43})`)
2471
2472func createMeeting(t *testing.T, c *client, title, starts string) (sqid, token string) {
2473 t.Helper()
2474 rec := c.post("/meetings", url.Values{"title": {title}, "starts_at": {starts}})
2475 if rec.Code != http.StatusOK {
2476 t.Fatalf("POST /meetings: %d\n%s", rec.Code, rec.Body.String())
2477 }
2478 m := linkInPage.FindStringSubmatch(rec.Body.String())
2479 if m == nil {
2480 t.Fatalf("no link on the page:\n%s", rec.Body.String())
2481 }
2482 s := regexp.MustCompile(`/m/([23456789a-z]{10})/join`).FindStringSubmatch(rec.Body.String())
2483 return s[1], m[1]
2484}
2485
2486func TestCreateShowsTheLinkOnceAndTheListNever(t *testing.T) {
2487 x := newHarness(t, "paul@example.com")
2488 c := x.browser(t)
2489 c.signIn("paul@example.com")
2490 _, token := createMeeting(t, c, "Board meeting", "")
2491 rec := c.get("/")
2492 if strings.Contains(rec.Body.String(), token) {
2493 t.Fatal("the list shows a link token")
2494 }
2495 if !strings.Contains(rec.Body.String(), "Board meeting") {
2496 t.Fatal("the list does not show the title")
2497 }
2498}
2499
2500func TestCreateRefusesAnEmptyTitleInPlace(t *testing.T) {
2501 x := newHarness(t, "paul@example.com")
2502 c := x.browser(t)
2503 c.signIn("paul@example.com")
2504 rec := c.post("/meetings", url.Values{"title": {" "}})
2505 if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "Give the meeting a title") {
2506 t.Fatalf("empty title: %d", rec.Code)
2507 }
2508}
2509
2510func TestReplaceLinkNeedsItsConfirmPageAndKillsTheOldLink(t *testing.T) {
2511 x := newHarness(t, "paul@example.com")
2512 c := x.browser(t)
2513 c.signIn("paul@example.com")
2514 sqid, old := createMeeting(t, c, "x", "")
2515 if rec := c.get("/meetings/" + sqid + "/links/replace"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Replace the link") {
2516 t.Fatalf("confirm page: %d", rec.Code)
2517 }
2518 rec := c.post("/meetings/"+sqid+"/links", nil)
2519 if rec.Code != http.StatusOK || !linkInPage.MatchString(rec.Body.String()) {
2520 t.Fatalf("replace: %d", rec.Code)
2521 }
2522 if rec := x.browser(t).get("/j/" + old); rec.Code != http.StatusNotFound {
2523 t.Fatalf("old link after replace: %d, want 404", rec.Code)
2524 }
2525}
2526
2527func TestEndMeetingNeedsItsConfirmPageAndRefusesJoinsAfter(t *testing.T) {
2528 x := newHarness(t, "paul@example.com")
2529 c := x.browser(t)
2530 c.signIn("paul@example.com")
2531 sqid, tok := createMeeting(t, c, "x", "")
2532 if rec := c.get("/meetings/" + sqid + "/end"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "End the meeting") {
2533 t.Fatalf("confirm page: %d", rec.Code)
2534 }
2535 if rec := c.post("/meetings/"+sqid+"/end", nil); rec.Code != http.StatusSeeOther {
2536 t.Fatalf("end: %d", rec.Code)
2537 }
2538 if rec := x.browser(t).get("/j/" + tok); rec.Code != http.StatusNotFound {
2539 t.Fatalf("link after end: %d, want 404", rec.Code)
2540 }
2541 if rec := c.get("/"); !strings.Contains(rec.Body.String(), "Ended meetings (1)") {
2542 t.Fatal("ended meeting not in the collapsed section")
2543 }
2544}
2545
2546func TestCrossOriginPostIsRefused(t *testing.T) {
2547 x := newHarness(t, "paul@example.com")
2548 c := x.browser(t)
2549 c.signIn("paul@example.com")
2550 sqid, _ := createMeeting(t, c, "x", "")
2551 req := httptestPost("/meetings/"+sqid+"/end", nil)
2552 req.Header.Set("Origin", "https://evil.example")
2553 rec := c.do(req)
2554 if rec.Code != http.StatusForbidden {
2555 t.Fatalf("cross-origin end: %d, want 403", rec.Code)
2556 }
2557}
2558
2559// The one rule (spec §0.1): the database file and its WAL never hold a
2560// plaintext title.
2561func TestTheDatabaseNeverHoldsAPlaintextTitle(t *testing.T) {
2562 x := newHarness(t, "paul@example.com")
2563 c := x.browser(t)
2564 c.signIn("paul@example.com")
2565 const secret = "Quarterly board meeting about the acquisition"
2566 createMeeting(t, c, secret, "")
2567 grep := func(when string) {
2568 for _, suffix := range []string{"", "-wal", "-shm"} {
2569 b, err := os.ReadFile(x.dbPath + suffix)
2570 if err != nil {
2571 continue
2572 }
2573 if bytes.Contains(b, []byte(secret)) {
2574 t.Errorf("%s: title in plaintext in %s", when, filepath.Base(x.dbPath+suffix))
2575 }
2576 }
2577 }
2578 grep("before checkpoint")
2579 if _, err := x.sqlDB().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
2580 t.Fatal(err)
2581 }
2582 grep("after checkpoint")
2583}
2584```
2585
2586Add to `harness_test.go`: a `dbPath string` field set in `newHarness` (the `filepath.Join(t.TempDir(), "app.db")` value), a method `func (x *harness) sqlDB() *sql.DB { s, _ := x.db.G.DB(); return s }`, and:
2587
2588```go
2589func httptestPost(target string, form url.Values) *http.Request {
2590 req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode()))
2591 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
2592 return req
2593}
2594```
2595
2596Note the `/j/` route does not exist until Task 6; the two tests that hit it will fail with 404 for the wrong reason until then. That is acceptable only because the assertion is 404 either way; Task 6's tests distinguish live from dead links.
2597
2598- [ ] **Step 5: Run the gate and commit**
2599
2600Run: `make ci 2>&1 | tail -5`
2601Expected: green.
2602
2603```bash
2604git add -A
2605git commit -m "meetings: list, create, link once, replace and end
2606
2607Sealed titles listed and opened through the store only; the link is
2608answered on a page, once; replace and end have their confirm pages;
2609the database and WAL never hold a plaintext title, tested before and
2610after a checkpoint.
2611
2612Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
2613```
2614
2615---
2616
2617### Task 6: Following a link: `/j/`, the pending-join cookie, the exception, budgets, `/continue`
2618
2619**Files:**
2620- Create: `internal/calls/joincookie.go`, `internal/calls/budget.go`, `internal/calls/handlers_join.go`
2621- Modify: `internal/calls/auth.go` (`mayRequestLink`, `continuePending`), `internal/calls/app.go`
2622- Test: `internal/calls/joincookie_test.go`, `internal/calls/budget_test.go`, `internal/callstest/join_test.go`
2623
2624**Interfaces:**
2625- Produces: route `GET /j/{token}`; `joinCookie{TokenHash string; Expires time.Time}` with `(*app).setJoinCookie(w, hash)`, `(*app).readJoinCookie(r) (joinCookie, bool)`, `(*app).clearJoinCookie(w)`; `budget` with `newBudget(limit int, window time.Duration) *budget` and `(*budget).allow(key string, now time.Time) bool`; the pending-join branch of `/continue`.
2626- Consumes: `store.liveLink`, `store.writePending`, `store.latestPending`, `store.liveLinkByID`, `store.mintGrant`, `store.deletePending`, `standingFor`.
2627
2628- [ ] **Step 1: The cookie**
2629
2630Create `internal/calls/joincookie.go`:
2631
2632```go
2633package meet
2634
2635import (
2636 "crypto/hmac"
2637 "crypto/sha256"
2638 "encoding/base64"
2639 "encoding/hex"
2640 "net/http"
2641 "strconv"
2642 "strings"
2643 "time"
2644)
2645
2646const joinCookieName = "calls_join"
2647
2648// joinCookie tells POST /signin which live link this browser followed.
2649// It carries the token's hash, not the token, so a cookie leak is not a
2650// link leak, and it is signed under the instance key so it cannot be
2651// forged. Its only job is that one hand-off (spec §3.2).
2652type joinCookie struct {
2653 TokenHash string
2654 Expires time.Time
2655}
2656
2657func (a *app) joinMAC(payload string) string {
2658 mac := hmac.New(sha256.New, []byte("meet/join\x00"+a.cfg.instanceKey))
2659 mac.Write([]byte(payload))
2660 return hex.EncodeToString(mac.Sum(nil))
2661}
2662
2663func (a *app) setJoinCookie(w http.ResponseWriter, tokenHash string) {
2664 exp := a.store.now().Add(pendingLife)
2665 payload := tokenHash + "|" + strconv.FormatInt(exp.Unix(), 10)
2666 value := base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + a.joinMAC(payload)
2667 http.SetCookie(w, &http.Cookie{
2668 Name: joinCookieName, Value: value, Path: "/", HttpOnly: true,
2669 Secure: strings.HasPrefix(a.cfg.Origin, "https://"), SameSite: http.SameSiteLaxMode,
2670 MaxAge: int(pendingLife.Seconds()),
2671 })
2672}
2673
2674func (a *app) clearJoinCookie(w http.ResponseWriter) {
2675 http.SetCookie(w, &http.Cookie{Name: joinCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
2676}
2677
2678func (a *app) readJoinCookie(r *http.Request) (joinCookie, bool) {
2679 ck, err := r.Cookie(joinCookieName)
2680 if err != nil {
2681 return joinCookie{}, false
2682 }
2683 parts := strings.SplitN(ck.Value, ".", 2)
2684 if len(parts) != 2 {
2685 return joinCookie{}, false
2686 }
2687 raw, err := base64.RawURLEncoding.DecodeString(parts[0])
2688 if err != nil {
2689 return joinCookie{}, false
2690 }
2691 payload := string(raw)
2692 if !hmac.Equal([]byte(a.joinMAC(payload)), []byte(parts[1])) {
2693 return joinCookie{}, false
2694 }
2695 hash, expStr, ok := strings.Cut(payload, "|")
2696 if !ok || len(hash) != 64 {
2697 return joinCookie{}, false
2698 }
2699 exp, err := strconv.ParseInt(expStr, 10, 64)
2700 if err != nil {
2701 return joinCookie{}, false
2702 }
2703 c := joinCookie{TokenHash: hash, Expires: time.Unix(exp, 0)}
2704 if !c.Expires.After(a.store.now()) {
2705 return joinCookie{}, false
2706 }
2707 return c, true
2708}
2709```
2710
2711Create `internal/calls/joincookie_test.go`:
2712
2713```go
2714package meet
2715
2716import (
2717 "net/http"
2718 "net/http/httptest"
2719 "strings"
2720 "testing"
2721 "time"
2722)
2723
2724func cookieApp(now time.Time) *app {
2725 return &app{cfg: resolved{instanceKey: "k", Config: Config{Origin: "http://app.test"}}, store: &store{now: func() time.Time { return now }}}
2726}
2727
2728func TestJoinCookieRoundTripsAndRejectsTampering(t *testing.T) {
2729 now := time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC)
2730 a := cookieApp(now)
2731 rec := httptest.NewRecorder()
2732 hash := strings.Repeat("ab", 32)
2733 a.setJoinCookie(rec, hash)
2734 ck := rec.Result().Cookies()[0]
2735 req := httptest.NewRequest(http.MethodGet, "/", nil)
2736 req.AddCookie(ck)
2737 got, ok := a.readJoinCookie(req)
2738 if !ok || got.TokenHash != hash {
2739 t.Fatalf("round trip failed: %+v %v", got, ok)
2740 }
2741 bad := *ck
2742 bad.Value = strings.Replace(bad.Value, ".", "x.", 1)
2743 req2 := httptest.NewRequest(http.MethodGet, "/", nil)
2744 req2.AddCookie(&bad)
2745 if _, ok := a.readJoinCookie(req2); ok {
2746 t.Fatal("tampered cookie accepted")
2747 }
2748 later := cookieApp(now.Add(pendingLife + time.Second))
2749 if _, ok := later.readJoinCookie(req); ok {
2750 t.Fatal("expired cookie accepted")
2751 }
2752}
2753```
2754
2755- [ ] **Step 2: The budget**
2756
2757Create `internal/calls/budget.go`:
2758
2759```go
2760package meet
2761
2762import (
2763 "sync"
2764 "time"
2765)
2766
2767// budget is a sliding-window counter: at most limit events per key per
2768// window. It counts attempts, refused or not, because the point is to
2769// keep outsider traffic away from rastrillo's shared limiter (spec
2770// §3.2), and an attempt that reaches Begin is what consumes that.
2771type budget struct {
2772 mu sync.Mutex
2773 limit int
2774 window time.Duration
2775 hits map[string][]time.Time
2776}
2777
2778func newBudget(limit int, window time.Duration) *budget {
2779 return &budget{limit: limit, window: window, hits: map[string][]time.Time{}}
2780}
2781
2782// allow records an attempt and reports whether it was within budget.
2783func (b *budget) allow(key string, now time.Time) bool {
2784 b.mu.Lock()
2785 defer b.mu.Unlock()
2786 cut := now.Add(-b.window)
2787 kept := b.hits[key][:0]
2788 for _, t := range b.hits[key] {
2789 if t.After(cut) {
2790 kept = append(kept, t)
2791 }
2792 }
2793 if len(kept) >= b.limit {
2794 b.hits[key] = kept
2795 return false
2796 }
2797 b.hits[key] = append(kept, now)
2798 return true
2799}
2800```
2801
2802Create `internal/calls/budget_test.go`:
2803
2804```go
2805package meet
2806
2807import (
2808 "testing"
2809 "time"
2810)
2811
2812func TestBudgetSlidesItsWindow(t *testing.T) {
2813 b := newBudget(2, time.Minute)
2814 now := time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC)
2815 if !b.allow("k", now) || !b.allow("k", now) {
2816 t.Fatal("first two refused")
2817 }
2818 if b.allow("k", now) {
2819 t.Fatal("third allowed")
2820 }
2821 if !b.allow("other", now) {
2822 t.Fatal("other key refused")
2823 }
2824 if !b.allow("k", now.Add(61*time.Second)) {
2825 t.Fatal("refused after the window slid")
2826 }
2827}
2828```
2829
2830- [ ] **Step 3: The exception in the gate and the pending branch of `/continue`**
2831
2832In `internal/calls/auth.go`, add fields to `app` (in `app.go`): `linkBudget *budget` and `outsiderBudget *budget`, constructed in `build` as `newBudget(5, 15*time.Minute)` and `newBudget(10, 15*time.Minute)`.
2833
2834Replace `mayRequestLink`:
2835
2836```go
2837// mayRequestLink decides whether Begin is reached (ruling 12). A member
2838// address always may. Any other address may only when the browser
2839// carries a valid pending-join cookie for a live link, within two
2840// budgets counted before Begin: five attempts per link and ten across
2841// all links per fifteen minutes. When it may, a pending_joins row is
2842// written first, for the member too (it carries their destination).
2843func (a *app) mayRequestLink(w http.ResponseWriter, r *http.Request, address string) bool {
2844 address = normalizeAddress(address)
2845 member := a.cfg.allow.allows(address)
2846 ck, hasCookie := a.readJoinCookie(r)
2847 if !hasCookie {
2848 return member
2849 }
2850 link, _, live, err := a.store.liveLinkByHash(r.Context(), ck.TokenHash)
2851 if err != nil {
2852 a.logger.Error("calls: link for cookie", "err", err)
2853 return member
2854 }
2855 if !live {
2856 return member
2857 }
2858 if !member {
2859 now := a.store.now()
2860 if !a.outsiderBudget.allow("all", now) || !a.linkBudget.allow(ck.TokenHash, now) {
2861 a.logger.Warn("calls: outsider sign-in attempts over budget", "link", ck.TokenHash[:8])
2862 return false
2863 }
2864 }
2865 if address == "" {
2866 return member
2867 }
2868 if err := a.store.writePending(r.Context(), address, link); err != nil {
2869 a.logger.Error("calls: write pending join", "err", err)
2870 return member
2871 }
2872 return true
2873}
2874```
2875
2876Add to `store.go`:
2877
2878```go
2879func (s *store) liveLinkByHash(ctx context.Context, hash string) (MeetingLink, Meeting, bool, error) {
2880 var l MeetingLink
2881 err := s.g.WithContext(ctx).Where("token_hash = ?", hash).Take(&l).Error
2882 if errors.Is(err, gorm.ErrRecordNotFound) {
2883 return MeetingLink{}, Meeting{}, false, nil
2884 }
2885 if err != nil {
2886 return MeetingLink{}, Meeting{}, false, err
2887 }
2888 return s.liveLinkRow(ctx, l)
2889}
2890```
2891
2892and make `liveLink` call `liveLinkByHash(ctx, hashLinkToken(token))` after its empty-token check.
2893
2894Replace `continuePending`:
2895
2896```go
2897// continuePending is /continue's first branch (spec §3.2): the most
2898// recent unexpired pending row for this person, re-checked against its
2899// link and meeting in one transaction with the grant, then deleted.
2900func (a *app) continuePending(w http.ResponseWriter, r *http.Request, p Person) (string, bool) {
2901 a.clearJoinCookie(w)
2902 pend, ok, err := a.store.latestPending(r.Context(), p.Email)
2903 if err != nil {
2904 a.logger.Error("calls: latest pending", "err", err)
2905 return "", false
2906 }
2907 if !ok {
2908 return "", false
2909 }
2910 dest, err := a.store.consumePending(r.Context(), p, pend, a.isMember(p))
2911 if err != nil {
2912 a.logger.Error("calls: consume pending", "err", err)
2913 return "", false
2914 }
2915 return dest, dest != ""
2916}
2917```
2918
2919Add to `store.go`:
2920
2921```go
2922// consumePending re-checks the pending row's link and meeting as
2923// GET /j/{token} would, mints the grant for a non-member, and deletes
2924// every pending row for the address, all in one transaction. It returns
2925// the holding-screen path, or "" when the re-check failed.
2926func (s *store) consumePending(ctx context.Context, p Person, pend PendingJoin, member bool) (string, error) {
2927 dest := ""
2928 err := s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
2929 ts := &store{g: tx, sealer: s.sealer, now: s.now}
2930 if err := ts.deletePending(ctx, p.Email); err != nil {
2931 return err
2932 }
2933 if !pend.ExpiresAt.After(s.now()) {
2934 return nil
2935 }
2936 _, m, live, err := ts.liveLinkByID(ctx, pend.LinkID)
2937 if err != nil || !live {
2938 return err
2939 }
2940 if !member {
2941 if _, err := ts.mintGrant(ctx, m, p.ID); err != nil {
2942 return err
2943 }
2944 }
2945 dest = "/m/" + m.Sqid + "/join"
2946 return nil
2947 })
2948 return dest, err
2949}
2950```
2951
2952- [ ] **Step 4: `GET /j/{token}`**
2953
2954Create `internal/calls/handlers_join.go`:
2955
2956```go
2957package meet
2958
2959import (
2960 "net/http"
2961
2962 "github.com/go-chi/chi/v5"
2963
2964 rauth "amadan.net/rastrillo/rastrillo/auth"
2965)
2966
2967// followLink is GET /j/{token}: one 404 for unknown, revoked, ended or
2968// expired; otherwise one of three outcomes (spec §3.2). It is mounted
2969// outside the session group because the person is often not signed in.
2970func (a *app) followLink(w http.ResponseWriter, r *http.Request) {
2971 link, m, live, err := a.store.liveLink(r.Context(), chi.URLParam(r, "token"))
2972 if err != nil {
2973 a.logger.Error("calls: follow link", "err", err)
2974 ErrorPage(w, r, http.StatusInternalServerError, "")
2975 return
2976 }
2977 if !live {
2978 ErrorPage(w, r, http.StatusNotFound, "")
2979 return
2980 }
2981 id, signedIn := a.auth.SessionFrom(r)
2982 if !signedIn {
2983 a.setJoinCookie(w, link.TokenHash)
2984 http.Redirect(w, r, signinPath, http.StatusSeeOther)
2985 return
2986 }
2987 p, err := a.store.ensurePerson(r.Context(), id.Address)
2988 if err != nil {
2989 a.logger.Error("calls: ensure person", "err", err)
2990 ErrorPage(w, r, http.StatusInternalServerError, "")
2991 return
2992 }
2993 if !a.isMember(p) {
2994 if _, err := a.store.mintGrant(r.Context(), m, p.ID); err != nil {
2995 a.logger.Error("calls: mint grant", "err", err)
2996 ErrorPage(w, r, http.StatusInternalServerError, "")
2997 return
2998 }
2999 }
3000 http.Redirect(w, r, "/m/"+m.Sqid+"/join", http.StatusSeeOther)
3001}
3002```
3003
3004Keep the auth plugin on the app: add `auth *rauth.Auth` to the `app` struct and set `a.auth = authPlugin` in `build`. Register the route in the CSRF tier, outside the session group: `r.Get("/j/{token}", a.followLink)`.
3005
3006- [ ] **Step 5: Tests**
3007
3008Create `internal/callstest/join_test.go`:
3009
3010```go
3011package callstest
3012
3013import (
3014 "net/http"
3015 "net/url"
3016 "strings"
3017 "testing"
3018 "time"
3019)
3020
3021func memberWithMeeting(t *testing.T) (*harness, string, string) {
3022 t.Helper()
3023 x := newHarness(t, "paul@example.com")
3024 c := x.browser(t)
3025 c.signIn("paul@example.com")
3026 sqid, tok := createMeeting(t, c, "Board meeting", "")
3027 return x, sqid, tok
3028}
3029
3030func TestUnknownRevokedAndEndedLinksAreOne404(t *testing.T) {
3031 x, _, _ := memberWithMeeting(t)
3032 rec := x.browser(t).get("/j/" + strings.Repeat("A", 43))
3033 if rec.Code != http.StatusNotFound {
3034 t.Fatalf("unknown: %d", rec.Code)
3035 }
3036}
3037
3038func TestSignedOutOutsiderSignsInThroughTheLinkInTheSameBrowser(t *testing.T) {
3039 x, sqid, tok := memberWithMeeting(t)
3040 g := x.browser(t)
3041 rec := g.get("/j/" + tok)
3042 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin" {
3043 t.Fatalf("follow signed out: %d %s", rec.Code, rec.Header().Get("Location"))
3044 }
3045 if _, ok := g.cookies["calls_join"]; !ok {
3046 t.Fatal("no pending-join cookie")
3047 }
3048 if dest := g.signIn("guest@example.com"); dest != "/m/"+sqid+"/join" {
3049 t.Fatalf("outsider landed on %s", dest)
3050 }
3051 if rec := g.get("/m/" + sqid + "/join"); rec.Code != http.StatusOK {
3052 t.Fatalf("holding page as guest: %d", rec.Code)
3053 }
3054 if rec := g.get("/"); rec.Code != http.StatusNotFound {
3055 t.Fatal("guest can see the meeting list")
3056 }
3057}
3058
3059func TestEmailedLinkWorksInAnotherBrowser(t *testing.T) {
3060 x, sqid, tok := memberWithMeeting(t)
3061 phone := x.browser(t)
3062 phone.get("/j/" + tok)
3063 rec := phone.post("/signin", url.Values{"address": {"guest@example.com"}})
3064 if rec.Header().Get("Location") != "/signin?sent=1" {
3065 t.Fatal("outsider with cookie refused")
3066 }
3067 _, link := x.mail.last()
3068 laptop := x.browser(t) // no cookie here
3069 if dest := laptop.follow(link); dest != "/m/"+sqid+"/join" {
3070 t.Fatalf("other-browser click landed on %s", dest)
3071 }
3072}
3073
3074func TestLinkReplacedBetweenMailAndClickAdmitsNobody(t *testing.T) {
3075 x, sqid, tok := memberWithMeeting(t)
3076 g := x.browser(t)
3077 g.get("/j/" + tok)
3078 g.post("/signin", url.Values{"address": {"guest@example.com"}})
3079 _, link := x.mail.last()
3080 member := x.browser(t)
3081 member.signIn("paul@example.com")
3082 member.post("/meetings/"+sqid+"/links", nil)
3083 u, _ := url.Parse(link)
3084 rec := g.get(u.RequestURI())
3085 if rec.Code != http.StatusForbidden {
3086 t.Fatalf("outsider's stale click: %d, want 403 from Authorize", rec.Code)
3087 }
3088 if rec := g.get("/m/" + sqid + "/join"); rec.Code == http.StatusOK {
3089 t.Fatal("a grant was minted through a replaced link")
3090 }
3091}
3092
3093func TestSignedOutMemberFollowsALinkAndLandsOnItsHoldingPage(t *testing.T) {
3094 x, sqid, tok := memberWithMeeting(t)
3095 m := x.browser(t)
3096 m.get("/j/" + tok)
3097 if dest := m.signIn("paul@example.com"); dest != "/m/"+sqid+"/join" {
3098 t.Fatalf("member landed on %s", dest)
3099 }
3100}
3101
3102func TestOutsiderWithoutCookieStillGetsNoMail(t *testing.T) {
3103 x, _, _ := memberWithMeeting(t)
3104 before := x.mail.count()
3105 x.browser(t).post("/signin", url.Values{"address": {"guest@example.com"}})
3106 if x.mail.count() != before {
3107 t.Fatal("mail sent to an outsider with no link")
3108 }
3109}
3110
3111func TestOutsiderBudgetsStopMailAtFivePerLinkAndTenOverall(t *testing.T) {
3112 x, _, tok := memberWithMeeting(t)
3113 before := x.mail.count()
3114 for i := 0; i < 7; i++ {
3115 g := x.browser(t)
3116 g.get("/j/" + tok)
3117 g.post("/signin", url.Values{"address": {"g" + string(rune('a'+i)) + "@example.com"}})
3118 }
3119 if got := x.mail.count() - before; got != 5 {
3120 t.Fatalf("mails through one link: %d, want 5", got)
3121 }
3122 x.advance(16 * time.Minute)
3123 c := x.browser(t)
3124 c.signIn("paul@example.com")
3125 var toks []string
3126 for i := 0; i < 3; i++ {
3127 _, t2 := createMeeting(t, c, "m", "")
3128 toks = append(toks, t2)
3129 }
3130 before = x.mail.count()
3131 for i := 0; i < 12; i++ {
3132 g := x.browser(t)
3133 g.get("/j/" + toks[i%3])
3134 g.post("/signin", url.Values{"address": {"h" + string(rune('a'+i)) + "@example.com"}})
3135 }
3136 if got := x.mail.count() - before; got != 10 {
3137 t.Fatalf("mails across links: %d, want 10", got)
3138 }
3139}
3140
3141func TestExpiredGrantIsRefusedEntry(t *testing.T) {
3142 x, sqid, tok := memberWithMeeting(t)
3143 g := x.browser(t)
3144 g.get("/j/" + tok)
3145 g.signIn("guest@example.com")
3146 x.advance(2*time.Hour + time.Minute)
3147 if rec := g.get("/m/" + sqid + "/join"); rec.Code != http.StatusNotFound {
3148 t.Fatalf("expired guest at the holding page: %d, want 404", rec.Code)
3149 }
3150}
3151```
3152
3153The holding page tests depend on Task 7's route; implement Task 7 before running this file, or run only the tests that do not hit `/m/`. The plan orders Task 7 next for that reason.
3154
3155- [ ] **Step 6: Run the gate and commit (after Task 7's route exists, rerun)**
3156
3157Run: `make ci 2>&1 | tail -5`
3158
3159```bash
3160git add -A
3161git commit -m "join: /j/, the pending-join cookie, ruling 12 and /continue
3162
3163Following a live link sets a signed cookie and redirects to sign-in;
3164the gate lets a non-member address through only with that cookie,
3165within two budgets, and writes the pending row before Begin so the
3166emailed link admits it anywhere; /continue re-checks the link and
3167mints the grant in one transaction.
3168
3169Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
3170```
3171
3172---
3173
3174### Task 7: The holding page
3175
3176**Files:**
3177- Modify: `internal/calls/handlers_join.go`, `internal/calls/templates/join.html`, `internal/calls/app.go`
3178- Test: `internal/callstest/holding_test.go`
3179
3180**Interfaces:**
3181- Produces: route `GET /m/{sqid}/join` under `requirePerson`, authorized by standing (spec §2.5; participations arrive in slice 2, so standing alone here); page data `{Title, Starts, Standing, Floor bool, IsMember, Sqid}`.
3182
3183- [ ] **Step 1: Handler**
3184
3185Append to `handlers_join.go`:
3186
3187```go
3188// holding is GET /m/{sqid}/join, authorized by standing (spec §2.5). In
3189// this slice it renders the server-side half of the holding screen and
3190// says the room is the next slice.
3191func (a *app) holding(w http.ResponseWriter, r *http.Request) {
3192 m, ok, err := a.store.meetingBySqid(r.Context(), chi.URLParam(r, "sqid"))
3193 if err != nil {
3194 a.logger.Error("calls: load meeting", "err", err)
3195 ErrorPage(w, r, http.StatusInternalServerError, "")
3196 return
3197 }
3198 if !ok || m.EndedAt != nil {
3199 ErrorPage(w, r, http.StatusNotFound, "")
3200 return
3201 }
3202 p, _ := currentPerson(r)
3203 st, err := a.standingFor(r.Context(), p, m)
3204 if err != nil {
3205 a.logger.Error("calls: standing", "err", err)
3206 ErrorPage(w, r, http.StatusInternalServerError, "")
3207 return
3208 }
3209 if st == StandingNone {
3210 ErrorPage(w, r, http.StatusNotFound, "")
3211 return
3212 }
3213 render(w, "join", map[string]any{
3214 "Sqid": m.Sqid, "Title": a.store.title(m), "Starts": a.fmtStart(m.StartsAt),
3215 "Name": p.Name, "Standing": st.String(), "IsMember": st == StandingMember,
3216 "Floor": !a.cfg.RelayConfigured,
3217 })
3218}
3219```
3220
3221Route, inside the `requirePerson` group but outside `requireMember`: `r.Get("/m/{sqid}/join", a.holding)`.
3222
3223- [ ] **Step 2: Template**
3224
3225Replace `internal/calls/templates/join.html`:
3226
3227```html
3228{{define "title"}}{{.Title}}{{end}}
3229{{define "content"}}
3230{{if .IsMember}}{{template "back-nav" dict "Href" "/" "Label" "Meetings"}}{{end}}
3231{{template "page-header" dict "Title" .Title}}
3232{{if .Starts}}<p>Starts {{.Starts}}.</p>{{end}}
3233<p>You are {{.Name}}, joining as a {{.Standing}}.</p>
3234{{if .Floor}}<p rst-notice>This team's Meet has no relay yet. Calls usually connect only between people on the same network.</p>{{end}}
3235<p>The call itself is the next slice of this app. When it lands, this page will show your camera preview, device pickers and a join button, and needs JavaScript for that part only; everything else here works without it.</p>
3236{{if .IsMember}}<p><a href="/meetings/{{.Sqid}}/end">End the meeting</a></p>{{end}}
3237{{end}}
3238```
3239
3240- [ ] **Step 3: Tests**
3241
3242Create `internal/callstest/holding_test.go`:
3243
3244```go
3245package callstest
3246
3247import (
3248 "net/http"
3249 "strings"
3250 "testing"
3251)
3252
3253func TestHoldingPageShowsTitleStandingAndFloor(t *testing.T) {
3254 x, sqid, _ := memberWithMeeting(t)
3255 c := x.browser(t)
3256 c.signIn("paul@example.com")
3257 rec := c.get("/m/" + sqid + "/join")
3258 body := rec.Body.String()
3259 for _, want := range []string{"Board meeting", "joining as a member", "no relay yet", "End the meeting"} {
3260 if !strings.Contains(body, want) {
3261 t.Errorf("holding page lacks %q", want)
3262 }
3263 }
3264}
3265
3266func TestHoldingPageIs404ForNoStandingAndForEndedMeetings(t *testing.T) {
3267 x, sqid, _ := memberWithMeeting(t)
3268 stranger := x.browser(t)
3269 stranger.signIn("nobody@example.com") // admitted in development, no standing
3270 if rec := stranger.get("/m/" + sqid + "/join"); rec.Code != http.StatusNotFound {
3271 t.Fatalf("no standing: %d", rec.Code)
3272 }
3273 m := x.browser(t)
3274 m.signIn("paul@example.com")
3275 m.post("/meetings/"+sqid+"/end", nil)
3276 if rec := m.get("/m/" + sqid + "/join"); rec.Code != http.StatusNotFound {
3277 t.Fatalf("ended: %d", rec.Code)
3278 }
3279}
3280```
3281
3282Note: `TestHoldingPageIs404ForNoStandingAndForEndedMeetings` signs in `nobody@example.com` on an instance whose allowlist is `paul@example.com`; that sign-in is refused by the gate (no cookie), so `signIn` will fail. Change that test to build the harness with no allowlist for the stranger part:
3283
3284```go
3285func TestHoldingPageIs404ForNoStanding(t *testing.T) {
3286 x := newHarness(t) // no team: everyone signs in, nobody is a member
3287 c := x.browser(t)
3288 c.signIn("nobody@example.com")
3289 if rec := c.get("/m/2345678abc/join"); rec.Code != http.StatusNotFound {
3290 t.Fatalf("no standing: %d", rec.Code)
3291 }
3292}
3293```
3294
3295and keep the ended-meeting half in its own test using `memberWithMeeting`.
3296
3297- [ ] **Step 4: Run the whole gate, including Task 6's tests, and commit**
3298
3299Run: `make ci 2>&1 | tail -5`
3300Expected: green, every test in `join_test.go`, `holding_test.go`, `meetings_test.go`, `signin_test.go`.
3301
3302```bash
3303git add -A
3304git commit -m "holding page: standing-gated, scriptless, says the room is next
3305
3306Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
3307```
3308
3309---
3310
3311### Task 8: Copy list, AGENTS.md notes, branch record
3312
3313**Files:**
3314- Create: `docs/superpowers/copy/2026-09-08-slice-1-strings.md`
3315- Modify: `AGENTS.md` (one paragraph under Layout naming the packages this slice added and the store-only sealing rule)
3316
3317- [ ] **Step 1: Collect every string**
3318
3319Create `docs/superpowers/copy/2026-09-08-slice-1-strings.md` listing, one per line with its template or file, every user-facing string introduced in Tasks 4 to 7: the sign-in page (title, heading, sent notice, four error messages, field label, button), the welcome page, the index (headings, field labels, button, empty state, "Starts now", "no live link", the two action links, the ended summary), the link-once page (both sentences, button), the two confirm pages (titles, bodies, labels), the holding page (every sentence including the floor notice), and the error page. Mark the file "drafts, pending copy review per spec §10; the review is one sitting for the slice before merge".
3320
3321- [ ] **Step 2: AGENTS.md**
3322
3323Append under `## Layout`:
3324
3325```markdown
3326- `internal/calls/seal/` — the app's only encryption surface. A title
3327 is sealed by the store and opened by the store; no handler holds a
3328 plaintext title it did not get from `store.title`. An outsider's
3329 address is stored only as a keyed fingerprint.
3330- `internal/calls/store.go` — every database access. `standing.go` is
3331 the entry authority (spec §2.3), computed per request, never stored.
3332- Sign-in: `auth.go` is Docs' shape with the pending-join exception of
3333 spec §3.2 and Slides' same-answer wrapper. Every answer from
3334 `POST /signin` is `303 /signin?sent=1`.
3335```
3336
3337- [ ] **Step 3: Final gate, push, record**
3338
3339Run: `make ci 2>&1 | tail -3 && make release VERSION=v0.0.0-slice1-check && ls releases/`
3340Expected: green, and a stamped binary in `releases/` (delete it afterwards; `releases/` is gitignored).
3341
3342```bash
3343git add -A
3344git commit -m "docs: slice 1 strings for copy review; AGENTS.md notes
3345
3346Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
3347git push origin slice/1-meetings-links-signin
3348```
3349
3350Then, per the suite protocol, open the branch discussion on `oficina/meet` with the candidate SHA, request the Fable review of the complete diff, and add the slice's tasks with `amadan task add`.
3351
3352---
3353
3354## Self-review
3355
3356**Spec coverage for slice 1 (§12 item 1):** persons, meetings, meeting_links, guest_grants, pending_joins (Task 2); sealing (Tasks 1, 3); the list, create, link shown once (Task 5); the two confirm pages and POSTs (Task 5); `/j/` with three outcomes and one 404 (Task 6); pending-join cookie, `pending_joins`, `Authorize`, `/continue` with three outcomes and the atomic re-check, the allowlist exception, the two outsider budgets, `/welcome` (Tasks 4, 6); standing as a package with tests (Task 3); the §2.5 contract as middleware for this slice's routes (Tasks 4, 7); `make release` stamping (Task 1); scriptless throughout (no scripts added); the holding page saying the room is next (Task 7). §13 slice-1 tests: one-404 rule, emailed-link round trip with cookie present and absent, `Authorize` admitting pending and refusing unknown, revocation during sign-in, `/continue` outcomes, the budgets and the documented shared-budget residue (the test for repeated member-address submission is noted in the spec as the edge-wide limitation; it is not asserted here because rastrillo's limiter is out of Meet's hands, and the plan records that), standing table test (covered by store and holding tests), database and WAL grep before and after checkpoint (Task 5).
3357
3358**Not in this slice, on purpose:** the room, streams, participations (slice 2); the relay parsing (slice 3); the API (slice 5); README and PRODUCT.md (slice 6). The copy review itself needs Paul and is a gate before merge, not a task the executor can complete alone.
3359
3360**Type consistency:** `store` methods and signatures are declared once in Task 3 and used by name in Tasks 4 to 7; `liveLinkByHash` and `consumePending` are added to the store in Task 6 with their full bodies; `app` fields (`store`, `auth`, `linkBudget`, `outsiderBudget`, `cfg`, `sealer`) are named in the tasks that add them; the harness `client` methods (`get`, `post`, `do`, `signIn`, `follow`) are defined in Tasks 3 and 4 and used from Task 5 on.
3361