| 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 | |
| 67 | Create `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. |
| 81 | package seal |
| 82 | |
| 83 | import ( |
| 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. |
| 97 | const label = "oficina/meet:" |
| 98 | |
| 99 | const ( |
| 100 | PurposeMeetingTitle = "meeting.title" |
| 101 | PurposePendingAddress = "pending.address" |
| 102 | ) |
| 103 | |
| 104 | func sha256New() hash.Hash { return sha256.New() } |
| 105 | |
| 106 | type Sealer struct{ dek []byte } |
| 107 | |
| 108 | // New refuses anything but a 32-byte key and keeps its own copy. |
| 109 | func 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 | |
| 118 | func (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 | |
| 127 | func (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. |
| 140 | func (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 | |
| 152 | func (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. |
| 166 | func (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 | |
| 173 | func (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 | |
| 185 | Create `internal/calls/seal/fingerprint.go`: |
| 186 | |
| 187 | ```go |
| 188 | package seal |
| 189 | |
| 190 | import ( |
| 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. |
| 199 | func (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 | |
| 212 | Create `internal/calls/seal/seal_test.go`: |
| 213 | |
| 214 | ```go |
| 215 | package seal |
| 216 | |
| 217 | import ( |
| 218 | "bytes" |
| 219 | "testing" |
| 220 | ) |
| 221 | |
| 222 | func 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 | |
| 243 | func 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 | |
| 257 | func 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 | |
| 266 | Run: `go get golang.org/x/crypto@latest && go test ./internal/calls/seal/ -v` |
| 267 | Expected: PASS (three tests). |
| 268 | |
| 269 | - [ ] **Step 4: Write the config** |
| 270 | |
| 271 | Create `internal/calls/config.go`: |
| 272 | |
| 273 | ```go |
| 274 | package meet |
| 275 | |
| 276 | import ( |
| 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. |
| 290 | const 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. |
| 295 | type 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 | |
| 324 | func (cfg Config) development() bool { return cfg.Env == EnvDevelopment } |
| 325 | |
| 326 | const devInstanceKey = "meet-development-instance-key-not-a-secret" |
| 327 | |
| 328 | var devDEK = make([]byte, 32) |
| 329 | |
| 330 | // resolved is Config after every refusal has been checked and every |
| 331 | // default applied. |
| 332 | type resolved struct { |
| 333 | Config |
| 334 | instanceKey string |
| 335 | dek []byte |
| 336 | mailer mail.Sender |
| 337 | allow allowlist |
| 338 | zone *time.Location |
| 339 | } |
| 340 | |
| 341 | func 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 | |
| 412 | Create `internal/calls/standing.go` with only the allowlist for now; Task 3 adds `Standing`: |
| 413 | |
| 414 | ```go |
| 415 | package meet |
| 416 | |
| 417 | import ( |
| 418 | "fmt" |
| 419 | "strings" |
| 420 | ) |
| 421 | |
| 422 | func 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. |
| 429 | type allowlist struct { |
| 430 | addrs map[string]struct{} |
| 431 | } |
| 432 | |
| 433 | func (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 | |
| 441 | func 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 | |
| 462 | Replace `internal/calls/app.go` entirely: |
| 463 | |
| 464 | ```go |
| 465 | package meet |
| 466 | |
| 467 | import ( |
| 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. |
| 482 | type 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. |
| 492 | func 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 | |
| 521 | Delete the `app` struct from `internal/calls/handlers.go`, leaving only `index`: |
| 522 | |
| 523 | ```go |
| 524 | package meet |
| 525 | |
| 526 | import "net/http" |
| 527 | |
| 528 | func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| 529 | render(w, "index", nil) |
| 530 | } |
| 531 | ``` |
| 532 | |
| 533 | Replace `cmd/calls/main.go`: |
| 534 | |
| 535 | ```go |
| 536 | package main |
| 537 | |
| 538 | import ( |
| 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 | |
| 551 | func 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 | |
| 606 | Replace `newApp` in `internal/callstest/harness_test.go` so it passes a development `Config`: |
| 607 | |
| 608 | ```go |
| 609 | func 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 | |
| 625 | Create `internal/callstest/config_test.go`: |
| 626 | |
| 627 | ```go |
| 628 | package callstest |
| 629 | |
| 630 | import ( |
| 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 | |
| 642 | func 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 | |
| 652 | func 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 | |
| 667 | func 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 | |
| 673 | func 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 | |
| 681 | Add `nopMailer` to `harness_test.go`: |
| 682 | |
| 683 | ```go |
| 684 | type nopMailer struct{} |
| 685 | |
| 686 | func (nopMailer) Send(ctx context.Context, to, subject, body string) error { return nil } |
| 687 | ``` |
| 688 | |
| 689 | - [ ] **Step 8: Run the tests** |
| 690 | |
| 691 | Run: `go test ./... 2>&1 | tail -8` |
| 692 | Expected: 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 | |
| 696 | Run: `go get -tool amadan.net/rastrillo/rastrillo/cmd/rastrillo@v0.26.0 && go mod tidy` |
| 697 | |
| 698 | Replace 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 |
| 706 | VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null) |
| 707 | |
| 708 | .PHONY: build release version-check test vet fmt-check migration-check ci |
| 709 | |
| 710 | release: 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 | |
| 720 | version-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 | |
| 734 | Remove the old `.PHONY` line and the old `release:` target. |
| 735 | |
| 736 | - [ ] **Step 10: Run the gate and commit** |
| 737 | |
| 738 | Run: `make ci 2>&1 | tail -5` |
| 739 | Expected: vet, gofmt, tests and migration check all pass. |
| 740 | |
| 741 | ```bash |
| 742 | git add -A |
| 743 | git commit -m "config: refusals, sealer, release stamping |
| 744 | |
| 745 | Config with every startup refusal from spec 7.1, development selected |
| 746 | only by the -env flag; the sealer lifted from calendar with Meet's |
| 747 | label; make release stamps BuildVersion and refuses a dirty tree. |
| 748 | |
| 749 | Co-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 | |
| 767 | Replace `internal/calls/models.go`: |
| 768 | |
| 769 | ```go |
| 770 | package meet |
| 771 | |
| 772 | import "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. |
| 778 | var 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). |
| 788 | type 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. |
| 797 | type 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. |
| 811 | type 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. |
| 823 | type 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. |
| 837 | type 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 | |
| 849 | Nothing has shipped, so the scaffold's `notes` migration is replaced rather than appended to. |
| 850 | |
| 851 | Run: `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 | |
| 853 | Open 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 | |
| 857 | In `internal/calls/migrations.go`, change the merge line and imports: |
| 858 | |
| 859 | ```go |
| 860 | import ( |
| 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 |
| 869 | var migrationFS embed.FS |
| 870 | |
| 871 | var 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. |
| 875 | var BootSchema = migrate.Merge(sessions.Schema, rauth.Schema, Schema) |
| 876 | ``` |
| 877 | |
| 878 | - [ ] **Step 4: Write the schema test** |
| 879 | |
| 880 | Create `internal/callstest/schema_test.go`: |
| 881 | |
| 882 | ```go |
| 883 | package callstest |
| 884 | |
| 885 | import ( |
| 886 | "testing" |
| 887 | |
| 888 | "amadan.net/oficina/meet/internal/calls" |
| 889 | ) |
| 890 | |
| 891 | func 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 | |
| 910 | Run: `make ci 2>&1 | tail -5` |
| 911 | Expected: green, including `migration check`. |
| 912 | |
| 913 | ```bash |
| 914 | git add -A |
| 915 | git commit -m "schema: persons, meetings, links, grants, pending joins |
| 916 | |
| 917 | The five tables of spec 2.1 replace the scaffold's notes; sessions and |
| 918 | auth schemas apply first. |
| 919 | |
| 920 | Co-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 | |
| 951 | Create `internal/calls/ids.go`: |
| 952 | |
| 953 | ```go |
| 954 | package meet |
| 955 | |
| 956 | import ( |
| 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. |
| 966 | const sqidAlphabet = "23456789abcdefghjkmnpqrstuvwxyz" |
| 967 | const sqidLength = 10 |
| 968 | const maxSqidAttempts = 5 |
| 969 | |
| 970 | func 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 | |
| 982 | const 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). |
| 986 | func 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 | |
| 995 | func hashLinkToken(token string) string { |
| 996 | sum := sha256.Sum256([]byte(token)) |
| 997 | return hex.EncodeToString(sum[:]) |
| 998 | } |
| 999 | ``` |
| 1000 | |
| 1001 | Create `internal/calls/ids_test.go`: |
| 1002 | |
| 1003 | ```go |
| 1004 | package meet |
| 1005 | |
| 1006 | import ( |
| 1007 | "strings" |
| 1008 | "testing" |
| 1009 | ) |
| 1010 | |
| 1011 | func 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 | |
| 1023 | func 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 | |
| 1036 | Create `internal/calls/store.go`: |
| 1037 | |
| 1038 | ```go |
| 1039 | package meet |
| 1040 | |
| 1041 | import ( |
| 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. |
| 1056 | const grantLife = 2 * time.Hour |
| 1057 | |
| 1058 | // pendingLife matches the pending-join cookie: fifteen minutes. |
| 1059 | const 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. |
| 1064 | type store struct { |
| 1065 | g *gorm.DB |
| 1066 | sealer *seal.Sealer |
| 1067 | now func() time.Time |
| 1068 | } |
| 1069 | |
| 1070 | func 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. |
| 1074 | func (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 | |
| 1101 | type meetingView struct { |
| 1102 | Meeting |
| 1103 | Title string |
| 1104 | LinkPresent bool |
| 1105 | } |
| 1106 | |
| 1107 | func (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. |
| 1118 | func (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 | |
| 1154 | func (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 | |
| 1170 | func (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). |
| 1181 | func (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 | |
| 1196 | func (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 | |
| 1208 | func (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. |
| 1224 | func (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. |
| 1243 | func (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 | |
| 1255 | func (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. |
| 1265 | func (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 | |
| 1282 | func (s *store) fingerprint(address string) (string, error) { |
| 1283 | return s.sealer.Fingerprint(seal.PurposePendingAddress, normalizeAddress(address)) |
| 1284 | } |
| 1285 | |
| 1286 | func (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? |
| 1300 | func (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 | |
| 1309 | func (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 | |
| 1323 | func (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 | |
| 1334 | Append 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. |
| 1339 | type Standing int |
| 1340 | |
| 1341 | const ( |
| 1342 | StandingNone Standing = iota |
| 1343 | StandingGuest |
| 1344 | StandingMember |
| 1345 | ) |
| 1346 | |
| 1347 | func (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. |
| 1358 | func (a *app) isMember(p Person) bool { |
| 1359 | return a.cfg.allow.addrs != nil && a.cfg.allow.allows(p.Email) |
| 1360 | } |
| 1361 | |
| 1362 | func (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 | |
| 1377 | Note `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 | |
| 1379 | Add `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. |
| 1384 | func (a *app) SetClockForTest(now func() time.Time) { a.store.now = now } |
| 1385 | ``` |
| 1386 | |
| 1387 | Since 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. |
| 1392 | func 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 | |
| 1401 | and 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 | |
| 1405 | In `internal/callstest/harness_test.go` replace `newApp` with: |
| 1406 | |
| 1407 | ```go |
| 1408 | type 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 | |
| 1416 | func (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 | |
| 1422 | type recordingMailer struct { |
| 1423 | mu sync.Mutex |
| 1424 | to []string |
| 1425 | links []string |
| 1426 | } |
| 1427 | |
| 1428 | var magicLink = regexp.MustCompile(`https?://\S+/auth/verify\?token=\S+`) |
| 1429 | |
| 1430 | func (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 | |
| 1438 | func (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 | |
| 1447 | func (m *recordingMailer) count() int { |
| 1448 | m.mu.Lock() |
| 1449 | defer m.mu.Unlock() |
| 1450 | return len(m.to) |
| 1451 | } |
| 1452 | |
| 1453 | func 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 | |
| 1473 | func newApp(t *testing.T) http.Handler { return newHarness(t).h } |
| 1474 | ``` |
| 1475 | |
| 1476 | Keep `get` and `post`; add cookie-carrying variants used from Task 4 on: |
| 1477 | |
| 1478 | ```go |
| 1479 | type client struct { |
| 1480 | t *testing.T |
| 1481 | x *harness |
| 1482 | cookies map[string]*http.Cookie |
| 1483 | } |
| 1484 | |
| 1485 | func (x *harness) browser(t *testing.T) *client { |
| 1486 | return &client{t: t, x: x, cookies: map[string]*http.Cookie{}} |
| 1487 | } |
| 1488 | |
| 1489 | func (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 | |
| 1505 | func (c *client) get(target string) *httptest.ResponseRecorder { |
| 1506 | return c.do(httptest.NewRequest(http.MethodGet, target, nil)) |
| 1507 | } |
| 1508 | |
| 1509 | func (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 | |
| 1519 | The store is unexported; test it through `meet` package tests. Create `internal/calls/store_test.go`: |
| 1520 | |
| 1521 | ```go |
| 1522 | package meet |
| 1523 | |
| 1524 | import ( |
| 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 | |
| 1539 | func 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 | |
| 1555 | func 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 | |
| 1577 | func 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 | |
| 1593 | func 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 | |
| 1605 | func 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 | |
| 1625 | func 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 | |
| 1658 | Run: `make ci 2>&1 | tail -5` |
| 1659 | Expected: green. |
| 1660 | |
| 1661 | ```bash |
| 1662 | git add -A |
| 1663 | git commit -m "store: meetings, links, grants, pending joins, standing |
| 1664 | |
| 1665 | Sealed titles, one-404 link lookup, replace and end in transactions, |
| 1666 | idempotent grants expiring per ruling 4, keyed-fingerprint pending |
| 1667 | rows bound to their link, standing computed per request. |
| 1668 | |
| 1669 | Co-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 | |
| 1687 | Replace `internal/calls/render.go`: |
| 1688 | |
| 1689 | ```go |
| 1690 | package meet |
| 1691 | |
| 1692 | import ( |
| 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 |
| 1705 | var appFS embed.FS |
| 1706 | |
| 1707 | var assets = rastrillo.NewAssets(appFS) |
| 1708 | |
| 1709 | var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join"} |
| 1710 | |
| 1711 | var pages = map[string]*template.Template{} |
| 1712 | |
| 1713 | func 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. |
| 1722 | func 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 | |
| 1731 | func 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. |
| 1742 | func 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 | |
| 1748 | Replace `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 | |
| 1773 | Create `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 | |
| 1784 | Create `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 | |
| 1802 | Create `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 | |
| 1813 | Replace `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 | |
| 1822 | Create 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 | |
| 1831 | Create `internal/calls/auth.go`: |
| 1832 | |
| 1833 | ```go |
| 1834 | package meet |
| 1835 | |
| 1836 | import ( |
| 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 | |
| 1847 | const signinPath = "/signin" |
| 1848 | const 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. |
| 1855 | func (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 | |
| 1868 | func (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. |
| 1884 | func (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. |
| 1901 | func (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. |
| 1908 | type sameAnswer struct { |
| 1909 | http.ResponseWriter |
| 1910 | app *app |
| 1911 | } |
| 1912 | |
| 1913 | func (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 | |
| 1921 | type 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. |
| 1926 | func (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 | |
| 1944 | func 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. |
| 1950 | func (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 | |
| 1961 | func (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. |
| 1980 | func (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. |
| 1998 | func (a *app) continuePending(w http.ResponseWriter, r *http.Request, p Person) (string, bool) { |
| 1999 | return "", false |
| 2000 | } |
| 2001 | |
| 2002 | func (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 | |
| 2010 | Replace 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 | |
| 2052 | Add 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. |
| 2058 | func (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. |
| 2072 | func (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 | |
| 2091 | Create `internal/callstest/signin_test.go`: |
| 2092 | |
| 2093 | ```go |
| 2094 | package callstest |
| 2095 | |
| 2096 | import ( |
| 2097 | "net/http" |
| 2098 | "net/url" |
| 2099 | "strings" |
| 2100 | "testing" |
| 2101 | ) |
| 2102 | |
| 2103 | func 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 | |
| 2114 | func 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 | |
| 2126 | func 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 | |
| 2134 | func 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 | |
| 2153 | Run: `make ci 2>&1 | tail -5` |
| 2154 | Expected: 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 |
| 2157 | git add -A |
| 2158 | git commit -m "auth: magic-link sign-in, persons, continue and welcome |
| 2159 | |
| 2160 | rastrillo/auth behind Docs' gate with Slides' same-answer wrapper, |
| 2161 | Authorize admitting allowlist or a live pending row, SignedInPath at |
| 2162 | /continue, requirePerson and requireMember middleware, the template |
| 2163 | tree with rastrillo's ui partials. |
| 2164 | |
| 2165 | Co-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 | |
| 2182 | Create `internal/calls/handlers_meetings.go`: |
| 2183 | |
| 2184 | ```go |
| 2185 | package meet |
| 2186 | |
| 2187 | import ( |
| 2188 | "net/http" |
| 2189 | "net/url" |
| 2190 | "strings" |
| 2191 | "time" |
| 2192 | |
| 2193 | "github.com/go-chi/chi/v5" |
| 2194 | ) |
| 2195 | |
| 2196 | const maxTitleLen = 200 |
| 2197 | |
| 2198 | // linkURL is built from configuration, never from the Host header. |
| 2199 | func (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. |
| 2205 | func (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 | |
| 2218 | func (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 | |
| 2225 | func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| 2226 | a.renderIndex(w, r, "", "") |
| 2227 | } |
| 2228 | |
| 2229 | func (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 | |
| 2255 | func (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. |
| 2284 | func (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. |
| 2292 | func (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 | |
| 2306 | func (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 | |
| 2319 | func (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 | |
| 2338 | func (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 | |
| 2351 | func (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 | |
| 2366 | Delete `internal/calls/handlers.go` (its `index` now lives above). |
| 2367 | |
| 2368 | - [ ] **Step 2: Templates** |
| 2369 | |
| 2370 | Replace `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 | |
| 2411 | Replace `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 | |
| 2425 | Replace `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 | |
| 2441 | In `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 | |
| 2454 | Create `internal/callstest/meetings_test.go`: |
| 2455 | |
| 2456 | ```go |
| 2457 | package callstest |
| 2458 | |
| 2459 | import ( |
| 2460 | "bytes" |
| 2461 | "net/http" |
| 2462 | "net/url" |
| 2463 | "os" |
| 2464 | "path/filepath" |
| 2465 | "regexp" |
| 2466 | "strings" |
| 2467 | "testing" |
| 2468 | ) |
| 2469 | |
| 2470 | var linkInPage = regexp.MustCompile(`http://app\.test/j/([A-Za-z0-9_-]{43})`) |
| 2471 | |
| 2472 | func 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 | |
| 2486 | func 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 | |
| 2500 | func 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 | |
| 2510 | func 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 | |
| 2527 | func 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 | |
| 2546 | func 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. |
| 2561 | func 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 | |
| 2586 | Add 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 |
| 2589 | func 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 | |
| 2596 | Note 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 | |
| 2600 | Run: `make ci 2>&1 | tail -5` |
| 2601 | Expected: green. |
| 2602 | |
| 2603 | ```bash |
| 2604 | git add -A |
| 2605 | git commit -m "meetings: list, create, link once, replace and end |
| 2606 | |
| 2607 | Sealed titles listed and opened through the store only; the link is |
| 2608 | answered on a page, once; replace and end have their confirm pages; |
| 2609 | the database and WAL never hold a plaintext title, tested before and |
| 2610 | after a checkpoint. |
| 2611 | |
| 2612 | Co-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 | |
| 2630 | Create `internal/calls/joincookie.go`: |
| 2631 | |
| 2632 | ```go |
| 2633 | package meet |
| 2634 | |
| 2635 | import ( |
| 2636 | "crypto/hmac" |
| 2637 | "crypto/sha256" |
| 2638 | "encoding/base64" |
| 2639 | "encoding/hex" |
| 2640 | "net/http" |
| 2641 | "strconv" |
| 2642 | "strings" |
| 2643 | "time" |
| 2644 | ) |
| 2645 | |
| 2646 | const 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). |
| 2652 | type joinCookie struct { |
| 2653 | TokenHash string |
| 2654 | Expires time.Time |
| 2655 | } |
| 2656 | |
| 2657 | func (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 | |
| 2663 | func (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 | |
| 2674 | func (a *app) clearJoinCookie(w http.ResponseWriter) { |
| 2675 | http.SetCookie(w, &http.Cookie{Name: joinCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) |
| 2676 | } |
| 2677 | |
| 2678 | func (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 | |
| 2711 | Create `internal/calls/joincookie_test.go`: |
| 2712 | |
| 2713 | ```go |
| 2714 | package meet |
| 2715 | |
| 2716 | import ( |
| 2717 | "net/http" |
| 2718 | "net/http/httptest" |
| 2719 | "strings" |
| 2720 | "testing" |
| 2721 | "time" |
| 2722 | ) |
| 2723 | |
| 2724 | func 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 | |
| 2728 | func 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 | |
| 2757 | Create `internal/calls/budget.go`: |
| 2758 | |
| 2759 | ```go |
| 2760 | package meet |
| 2761 | |
| 2762 | import ( |
| 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. |
| 2771 | type budget struct { |
| 2772 | mu sync.Mutex |
| 2773 | limit int |
| 2774 | window time.Duration |
| 2775 | hits map[string][]time.Time |
| 2776 | } |
| 2777 | |
| 2778 | func 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. |
| 2783 | func (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 | |
| 2802 | Create `internal/calls/budget_test.go`: |
| 2803 | |
| 2804 | ```go |
| 2805 | package meet |
| 2806 | |
| 2807 | import ( |
| 2808 | "testing" |
| 2809 | "time" |
| 2810 | ) |
| 2811 | |
| 2812 | func 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 | |
| 2832 | In `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 | |
| 2834 | Replace `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). |
| 2843 | func (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 | |
| 2876 | Add to `store.go`: |
| 2877 | |
| 2878 | ```go |
| 2879 | func (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 | |
| 2892 | and make `liveLink` call `liveLinkByHash(ctx, hashLinkToken(token))` after its empty-token check. |
| 2893 | |
| 2894 | Replace `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. |
| 2900 | func (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 | |
| 2919 | Add 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. |
| 2926 | func (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 | |
| 2954 | Create `internal/calls/handlers_join.go`: |
| 2955 | |
| 2956 | ```go |
| 2957 | package meet |
| 2958 | |
| 2959 | import ( |
| 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. |
| 2970 | func (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 | |
| 3004 | Keep 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 | |
| 3008 | Create `internal/callstest/join_test.go`: |
| 3009 | |
| 3010 | ```go |
| 3011 | package callstest |
| 3012 | |
| 3013 | import ( |
| 3014 | "net/http" |
| 3015 | "net/url" |
| 3016 | "strings" |
| 3017 | "testing" |
| 3018 | "time" |
| 3019 | ) |
| 3020 | |
| 3021 | func 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 | |
| 3030 | func 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 | |
| 3038 | func 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 | |
| 3059 | func 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 | |
| 3074 | func 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 | |
| 3093 | func 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 | |
| 3102 | func 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 | |
| 3111 | func 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 | |
| 3141 | func 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 | |
| 3153 | The 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 | |
| 3157 | Run: `make ci 2>&1 | tail -5` |
| 3158 | |
| 3159 | ```bash |
| 3160 | git add -A |
| 3161 | git commit -m "join: /j/, the pending-join cookie, ruling 12 and /continue |
| 3162 | |
| 3163 | Following a live link sets a signed cookie and redirects to sign-in; |
| 3164 | the gate lets a non-member address through only with that cookie, |
| 3165 | within two budgets, and writes the pending row before Begin so the |
| 3166 | emailed link admits it anywhere; /continue re-checks the link and |
| 3167 | mints the grant in one transaction. |
| 3168 | |
| 3169 | Co-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 | |
| 3185 | Append 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. |
| 3191 | func (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 | |
| 3221 | Route, inside the `requirePerson` group but outside `requireMember`: `r.Get("/m/{sqid}/join", a.holding)`. |
| 3222 | |
| 3223 | - [ ] **Step 2: Template** |
| 3224 | |
| 3225 | Replace `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 | |
| 3242 | Create `internal/callstest/holding_test.go`: |
| 3243 | |
| 3244 | ```go |
| 3245 | package callstest |
| 3246 | |
| 3247 | import ( |
| 3248 | "net/http" |
| 3249 | "strings" |
| 3250 | "testing" |
| 3251 | ) |
| 3252 | |
| 3253 | func 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 | |
| 3266 | func 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 | |
| 3282 | Note: `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 |
| 3285 | func 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 | |
| 3295 | and 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 | |
| 3299 | Run: `make ci 2>&1 | tail -5` |
| 3300 | Expected: green, every test in `join_test.go`, `holding_test.go`, `meetings_test.go`, `signin_test.go`. |
| 3301 | |
| 3302 | ```bash |
| 3303 | git add -A |
| 3304 | git commit -m "holding page: standing-gated, scriptless, says the room is next |
| 3305 | |
| 3306 | Co-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 | |
| 3319 | Create `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 | |
| 3323 | Append 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 | |
| 3339 | Run: `make ci 2>&1 | tail -3 && make release VERSION=v0.0.0-slice1-check && ls releases/` |
| 3340 | Expected: green, and a stamped binary in `releases/` (delete it afterwards; `releases/` is gitignored). |
| 3341 | |
| 3342 | ```bash |
| 3343 | git add -A |
| 3344 | git commit -m "docs: slice 1 strings for copy review; AGENTS.md notes |
| 3345 | |
| 3346 | Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>" |
| 3347 | git push origin slice/1-meetings-links-signin |
| 3348 | ``` |
| 3349 | |
| 3350 | Then, 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 | |