oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

Meet slice 1: meetings, links, sign-in — Implementation Plan

> 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.

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.

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.

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.

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.

Global Constraints

  • 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).
  • CGO_ENABLED=0 everywhere; make ci (vet, gofmt, tests, migration check) is the gate and must be green at the end of every task.
  • Every screen in this slice works with JavaScript disabled (spec §9); no new script files.
  • Never bind a request body onto a gorm model: explicit values plus .Select allowlists (AGENTS.md).
  • Destructive actions have their own confirm page rendered with ui/confirm-form and a POST behind csrf.Protect (AGENTS.md, spec §3.1).
  • A link token is stored as SHA-256 hex and returned once in the response that minted it (ruling 16).
  • Titles and outsider addresses never reach the database in plaintext (spec §0.1, §2.1); the label for HKDF is oficina/meet:.
  • Sign-in refusals from Meet's own gate redirect to /signin?sent=1, byte-identical to success (spec §3.2 "Responses").
  • User-facing strings written in this slice are drafts pending the copy review (spec §10); they are collected in Task 8 for one sitting.
  • Commits use the house form and end with Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>.
  • 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).

---

File structure

| File | Responsibility | |---|---| | cmd/calls/main.go | Reads env and the -env flag into meet.Config, builds the app, serves. | | internal/calls/config.go | Config, resolve (env, origin, instance key, DEK, mailer, allowlist), all startup refusals. | | 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. | | internal/calls/models.go | gorm structs: Person, Meeting, MeetingLink, GuestGrant, PendingJoin. | | internal/calls/migrations/0001_init.sql, schema.sql | The five tables. migrations.go merges sessions and auth schemas first. | | internal/calls/ids.go | newSqid, newLinkToken, hashLinkToken. | | internal/calls/store.go | All database access: persons, meetings, links, grants, pending joins. Sealing happens here and nowhere else. | | internal/calls/standing.go | Standing type, standingFor, the allowlist. | | internal/calls/auth.go | Instance key, mailer, gateBegin with the pending-join exception and budgets, Authorize, /continue, /welcome, the sign-in page, currentPerson middleware. | | internal/calls/budget.go | Sliding-window counters for outsider sign-in attempts. | | internal/calls/joincookie.go | The signed pending-join cookie. | | internal/calls/handlers_meetings.go | List, create, link-once page, replace-link and end-meeting confirm pages and POSTs. | | internal/calls/handlers_join.go | GET /j/{token} and the holding page GET /m/{sqid}/join. | | internal/calls/app.go | App(d, cfg): migrate, wire auth, router with the three tiers, middleware. | | internal/calls/render.go | Template tree with ui.Funcs and ui.Templates(), render, ErrorPage. | | internal/calls/templates/*.html | layout, index, link_once, confirm, signin, welcome, join, errors. | | internal/callstest/*_test.go | Harness (newApp, signIn, recording mailer, clock), one test file per task. | | Makefile | release stamps BuildVersion; version-check. | | docs/superpowers/copy/2026-09-08-slice-1-strings.md | Every user-facing string in the slice, for the copy review. |

---

Task 1: Config, sealer, refusals, tooling

Files:

  • Create: internal/calls/config.go, internal/calls/seal/seal.go, internal/calls/seal/fingerprint.go, internal/calls/seal/seal_test.go
  • Modify: cmd/calls/main.go, internal/calls/app.go, Makefile, go.mod
  • Test: internal/callstest/config_test.go

Interfaces:

  • 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".
  • [ ] Step 1: Copy the sealer with Meet's label

Create internal/calls/seal/seal.go:

// Package seal is the app's only encryption surface.
//
// The one rule (spec §0.1): a meeting's title is sealed at rest, and
// the database never holds a plaintext title or an outsider's address.
// The DEK arrives as platform config and is never written to the
// database it decrypts.
//
// Lifted from calendar's seal package, itself lifted from sheets', with
// only the label changed. Calendar names the third app as the trigger
// to extract this into estilo or rastrillo; Meet is that app, and the
// extraction is a task on the plan, not a change here.
package seal

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"crypto/sha256"
	"errors"
	"hash"
	"io"

	"golang.org/x/crypto/hkdf"
)

// label is mixed into every derived key. Changing it makes every
// existing sealed value unreadable, so it is a constant, not config.
const label = "oficina/meet:"

const (
	PurposeMeetingTitle   = "meeting.title"
	PurposePendingAddress = "pending.address"
)

func sha256New() hash.Hash { return sha256.New() }

type Sealer struct{ dek []byte }

// New refuses anything but a 32-byte key and keeps its own copy.
func New(dek []byte) (*Sealer, error) {
	if len(dek) != 32 {
		return nil, errors.New("seal: the DEK must be exactly 32 bytes")
	}
	k := make([]byte, 32)
	copy(k, dek)
	return &Sealer{dek: k}, nil
}

func (s *Sealer) purposeKey(purpose string) ([]byte, error) {
	key := make([]byte, 32)
	r := hkdf.New(sha256New, s.dek, nil, []byte(label+purpose))
	if _, err := io.ReadFull(r, key); err != nil {
		return nil, err
	}
	return key, nil
}

func (s *Sealer) aeadFor(purpose string) (cipher.AEAD, error) {
	key, err := s.purposeKey(purpose)
	if err != nil {
		return nil, err
	}
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	return cipher.NewGCM(block)
}

// Seal returns nonce || ciphertext.
func (s *Sealer) Seal(purpose string, plaintext []byte) ([]byte, error) {
	aead, err := s.aeadFor(purpose)
	if err != nil {
		return nil, err
	}
	nonce := make([]byte, aead.NonceSize())
	if _, err := rand.Read(nonce); err != nil {
		return nil, err
	}
	return aead.Seal(nonce, nonce, plaintext, nil), nil
}

func (s *Sealer) Open(purpose string, ct []byte) ([]byte, error) {
	aead, err := s.aeadFor(purpose)
	if err != nil {
		return nil, err
	}
	if len(ct) < aead.NonceSize() {
		return nil, errors.New("seal: ciphertext too short")
	}
	nonce, body := ct[:aead.NonceSize()], ct[aead.NonceSize():]
	return aead.Open(nil, nonce, body, nil)
}

// SealString seals a string; an empty string seals to nil so an
// optional column stays NULL.
func (s *Sealer) SealString(purpose, plaintext string) ([]byte, error) {
	if plaintext == "" {
		return nil, nil
	}
	return s.Seal(purpose, []byte(plaintext))
}

func (s *Sealer) OpenString(purpose string, ct []byte) (string, error) {
	if len(ct) == 0 {
		return "", nil
	}
	b, err := s.Open(purpose, ct)
	if err != nil {
		return "", err
	}
	return string(b), nil
}

Create internal/calls/seal/fingerprint.go:

package seal

import (
	"crypto/hmac"
	"encoding/hex"
)

// Fingerprint is a keyed, stable digest: equal values give equal
// fingerprints and nothing else can be learned from one. An address is
// enumerable, so a plain hash of it is the address in another alphabet;
// HMAC under a purpose key is not.
func (s *Sealer) Fingerprint(purpose, value string) (string, error) {
	key, err := s.purposeKey(purpose)
	if err != nil {
		return "", err
	}
	mac := hmac.New(sha256New, key)
	mac.Write([]byte(value))
	return hex.EncodeToString(mac.Sum(nil)), nil
}
  • [ ] Step 2: Write the sealer test

Create internal/calls/seal/seal_test.go:

package seal

import (
	"bytes"
	"testing"
)

func TestSealRoundTripsAndDiffersPerPurpose(t *testing.T) {
	s, err := New(bytes.Repeat([]byte{7}, 32))
	if err != nil {
		t.Fatal(err)
	}
	ct, err := s.SealString(PurposeMeetingTitle, "Board meeting")
	if err != nil {
		t.Fatal(err)
	}
	if bytes.Contains(ct, []byte("Board")) {
		t.Fatal("ciphertext contains plaintext")
	}
	got, err := s.OpenString(PurposeMeetingTitle, ct)
	if err != nil || got != "Board meeting" {
		t.Fatalf("open: %q, %v", got, err)
	}
	if _, err := s.OpenString(PurposePendingAddress, ct); err == nil {
		t.Fatal("a ciphertext opened under the wrong purpose")
	}
}

func TestFingerprintIsStableAndKeyed(t *testing.T) {
	a, _ := New(bytes.Repeat([]byte{1}, 32))
	b, _ := New(bytes.Repeat([]byte{2}, 32))
	x1, _ := a.Fingerprint(PurposePendingAddress, "x@example.com")
	x2, _ := a.Fingerprint(PurposePendingAddress, "x@example.com")
	y, _ := b.Fingerprint(PurposePendingAddress, "x@example.com")
	if x1 != x2 {
		t.Fatal("fingerprint is not stable")
	}
	if x1 == y {
		t.Fatal("fingerprint does not depend on the key")
	}
}

func TestNewRefusesWrongKeyLength(t *testing.T) {
	if _, err := New([]byte("short")); err == nil {
		t.Fatal("a 5-byte key was accepted")
	}
}
  • [ ] Step 3: Run the sealer tests

Run: go get golang.org/x/crypto@latest && go test ./internal/calls/seal/ -v Expected: PASS (three tests).

  • [ ] Step 4: Write the config

Create internal/calls/config.go:

package meet

import (
	"encoding/base64"
	"fmt"
	"log/slog"
	"strings"
	"time"

	"amadan.net/rastrillo/rastrillo/mail"
)

// EnvDevelopment is the one Env value that relaxes startup refusals.
// Only the -env flag can select it (cmd/calls/main.go); an environment
// variable cannot, so a misconfigured production box fails loudly
// rather than quietly running with a zero key.
const EnvDevelopment = "development"

// Config is everything the app needs to start. Every field that is a
// secret or a boundary is a startup refusal when it is missing outside
// development, per spec §7.1.
type Config struct {
	// Env is "development" or anything else (treated as production).
	Env string
	// Origin is the app's external origin, scheme included
	// ("https://calls.oficina.works"). CALLS_ORIGIN.
	Origin string
	// InstanceKey seals rastrillo/auth's pending blob and signs the
	// pending-join cookie. CALLS_INSTANCE_KEY.
	InstanceKey string
	// DEK is the base64 data encryption key, exactly 32 bytes decoded.
	// CALLS_DEK, set with `carlos secrets set`.
	DEK string
	// AllowedAddresses is the team: the addresses that are members.
	// CALLS_ALLOWED_ADDRESSES, comma-separated.
	AllowedAddresses []string
	// Mailer delivers magic links. Nil outside development is refused;
	// nil in development logs the link instead of sending it.
	Mailer mail.Sender
	// RelayConfigured is whether CARLOS_RELAY is present. Slice 1 only
	// reads its presence for the holding page's floor sentence; slice 3
	// parses it.
	RelayConfigured bool
	// Zone is the IANA zone start times are entered and shown in.
	// CALLS_ZONE, default UTC. Per-person zones are deliberately not in
	// version one.
	Zone string
	Logger *slog.Logger
}

func (cfg Config) development() bool { return cfg.Env == EnvDevelopment }

const devInstanceKey = "meet-development-instance-key-not-a-secret"

var devDEK = make([]byte, 32)

// resolved is Config after every refusal has been checked and every
// default applied.
type resolved struct {
	Config
	instanceKey string
	dek         []byte
	mailer      mail.Sender
	allow       allowlist
	zone        *time.Location
}

func resolve(cfg Config) (resolved, error) {
	if cfg.Logger == nil {
		cfg.Logger = slog.Default()
	}
	out := resolved{Config: cfg}

	if strings.TrimSpace(cfg.Origin) == "" {
		if !cfg.development() {
			return out, fmt.Errorf("calls: Config.Origin is empty and Env is %q, not %q; set $CALLS_ORIGIN", cfg.Env, EnvDevelopment)
		}
		out.Origin = "http://localhost:8080"
	}

	key := strings.TrimSpace(cfg.InstanceKey)
	switch {
	case key != "":
		out.instanceKey = key
	case cfg.development():
		out.instanceKey = devInstanceKey
	default:
		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)
	}

	raw := strings.TrimSpace(cfg.DEK)
	switch {
	case raw == "" && cfg.development():
		out.dek = devDEK
	case raw == "":
		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)
	default:
		b, err := base64.StdEncoding.DecodeString(raw)
		if err != nil {
			return out, fmt.Errorf("calls: $CALLS_DEK is not valid base64: %w", err)
		}
		if len(b) != 32 {
			return out, fmt.Errorf("calls: $CALLS_DEK decodes to %d bytes, want exactly 32", len(b))
		}
		out.dek = b
	}

	switch {
	case cfg.Mailer != nil:
		out.mailer = cfg.Mailer
	case cfg.development():
		out.mailer = mail.Logged(cfg.Logger)
	default:
		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)
	}

	list, err := resolveAllowlist(cfg)
	if err != nil {
		return out, err
	}
	out.allow = list

	zone := strings.TrimSpace(cfg.Zone)
	if zone == "" {
		zone = "UTC"
	}
	loc, err := time.LoadLocation(zone)
	if err != nil {
		return out, fmt.Errorf("calls: $CALLS_ZONE %q is not an IANA zone: %w", zone, err)
	}
	out.zone = loc
	out.Zone = zone
	return out, nil
}
  • [ ] Step 5: Write the allowlist in standing.go (the config test needs it)

Create internal/calls/standing.go with only the allowlist for now; Task 3 adds Standing:

package meet

import (
	"fmt"
	"strings"
)

func normalizeAddress(address string) string {
	return strings.ToLower(strings.TrimSpace(address))
}

// allowlist is the team in lone shape (spec §7.1): the addresses that
// are members. A nil map admits everyone, which is allowed only in
// development.
type allowlist struct {
	addrs map[string]struct{}
}

func (a allowlist) allows(address string) bool {
	if a.addrs == nil {
		return true
	}
	_, ok := a.addrs[normalizeAddress(address)]
	return ok
}

func resolveAllowlist(cfg Config) (allowlist, error) {
	addrs := make(map[string]struct{})
	for _, addr := range cfg.AllowedAddresses {
		addr = normalizeAddress(addr)
		if addr == "" {
			continue
		}
		addrs[addr] = struct{}{}
	}
	if len(addrs) > 0 {
		return allowlist{addrs: addrs}, nil
	}
	if cfg.development() {
		return allowlist{}, nil
	}
	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)
}
  • [ ] Step 6: Change App to take Config, and main.go to build it

Replace internal/calls/app.go entirely:

package meet

import (
	"context"
	"log/slog"
	"net/http"

	"github.com/go-chi/chi/v5"

	"amadan.net/rastrillo/rastrillo/csrf"
	"amadan.net/rastrillo/rastrillo/db"
	"amadan.net/rastrillo/rastrillo/migrate"

	"amadan.net/oficina/meet/internal/calls/seal"
)

// app holds what every handler needs.
type app struct {
	db     *db.DB
	cfg    resolved
	sealer *seal.Sealer
	logger *slog.Logger
}

// App wires the whole app: refusals, schema, router, static files. It
// returns a *http.ServeMux because rastrillo.Options.Mux is typed that
// way; the chi router mounts inside it.
func App(d *db.DB, cfg Config) (*http.ServeMux, error) {
	rc, err := resolve(cfg)
	if err != nil {
		return nil, err
	}
	sealer, err := seal.New(rc.dek)
	if err != nil {
		return nil, err
	}
	migrated, err := migrate.Apply(context.Background(), d, BootSchema)
	if err != nil {
		return nil, err
	}
	rc.Logger.Info("migrate", "applied", migrated.Applied,
		"skipped", migrated.Skipped, "adopted", migrated.Adopted)

	a := &app{db: d, cfg: rc, sealer: sealer, logger: rc.Logger}

	r := chi.NewRouter()
	r.Use(csrf.Protect(rc.Origin))
	r.Get("/", a.index)

	mux := http.NewServeMux()
	mux.Handle("GET /static/", assets.Handler())
	mux.Handle("/", r)
	return mux, nil
}

Delete the app struct from internal/calls/handlers.go, leaving only index:

package meet

import "net/http"

func (a *app) index(w http.ResponseWriter, r *http.Request) {
	render(w, "index", nil)
}

Replace cmd/calls/main.go:

package main

import (
	"flag"
	"log/slog"
	"os"
	"strings"

	"amadan.net/rastrillo/rastrillo"
	"amadan.net/rastrillo/rastrillo/db"
	"amadan.net/rastrillo/rastrillo/mail"

	"amadan.net/oficina/meet/internal/calls"
)

func main() {
	logger := slog.New(slog.NewTextHandler(os.Stderr, nil))

	// Only the flag can select development: an env var that could would
	// let a misconfigured box relax every refusal (config.go).
	env := flag.String("env", "", "set to development to relax startup refusals")
	flag.Parse()

	opts, err := rastrillo.Resolve(rastrillo.Options{DBPath: "calls.db", Logger: logger})
	if err != nil {
		logger.Error("resolve activation", "err", err)
		os.Exit(1)
	}

	d, err := db.Open(opts.DBPath, logger)
	if err != nil {
		logger.Error("open database", "err", err)
		os.Exit(1)
	}
	defer d.Close()

	var mailer mail.Sender
	if os.Getenv("CALLS_SMTP_HOST") != "" && os.Getenv("CALLS_SMTP_FROM") != "" {
		mailer = mail.FromEnv("MEET", logger)
	}

	cfg := meet.Config{
		Env:              *env,
		Origin:           os.Getenv("CALLS_ORIGIN"),
		InstanceKey:      os.Getenv("CALLS_INSTANCE_KEY"),
		DEK:              os.Getenv("CALLS_DEK"),
		AllowedAddresses: strings.Split(os.Getenv("CALLS_ALLOWED_ADDRESSES"), ","),
		Mailer:           mailer,
		RelayConfigured:  os.Getenv("CARLOS_RELAY") != "",
		Zone:             os.Getenv("CALLS_ZONE"),
		Logger:           logger,
	}

	mux, err := meet.App(d, cfg)
	if err != nil {
		logger.Error("build app", "err", err)
		os.Exit(1)
	}

	opts.Mux = mux
	opts.DBPath = ""
	if err := rastrillo.Serve(opts); err != nil {
		logger.Error("serve failed", "err", err)
		os.Exit(1)
	}
}
  • [ ] Step 7: Update the harness and write the config test

Replace newApp in internal/callstest/harness_test.go so it passes a development Config:

func newApp(t *testing.T) http.Handler {
	t.Helper()
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { d.Close() })
	h, err := meet.App(d, meet.Config{Env: meet.EnvDevelopment, Origin: testOrigin, Logger: logger})
	if err != nil {
		t.Fatal(err)
	}
	return h
}

Create internal/callstest/config_test.go:

package callstest

import (
	"io"
	"log/slog"
	"path/filepath"
	"strings"
	"testing"

	"amadan.net/rastrillo/rastrillo/db"

	"amadan.net/oficina/meet/internal/calls"
)

func openDB(t *testing.T) *db.DB {
	t.Helper()
	d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), slog.New(slog.NewTextHandler(io.Discard, nil)))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { d.Close() })
	return d
}

func TestProductionRefusesToStartWithoutSecrets(t *testing.T) {
	cases := map[string]meet.Config{
		"no origin":    {Env: "production"},
		"no key":       {Env: "production", Origin: "https://x.test"},
		"no dek":       {Env: "production", Origin: "https://x.test", InstanceKey: "k"},
		"no mailer":    {Env: "production", Origin: "https://x.test", InstanceKey: "k", DEK: strings.Repeat("A", 44)},
		"no allowlist": {Env: "production", Origin: "https://x.test", InstanceKey: "k", DEK: strings.Repeat("A", 44), Mailer: nopMailer{}},
	}
	for name, cfg := range cases {
		if _, err := meet.App(openDB(t), cfg); err == nil {
			t.Errorf("%s: production started without it", name)
		}
	}
}

func TestDevelopmentStartsWithNothing(t *testing.T) {
	if _, err := meet.App(openDB(t), meet.Config{Env: meet.EnvDevelopment}); err != nil {
		t.Fatalf("development refused: %v", err)
	}
}

func TestBadDEKIsRefusedEvenInDevelopment(t *testing.T) {
	_, err := meet.App(openDB(t), meet.Config{Env: meet.EnvDevelopment, DEK: "not base64!"})
	if err == nil || !strings.Contains(err.Error(), "base64") {
		t.Fatalf("want a base64 refusal, got %v", err)
	}
}

Add nopMailer to harness_test.go:

type nopMailer struct{}

func (nopMailer) Send(ctx context.Context, to, subject, body string) error { return nil }
  • [ ] Step 8: Run the tests

Run: go test ./... 2>&1 | tail -8 Expected: PASS for internal/calls/seal and internal/callstest (index tests still pass; the config tests pass).

  • [ ] Step 9: Tool directive and release stamping

Run: go get -tool amadan.net/rastrillo/rastrillo/cmd/rastrillo@v0.26.0 && go mod tidy

Replace the release target in Makefile and add version-check, copying slides:

# What the running binary reports from GET /api/version. An unstamped
# binary answers "dev" from every build, so the platform's deploy
# verification can never disagree with itself. Stamping is what gives
# that check the ability to fail. Outside a repository, or before the
# first commit, name it yourself: make release VERSION=v0.1.0
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null)

.PHONY: build release version-check test vet fmt-check migration-check ci

release: version-check
	@mkdir -p releases
	CGO_ENABLED=0 GOOS=$(RELEASE_GOOS) GOARCH=$(RELEASE_GOARCH) \
		go build -ldflags="-s -w -X 'amadan.net/rastrillo/rastrillo.BuildVersion=$(VERSION)'" \
		-o $(RELEASE_BIN) ./cmd/$(APP)
	@echo
	@echo "built $(RELEASE_BIN) reporting version $(VERSION)"
	@echo "ship it with:"
	@echo "  carlos ship -app $(APP) -target $(RELEASE_GOOS)-$(RELEASE_GOARCH) $(RELEASE_BIN)"

version-check:
	@if [ -z "$(VERSION)" ]; then \
		echo "make release: no version to stamp."; \
		echo "  git describe found nothing here: no repository, or no commit yet."; \
		echo "  Commit, or name it:  make release VERSION=v0.1.0"; \
		exit 1; \
	fi
	@case "$(VERSION)" in *-dirty) \
		echo "make release: the tree is dirty, so $(VERSION) does not name what you are about to ship."; \
		echo "  Commit first, or name it:  make release VERSION=v0.1.0-wip"; \
		exit 1;; \
	esac

Remove the old .PHONY line and the old release: target.

  • [ ] Step 10: Run the gate and commit

Run: make ci 2>&1 | tail -5 Expected: vet, gofmt, tests and migration check all pass.

git add -A
git commit -m "config: refusals, sealer, release stamping

Config with every startup refusal from spec 7.1, development selected
only by the -env flag; the sealer lifted from calendar with Meet's
label; make release stamps BuildVersion and refuses a dirty tree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

---

Task 2: Models and the migration

Files:

  • Modify: internal/calls/models.go, internal/calls/migrations.go
  • Delete: internal/calls/migrations/0001_init.sql
  • Create: internal/calls/migrations/0001_init.sql (new content), regenerate schema.sql
  • Test: internal/callstest/schema_test.go

Interfaces:

  • 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).
  • [ ] Step 1: Write the models

Replace internal/calls/models.go:

package meet

import "time"

// Models is every model the schema generator manages. Keep it in step
// with the structs below: `rastrillo migration generate` reads it, and
// `rastrillo migration check` fails CI when it and the migrations
// disagree.
var Models = []any{
	&Person{},
	&Meeting{},
	&MeetingLink{},
	&GuestGrant{},
	&PendingJoin{},
}

// Person is a signed-in address. Standing (member, guest, none) is
// never a column; it is computed per request (spec §2.3).
type Person struct {
	ID        int64
	Email     string `gorm:"uniqueIndex;not null"`
	Name      string `gorm:"not null"`
	CreatedAt time.Time
}

// Meeting is a room that may be entered. Its title is sealed; its
// times are plaintext because the list sorts on them.
type Meeting struct {
	ID          int64
	Sqid        string `gorm:"uniqueIndex;not null"`
	TitleSealed []byte `gorm:"column:title_sealed"`
	StartsAt    *time.Time
	CreatedBy   string `gorm:"not null"`
	CreatedAt   time.Time
	EndedAt     *time.Time
	EndedBy     string
}

// MeetingLink holds the SHA-256 of a token that was shown once
// (ruling 16). Several may exist per meeting so a revoked one can be
// replaced.
type MeetingLink struct {
	ID        int64
	MeetingID int64  `gorm:"index;not null"`
	TokenHash string `gorm:"uniqueIndex;not null"`
	CreatedBy string `gorm:"not null"`
	CreatedAt time.Time
	RevokedAt *time.Time
}

// GuestGrant is a non-member's entry authority for one meeting. One
// row per person per meeting. AdmittedAt and AdmittedBy are an audit of
// the first admission (slice 2) and confer nothing.
type GuestGrant struct {
	ID         int64
	MeetingID  int64 `gorm:"uniqueIndex:idx_grant_meeting_person;not null"`
	PersonID   int64 `gorm:"uniqueIndex:idx_grant_meeting_person;not null"`
	GrantedAt  time.Time
	ExpiresAt  time.Time `gorm:"index"`
	AdmittedAt *time.Time
	AdmittedBy string
}

// PendingJoin is written before a magic link is mailed to someone who
// followed a live meeting link, so the emailed link can admit them in
// any browser and /continue can find their destination. The address is
// a keyed fingerprint, never plaintext.
type PendingJoin struct {
	ID                 int64
	MeetingID          int64  `gorm:"not null"`
	LinkID             int64  `gorm:"not null"`
	AddressFingerprint string `gorm:"index;not null"`
	CreatedAt          time.Time
	ExpiresAt          time.Time
}
  • [ ] Step 2: Replace the scaffold migration

Nothing has shipped, so the scaffold's notes migration is replaced rather than appended to.

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

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.

  • [ ] Step 3: Merge the sessions and auth schemas

In internal/calls/migrations.go, change the merge line and imports:

import (
	"embed"

	rauth "amadan.net/rastrillo/rastrillo/auth"
	"amadan.net/rastrillo/rastrillo/migrate"
	"amadan.net/rastrillo/rastrillo/sessions"
)

//go:embed migrations/*.sql
var migrationFS embed.FS

var Schema = migrate.MustFromFS(migrationFS, "meet")

// Apply order: sessions before auth (auth's second migration reads the
// sessions table), Meet's own last.
var BootSchema = migrate.Merge(sessions.Schema, rauth.Schema, Schema)
  • [ ] Step 4: Write the schema test

Create internal/callstest/schema_test.go:

package callstest

import (
	"testing"

	"amadan.net/oficina/meet/internal/calls"
)

func TestBootCreatesEveryTable(t *testing.T) {
	d := openDB(t)
	if _, err := meet.App(d, meet.Config{Env: meet.EnvDevelopment}); err != nil {
		t.Fatal(err)
	}
	for _, table := range []string{"persons", "meetings", "meeting_links", "guest_grants", "pending_joins", "sessions", "auth_links"} {
		var n int
		if err := d.G.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&n).Error; err != nil {
			t.Fatal(err)
		}
		if n != 1 {
			t.Errorf("table %s missing after boot", table)
		}
	}
}
  • [ ] Step 5: Run the gate and commit

Run: make ci 2>&1 | tail -5 Expected: green, including migration check.

git add -A
git commit -m "schema: persons, meetings, links, grants, pending joins

The five tables of spec 2.1 replace the scaffold's notes; sessions and
auth schemas apply first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

---

Task 3: Ids, store and standing

Files:

  • Create: internal/calls/ids.go, internal/calls/store.go
  • Modify: internal/calls/standing.go
  • Test: internal/calls/ids_test.go, internal/callstest/store_test.go

Interfaces:

  • Produces: - newSqid() (string, error), newLinkToken() (token, hash string, err error), hashLinkToken(token string) string - type store struct{ g *gorm.DB; sealer *seal.Sealer; now func() time.Time } - (*store).ensurePerson(ctx, address string) (Person, error) - (*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 - (*store).listMeetings(ctx) ([]meetingView, error) with type meetingView struct{ Meeting; Title string; LinkPresent bool } - (*store).meetingBySqid(ctx, sqid string) (Meeting, bool, error); (*store).title(m Meeting) string - (*store).liveLink(ctx, token string) (MeetingLink, Meeting, bool, error) — one false for unknown, revoked, ended - (*store).liveLinkByID(ctx, linkID int64) (MeetingLink, Meeting, bool, error) - (*store).replaceLink(ctx, meetingID int64, actor string) (token string, err error) - (*store).endMeeting(ctx, meetingID int64, actor string) error - (*store).grantFor(ctx, meetingID, personID int64) (GuestGrant, bool, error); (*store).mintGrant(ctx, m Meeting, personID int64) (GuestGrant, error) (idempotent; expiry per ruling 4) - (*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 - type Standing int with StandingNone, StandingGuest, StandingMember; (*app).standingFor(ctx, p Person, m Meeting) (Standing, error) - const grantLife = 2 * time.Hour, const pendingLife = 15 * time.Minute
  • [ ] Step 1: Ids

Create internal/calls/ids.go:

package meet

import (
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"fmt"
)

// sqidAlphabet is Docs' and Slides' alphabet, character for character:
// no 0/O, no 1/l/I, so a link read aloud survives.
const sqidAlphabet = "23456789abcdefghjkmnpqrstuvwxyz"
const sqidLength = 10
const maxSqidAttempts = 5

func newSqid() (string, error) {
	raw := make([]byte, sqidLength)
	if _, err := rand.Read(raw); err != nil {
		return "", fmt.Errorf("calls: generate sqid: %w", err)
	}
	out := make([]byte, sqidLength)
	for i, b := range raw {
		out[i] = sqidAlphabet[int(b)%len(sqidAlphabet)]
	}
	return string(out), nil
}

const linkTokenBytes = 32

// newLinkToken mints a token and its hash. The token is returned
// exactly once, to be shown exactly once (ruling 16).
func newLinkToken() (token, hash string, err error) {
	buf := make([]byte, linkTokenBytes)
	if _, err := rand.Read(buf); err != nil {
		return "", "", err
	}
	token = base64.RawURLEncoding.EncodeToString(buf)
	return token, hashLinkToken(token), nil
}

func hashLinkToken(token string) string {
	sum := sha256.Sum256([]byte(token))
	return hex.EncodeToString(sum[:])
}

Create internal/calls/ids_test.go:

package meet

import (
	"strings"
	"testing"
)

func TestSqidUsesOnlyTheReadableAlphabet(t *testing.T) {
	for i := 0; i < 50; i++ {
		s, err := newSqid()
		if err != nil {
			t.Fatal(err)
		}
		if len(s) != sqidLength || strings.Trim(s, sqidAlphabet) != "" {
			t.Fatalf("bad sqid %q", s)
		}
	}
}

func TestLinkTokenHashIsSHA256Hex(t *testing.T) {
	tok, hash, err := newLinkToken()
	if err != nil {
		t.Fatal(err)
	}
	if len(hash) != 64 || hashLinkToken(tok) != hash {
		t.Fatalf("hash %q does not match token", hash)
	}
}
  • [ ] Step 2: Store

Create internal/calls/store.go:

package meet

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

	"gorm.io/gorm"
	"gorm.io/gorm/clause"

	"amadan.net/oficina/meet/internal/calls/seal"
)

// grantLife is ruling 4: a guest grant expires two hours after the
// meeting's start, or two hours after creation when there is no start.
const grantLife = 2 * time.Hour

// pendingLife matches the pending-join cookie: fifteen minutes.
const pendingLife = 15 * time.Minute

// store is every database access in the app. Sealing happens here and
// nowhere else, so a handler never holds a plaintext title it did not
// get from the store.
type store struct {
	g      *gorm.DB
	sealer *seal.Sealer
	now    func() time.Time
}

func actorFor(p Person) string { return fmt.Sprintf("person:%d", p.ID) }

// ensurePerson reads first and writes only when the read is empty:
// this runs on every signed-in request, and SQLite has one writer.
func (s *store) ensurePerson(ctx context.Context, address string) (Person, error) {
	email := normalizeAddress(address)
	if email == "" {
		return Person{}, errors.New("calls: cannot map an empty address to a person")
	}
	var found Person
	err := s.g.WithContext(ctx).Where("email = ?", email).Take(&found).Error
	switch {
	case err == nil:
		return found, nil
	case !errors.Is(err, gorm.ErrRecordNotFound):
		return Person{}, fmt.Errorf("calls: load person: %w", err)
	}
	name := email
	if at := strings.Index(email, "@"); at > 0 {
		name = email[:at]
	}
	created := Person{Email: email, Name: name, CreatedAt: s.now().UTC()}
	if res := s.g.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&created); res.Error != nil {
		return Person{}, fmt.Errorf("calls: create person: %w", res.Error)
	}
	if err := s.g.WithContext(ctx).Where("email = ?", email).Take(&found).Error; err != nil {
		return Person{}, fmt.Errorf("calls: load person: %w", err)
	}
	return found, nil
}

type meetingView struct {
	Meeting
	Title       string
	LinkPresent bool
}

func (s *store) title(m Meeting) string {
	t, err := s.sealer.OpenString(seal.PurposeMeetingTitle, m.TitleSealed)
	if err != nil {
		return "(title unreadable)"
	}
	return t
}

// createMeeting creates the meeting and its first link in one
// transaction and returns the token, the only time it exists in
// plaintext on the server side.
func (s *store) createMeeting(ctx context.Context, title string, startsAt *time.Time, actor string) (Meeting, string, error) {
	sealed, err := s.sealer.SealString(seal.PurposeMeetingTitle, title)
	if err != nil {
		return Meeting{}, "", err
	}
	token, hash, err := newLinkToken()
	if err != nil {
		return Meeting{}, "", err
	}
	var m Meeting
	err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		for attempt := 0; ; attempt++ {
			sqid, err := newSqid()
			if err != nil {
				return err
			}
			m = Meeting{Sqid: sqid, TitleSealed: sealed, StartsAt: startsAt, CreatedBy: actor, CreatedAt: s.now().UTC()}
			res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&m)
			if res.Error != nil {
				return res.Error
			}
			if res.RowsAffected == 1 {
				break
			}
			if attempt+1 >= maxSqidAttempts {
				return errors.New("calls: could not mint a unique sqid")
			}
		}
		return tx.Create(&MeetingLink{MeetingID: m.ID, TokenHash: hash, CreatedBy: actor, CreatedAt: s.now().UTC()}).Error
	})
	if err != nil {
		return Meeting{}, "", err
	}
	return m, token, nil
}

func (s *store) listMeetings(ctx context.Context) ([]meetingView, error) {
	var ms []Meeting
	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 {
		return nil, err
	}
	out := make([]meetingView, 0, len(ms))
	for _, m := range ms {
		var n int64
		if err := s.g.WithContext(ctx).Model(&MeetingLink{}).Where("meeting_id = ? AND revoked_at IS NULL", m.ID).Count(&n).Error; err != nil {
			return nil, err
		}
		out = append(out, meetingView{Meeting: m, Title: s.title(m), LinkPresent: n > 0})
	}
	return out, nil
}

func (s *store) meetingBySqid(ctx context.Context, sqid string) (Meeting, bool, error) {
	var m Meeting
	err := s.g.WithContext(ctx).Where("sqid = ?", sqid).Take(&m).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return Meeting{}, false, nil
	}
	return m, err == nil, err
}

// liveLink answers one false for a token that never existed, one that
// was revoked, and one whose meeting has ended (spec §3.2's one 404).
func (s *store) liveLink(ctx context.Context, token string) (MeetingLink, Meeting, bool, error) {
	if token == "" {
		return MeetingLink{}, Meeting{}, false, nil
	}
	var l MeetingLink
	err := s.g.WithContext(ctx).Where("token_hash = ?", hashLinkToken(token)).Take(&l).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return MeetingLink{}, Meeting{}, false, nil
	}
	if err != nil {
		return MeetingLink{}, Meeting{}, false, err
	}
	return s.liveLinkRow(ctx, l)
}

func (s *store) liveLinkByID(ctx context.Context, id int64) (MeetingLink, Meeting, bool, error) {
	var l MeetingLink
	err := s.g.WithContext(ctx).Where("id = ?", id).Take(&l).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return MeetingLink{}, Meeting{}, false, nil
	}
	if err != nil {
		return MeetingLink{}, Meeting{}, false, err
	}
	return s.liveLinkRow(ctx, l)
}

func (s *store) liveLinkRow(ctx context.Context, l MeetingLink) (MeetingLink, Meeting, bool, error) {
	if l.RevokedAt != nil {
		return MeetingLink{}, Meeting{}, false, nil
	}
	var m Meeting
	if err := s.g.WithContext(ctx).Where("id = ?", l.MeetingID).Take(&m).Error; err != nil {
		return MeetingLink{}, Meeting{}, false, err
	}
	if m.EndedAt != nil {
		return MeetingLink{}, Meeting{}, false, nil
	}
	return l, m, true, nil
}

// replaceLink revokes every live link for the meeting and mints one
// new one, in one transaction, returning the new token once.
func (s *store) replaceLink(ctx context.Context, meetingID int64, actor string) (string, error) {
	token, hash, err := newLinkToken()
	if err != nil {
		return "", err
	}
	now := s.now().UTC()
	err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		if err := tx.Model(&MeetingLink{}).Where("meeting_id = ? AND revoked_at IS NULL", meetingID).Update("revoked_at", now).Error; err != nil {
			return err
		}
		return tx.Create(&MeetingLink{MeetingID: meetingID, TokenHash: hash, CreatedBy: actor, CreatedAt: now}).Error
	})
	if err != nil {
		return "", err
	}
	return token, nil
}

// endMeeting sets ended_at and revokes every link.
func (s *store) endMeeting(ctx context.Context, meetingID int64, actor string) error {
	now := s.now().UTC()
	return s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		res := tx.Model(&Meeting{}).Where("id = ? AND ended_at IS NULL", meetingID).
			Select("ended_at", "ended_by").Updates(map[string]any{"ended_at": now, "ended_by": actor})
		if res.Error != nil {
			return res.Error
		}
		return tx.Model(&MeetingLink{}).Where("meeting_id = ? AND revoked_at IS NULL", meetingID).Update("revoked_at", now).Error
	})
}

func (s *store) grantFor(ctx context.Context, meetingID, personID int64) (GuestGrant, bool, error) {
	var g GuestGrant
	err := s.g.WithContext(ctx).Where("meeting_id = ? AND person_id = ?", meetingID, personID).Take(&g).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return GuestGrant{}, false, nil
	}
	return g, err == nil, err
}

// mintGrant is idempotent: following the link twice touches nothing.
func (s *store) mintGrant(ctx context.Context, m Meeting, personID int64) (GuestGrant, error) {
	if g, ok, err := s.grantFor(ctx, m.ID, personID); err != nil || ok {
		return g, err
	}
	now := s.now().UTC()
	base := now
	if m.StartsAt != nil {
		base = m.StartsAt.UTC()
	}
	g := GuestGrant{MeetingID: m.ID, PersonID: personID, GrantedAt: now, ExpiresAt: base.Add(grantLife)}
	if res := s.g.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&g); res.Error != nil {
		return GuestGrant{}, res.Error
	}
	g, _, err := s.grantFor(ctx, m.ID, personID)
	return g, err
}

func (s *store) fingerprint(address string) (string, error) {
	return s.sealer.Fingerprint(seal.PurposePendingAddress, normalizeAddress(address))
}

func (s *store) writePending(ctx context.Context, address string, link MeetingLink) error {
	fp, err := s.fingerprint(address)
	if err != nil {
		return err
	}
	now := s.now().UTC()
	return s.g.WithContext(ctx).Create(&PendingJoin{
		MeetingID: link.MeetingID, LinkID: link.ID, AddressFingerprint: fp,
		CreatedAt: now, ExpiresAt: now.Add(pendingLife),
	}).Error
}

// pendingLive is the Authorize half: does this address hold an
// unexpired pending row whose link is still live?
func (s *store) pendingLive(ctx context.Context, address string) (bool, error) {
	p, ok, err := s.latestPending(ctx, address)
	if err != nil || !ok {
		return false, err
	}
	_, _, live, err := s.liveLinkByID(ctx, p.LinkID)
	return live, err
}

func (s *store) latestPending(ctx context.Context, address string) (PendingJoin, bool, error) {
	fp, err := s.fingerprint(address)
	if err != nil {
		return PendingJoin{}, false, err
	}
	var p PendingJoin
	err = s.g.WithContext(ctx).Where("address_fingerprint = ? AND expires_at > ?", fp, s.now().UTC()).
		Order("created_at DESC").Take(&p).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return PendingJoin{}, false, nil
	}
	return p, err == nil, err
}

func (s *store) deletePending(ctx context.Context, address string) error {
	fp, err := s.fingerprint(address)
	if err != nil {
		return err
	}
	return s.g.WithContext(ctx).Where("address_fingerprint = ?", fp).Delete(&PendingJoin{}).Error
}
  • [ ] Step 3: Standing

Append to internal/calls/standing.go:

// Standing is a person's entry authority toward a meeting (spec §2.3).
// Computed, never stored.
type Standing int

const (
	StandingNone Standing = iota
	StandingGuest
	StandingMember
)

func (s Standing) String() string {
	switch s {
	case StandingMember:
		return "member"
	case StandingGuest:
		return "guest"
	}
	return "none"
}

// isMember is the lone-shape roster read: the allowlist.
func (a *app) isMember(p Person) bool {
	return a.cfg.allow.addrs != nil && a.cfg.allow.allows(p.Email)
}

func (a *app) standingFor(ctx context.Context, p Person, m Meeting) (Standing, error) {
	if a.isMember(p) {
		return StandingMember, nil
	}
	g, ok, err := a.store.grantFor(ctx, m.ID, p.ID)
	if err != nil {
		return StandingNone, err
	}
	if ok && g.ExpiresAt.After(a.store.now()) {
		return StandingGuest, nil
	}
	return StandingNone, nil
}

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.

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:

// SetClockForTest replaces the store's clock; the harness uses it to
// expire grants and pending rows without sleeping.
func (a *app) SetClockForTest(now func() time.Time) { a.store.now = now }

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:

// NewForTest builds the app and returns the handler and a clock setter.
// Tests only; production uses App.
func NewForTest(d *db.DB, cfg Config) (*http.ServeMux, func(func() time.Time), error) {
	mux, a, err := build(d, cfg)
	if err != nil {
		return nil, nil, err
	}
	return mux, a.SetClockForTest, nil
}

and refactor App into build(d, cfg) (*http.ServeMux, *app, error) with App calling it and discarding the *app.

  • [ ] Step 4: Harness upgrade

In internal/callstest/harness_test.go replace newApp with:

type harness struct {
	h     http.Handler
	db    *db.DB
	mail  *recordingMailer
	clock func(func() time.Time)
	now   time.Time
}

func (x *harness) advance(d time.Duration) {
	x.now = x.now.Add(d)
	now := x.now
	x.clock(func() time.Time { return now })
}

type recordingMailer struct {
	mu    sync.Mutex
	to    []string
	links []string
}

var magicLink = regexp.MustCompile(`https?://\S+/auth/verify\?token=\S+`)

func (m *recordingMailer) Send(ctx context.Context, to, subject, body string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.to = append(m.to, to)
	m.links = append(m.links, magicLink.FindString(body))
	return nil
}

func (m *recordingMailer) last() (string, string) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if len(m.to) == 0 {
		return "", ""
	}
	return m.to[len(m.to)-1], m.links[len(m.links)-1]
}

func (m *recordingMailer) count() int {
	m.mu.Lock()
	defer m.mu.Unlock()
	return len(m.to)
}

func newHarness(t *testing.T, members ...string) *harness {
	t.Helper()
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { d.Close() })
	rec := &recordingMailer{}
	mux, setClock, err := meet.NewForTest(d, meet.Config{
		Env: meet.EnvDevelopment, Origin: testOrigin, AllowedAddresses: members, Mailer: rec, Logger: logger,
	})
	if err != nil {
		t.Fatal(err)
	}
	x := &harness{h: mux, db: d, mail: rec, clock: setClock, now: time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC)}
	x.advance(0)
	return x
}

func newApp(t *testing.T) http.Handler { return newHarness(t).h }

Keep get and post; add cookie-carrying variants used from Task 4 on:

type client struct {
	t       *testing.T
	x       *harness
	cookies map[string]*http.Cookie
}

func (x *harness) browser(t *testing.T) *client {
	return &client{t: t, x: x, cookies: map[string]*http.Cookie{}}
}

func (c *client) do(req *http.Request) *httptest.ResponseRecorder {
	for _, ck := range c.cookies {
		req.AddCookie(ck)
	}
	rec := httptest.NewRecorder()
	c.x.h.ServeHTTP(rec, req)
	for _, ck := range rec.Result().Cookies() {
		if ck.MaxAge < 0 {
			delete(c.cookies, ck.Name)
			continue
		}
		c.cookies[ck.Name] = ck
	}
	return rec
}

func (c *client) get(target string) *httptest.ResponseRecorder {
	return c.do(httptest.NewRequest(http.MethodGet, target, nil))
}

func (c *client) post(target string, form url.Values) *httptest.ResponseRecorder {
	req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("Origin", testOrigin)
	return c.do(req)
}
  • [ ] Step 5: Store tests through a test-only accessor

The store is unexported; test it through meet package tests. Create internal/calls/store_test.go:

package meet

import (
	"bytes"
	"context"
	"io"
	"log/slog"
	"path/filepath"
	"testing"
	"time"

	"amadan.net/rastrillo/rastrillo/db"
	"amadan.net/rastrillo/rastrillo/migrate"

	"amadan.net/oficina/meet/internal/calls/seal"
)

func testStore(t *testing.T) (*store, *time.Time) {
	t.Helper()
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	d, err := db.Open(filepath.Join(t.TempDir(), "s.db"), logger)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { d.Close() })
	if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil {
		t.Fatal(err)
	}
	sl, _ := seal.New(bytes.Repeat([]byte{9}, 32))
	now := time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC)
	return &store{g: d.G, sealer: sl, now: func() time.Time { return now }}, &now
}

func TestCreateMeetingSealsTitleAndMintsOneLiveLink(t *testing.T) {
	s, _ := testStore(t)
	ctx := context.Background()
	m, token, err := s.createMeeting(ctx, "Board meeting", nil, "person:1")
	if err != nil {
		t.Fatal(err)
	}
	if bytes.Contains(m.TitleSealed, []byte("Board")) {
		t.Fatal("title stored in plaintext")
	}
	if s.title(m) != "Board meeting" {
		t.Fatalf("title opened as %q", s.title(m))
	}
	l, m2, ok, err := s.liveLink(ctx, token)
	if err != nil || !ok || m2.ID != m.ID || l.MeetingID != m.ID {
		t.Fatalf("live link not found: ok=%v err=%v", ok, err)
	}
	if _, _, ok, _ := s.liveLink(ctx, "nope"); ok {
		t.Fatal("unknown token answered live")
	}
}

func TestReplaceLinkRevokesTheOldOne(t *testing.T) {
	s, _ := testStore(t)
	ctx := context.Background()
	m, old, _ := s.createMeeting(ctx, "x", nil, "person:1")
	fresh, err := s.replaceLink(ctx, m.ID, "person:1")
	if err != nil {
		t.Fatal(err)
	}
	if _, _, ok, _ := s.liveLink(ctx, old); ok {
		t.Fatal("old link still live")
	}
	if _, _, ok, _ := s.liveLink(ctx, fresh); !ok {
		t.Fatal("new link not live")
	}
}

func TestEndMeetingKillsItsLinks(t *testing.T) {
	s, _ := testStore(t)
	ctx := context.Background()
	m, tok, _ := s.createMeeting(ctx, "x", nil, "person:1")
	if err := s.endMeeting(ctx, m.ID, "person:1"); err != nil {
		t.Fatal(err)
	}
	if _, _, ok, _ := s.liveLink(ctx, tok); ok {
		t.Fatal("link live after end")
	}
}

func TestGrantExpiresTwoHoursAfterStartOrCreation(t *testing.T) {
	s, now := testStore(t)
	ctx := context.Background()
	start := now.Add(3 * time.Hour)
	scheduled, _, _ := s.createMeeting(ctx, "later", &start, "person:1")
	immediate, _, _ := s.createMeeting(ctx, "now", nil, "person:1")
	g1, _ := s.mintGrant(ctx, scheduled, 7)
	g2, _ := s.mintGrant(ctx, immediate, 7)
	if !g1.ExpiresAt.Equal(start.Add(grantLife)) {
		t.Errorf("scheduled grant expires %v, want %v", g1.ExpiresAt, start.Add(grantLife))
	}
	if !g2.ExpiresAt.Equal(now.Add(grantLife)) {
		t.Errorf("immediate grant expires %v, want %v", g2.ExpiresAt, now.Add(grantLife))
	}
	again, _ := s.mintGrant(ctx, immediate, 7)
	if again.ID != g2.ID {
		t.Fatal("minting twice made a second grant")
	}
}

func TestPendingIsKeyedFingerprintAndFollowsItsLink(t *testing.T) {
	s, now := testStore(t)
	ctx := context.Background()
	m, tok, _ := s.createMeeting(ctx, "x", nil, "person:1")
	l, _, _, _ := s.liveLink(ctx, tok)
	if err := s.writePending(ctx, "Guest@Example.com", l); err != nil {
		t.Fatal(err)
	}
	var raw []byte
	if err := s.g.Raw("SELECT address_fingerprint FROM pending_joins").Scan(&raw).Error; err != nil {
		t.Fatal(err)
	}
	if bytes.Contains(bytes.ToLower(raw), []byte("guest@")) {
		t.Fatal("address stored in plaintext")
	}
	if live, _ := s.pendingLive(ctx, "guest@example.com"); !live {
		t.Fatal("pending not live")
	}
	if _, err := s.replaceLink(ctx, m.ID, "person:1"); err != nil {
		t.Fatal(err)
	}
	if live, _ := s.pendingLive(ctx, "guest@example.com"); live {
		t.Fatal("pending still live after its link was replaced")
	}
	*now = now.Add(pendingLife + time.Minute)
	if _, ok, _ := s.latestPending(ctx, "guest@example.com"); ok {
		t.Fatal("expired pending returned")
	}
}
  • [ ] Step 6: Run the gate and commit

Run: make ci 2>&1 | tail -5 Expected: green.

git add -A
git commit -m "store: meetings, links, grants, pending joins, standing

Sealed titles, one-404 link lookup, replace and end in transactions,
idempotent grants expiring per ruling 4, keyed-fingerprint pending
rows bound to their link, standing computed per request.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

---

Task 4: Sign-in, the template tree, and the person middleware

Files:

  • Create: internal/calls/auth.go, internal/calls/templates/signin.html, internal/calls/templates/errors.html, internal/calls/templates/welcome.html
  • Modify: internal/calls/render.go, internal/calls/templates/layout.html, internal/calls/app.go, internal/calls/handlers.go
  • Test: internal/callstest/signin_test.go

Interfaces:

  • 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.
  • Consumes: store.ensurePerson, allowlist.allows, store.pendingLive (from Task 3; the pending exception itself is Task 6, but Authorize is written once here).
  • [ ] Step 1: Template tree with ui functions

Replace internal/calls/render.go:

package meet

import (
	"bytes"
	"embed"
	"html/template"
	"net/http"

	"amadan.net/rastrillo/rastrillo"
	"amadan.net/rastrillo/rastrillo/ui"

	"amadan.net/oficina/meet/internal/calls/icons"
)

//go:embed templates static
var appFS embed.FS

var assets = rastrillo.NewAssets(appFS)

var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join"}

var pages = map[string]*template.Template{}

func init() {
	for _, name := range pageNames {
		pages[name] = buildPage(name)
	}
}

// buildPage parses rastrillo's ui partials first, then the layout and
// the page, so a page can call confirm-form, field, empty-state and the
// rest by name with a dict.
func buildPage(name string) *template.Template {
	return template.Must(
		template.Must(template.New("layout").
			Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
			Funcs(template.FuncMap{"asset": assets.Path}).
			ParseFS(ui.Templates(), "*.html")).
			ParseFS(appFS, "templates/layout.html", "templates/"+name+".html"))
}

func render(w http.ResponseWriter, name string, data any) {
	var buf bytes.Buffer
	if err := pages[name].ExecuteTemplate(&buf, "layout", data); err != nil {
		http.Error(w, "something went wrong", http.StatusInternalServerError)
		return
	}
	buf.WriteTo(w)
}

// ErrorPage renders the one error template with the status and the
// reference rastrillo minted for the log line.
func ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
	w.WriteHeader(status)
	render(w, "errors", map[string]any{"Status": status, "Ref": ref})
}

Replace internal/calls/templates/layout.html:

{{define "layout"}}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}Meet{{end}}</title>
<link rel="stylesheet" href="{{asset "static/tokens.css"}}">
<script defer src="{{asset "static/rastrillo.js"}}"></script>
{{iconAssets}}
<script defer src="{{asset "static/select.js"}}"></script>
</head>
<body>
<main>
<div rst-page>
{{template "content" .}}
</div>
</main>
</body>
</html>
{{end}}

Create internal/calls/templates/errors.html:

{{define "title"}}Something went wrong{{end}}
{{define "content"}}
{{template "page-header" dict "Title" (printf "%d" .Status)}}
<p>That did not work.{{if .Ref}} Reference {{.Ref}}.{{end}}</p>
<p><a href="/">Back to meetings</a></p>
{{end}}

Create internal/calls/templates/signin.html:

{{define "title"}}Sign in{{end}}
{{define "content"}}
{{template "page-header" dict "Title" "Sign in to Meet"}}
{{if .Sent}}
<p rst-notice>If that address can sign in here, a link is on its way. Open it on this device to continue.</p>
{{else}}
{{if .Error}}<p rst-notice role="alert">{{.Error}}</p>{{end}}
<form rst-form method="post" action="/signin">
  {{template "field" dict "ID" "address" "Name" "address" "Label" "Email address" "Type" "email" "Required" true "Autocomplete" "email" "Autofocus" true}}
  <div rst-form-actions><button type="submit" rst-btn="primary lg">Send me a sign-in link</button></div>
</form>
{{end}}
{{end}}

Create internal/calls/templates/welcome.html:

{{define "title"}}Signed in{{end}}
{{define "content"}}
{{template "page-header" dict "Title" "You're signed in"}}
<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>
<form method="post" action="/signout"><button type="submit" rst-btn="ghost">Sign out</button></form>
{{end}}

Replace internal/calls/templates/index.html with a placeholder Task 5 fills:

{{define "title"}}Meetings{{end}}
{{define "content"}}
{{template "page-header" dict "Title" "Meetings"}}
{{end}}

Create empty placeholders so init parses (Task 5 and 7 replace them): internal/calls/templates/link_once.html, confirm.html, join.html, each containing:

{{define "title"}}Meet{{end}}
{{define "content"}}{{end}}
  • [ ] Step 2: Auth wiring

Create internal/calls/auth.go:

package meet

import (
	"context"
	"database/sql"
	"net/http"
	"net/url"
	"strings"

	rauth "amadan.net/rastrillo/rastrillo/auth"
	"amadan.net/rastrillo/rastrillo/sessions"
)

const signinPath = "/signin"
const sentLocation = signinPath + "?sent=1"

// newAuthPlugin wires rastrillo/auth. Authorize is Meet's one departure
// from Docs (spec §3.2): an address is admitted at the emailed link when
// it is on the allowlist, or when it holds an unexpired pending-join row
// whose link is still live. The row was written before the mail went
// out, so the click is admitted in whichever browser it lands.
func (a *app) newAuthPlugin(writer *sql.DB) (*rauth.Auth, error) {
	return rauth.New(rauth.Config{
		DB:           writer,
		Origin:       a.cfg.Origin,
		InstanceKey:  a.cfg.instanceKey,
		Mailer:       a.cfg.mailer,
		Authorize:    a.authorize,
		SigninPath:   signinPath,
		SignedInPath: "/continue",
		Logger:       a.logger,
	})
}

func (a *app) authorize(address string) bool {
	if a.cfg.allow.allows(address) {
		return true
	}
	live, err := a.store.pendingLive(context.Background(), address)
	if err != nil {
		a.logger.Error("calls: authorize: pending lookup", "err", err)
		return false
	}
	return live
}

// gateBegin sits in front of rastrillo's Begin. The guarantee: every
// answer is byte-identical, 303 Location: /signin?sent=1, whatever the
// address's standing, so the endpoint is not a membership oracle. Task 6
// adds the pending-join exception in front of the allowlist check.
func (a *app) gateBegin(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		_ = r.ParseForm()
		address := r.FormValue("address")
		if !a.mayRequestLink(w, r, address) {
			http.Redirect(w, r, sentLocation, http.StatusSeeOther)
			return
		}
		if r.Form == nil {
			r.Form = url.Values{}
		}
		r.Form.Set("force", "1")
		next(&sameAnswer{ResponseWriter: w, app: a}, r)
	}
}

// mayRequestLink decides whether Begin is reached. Task 6 extends it.
func (a *app) mayRequestLink(w http.ResponseWriter, r *http.Request, address string) bool {
	return a.cfg.allow.allows(address)
}

// sameAnswer rewrites rastrillo's own refusal redirects to the sent
// location. It hooks WriteHeader because http.Redirect sets Location
// and then calls WriteHeader, so the Location is visible in time.
type sameAnswer struct {
	http.ResponseWriter
	app *app
}

func (s *sameAnswer) WriteHeader(status int) {
	if loc := s.Header().Get("Location"); loc != "" && loc != sentLocation && strings.HasPrefix(loc, signinPath+"?err=") {
		s.app.logger.Warn("calls: sign-in refused; answering as if sent", "outcome", loc)
		s.Header().Set("Location", sentLocation)
	}
	s.ResponseWriter.WriteHeader(status)
}

type personCtxKey struct{}

// requirePerson runs behind RequireSession: it maps the session subject
// (the verified address; sessions.UserID would return 0, false) to a
// persons row, creating it on first sign-in.
func (a *app) requirePerson(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		s, ok := sessions.Current(r)
		if !ok || s.Subject == "" {
			a.logger.Error("calls: session with no subject reached requirePerson", "path", r.URL.Path)
			ErrorPage(w, r, http.StatusInternalServerError, "")
			return
		}
		p, err := a.store.ensurePerson(r.Context(), s.Subject)
		if err != nil {
			a.logger.Error("calls: ensure person", "err", err)
			ErrorPage(w, r, http.StatusInternalServerError, "")
			return
		}
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), personCtxKey{}, p)))
	})
}

func currentPerson(r *http.Request) (Person, bool) {
	p, ok := r.Context().Value(personCtxKey{}).(Person)
	return p, ok
}

// requireMember is spec §2.5's first row: current member standing.
func (a *app) requireMember(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		p, ok := currentPerson(r)
		if !ok || !a.isMember(p) {
			ErrorPage(w, r, http.StatusNotFound, "")
			return
		}
		next.ServeHTTP(w, r)
	})
}

func (a *app) signin(w http.ResponseWriter, r *http.Request) {
	data := map[string]any{"Sent": r.URL.Query().Get("sent") == "1"}
	switch r.URL.Query().Get("err") {
	case "address":
		data["Error"] = "That does not look like an email address."
	case "rate":
		data["Error"] = "Too many sign-in attempts just now. Wait a few minutes and try again."
	case "expired":
		data["Error"] = "That sign-in link has expired. Ask for a new one."
	case "":
	default:
		data["Error"] = "Sign-in did not work. Try again."
	}
	render(w, "signin", data)
}

// continueSignedIn is SignedInPath. Task 6 adds the pending-join branch
// in front; this slice's first version routes members to the list and
// everyone else to /welcome.
func (a *app) continueSignedIn(w http.ResponseWriter, r *http.Request) {
	p, ok := currentPerson(r)
	if !ok {
		ErrorPage(w, r, http.StatusInternalServerError, "")
		return
	}
	if dest, handled := a.continuePending(w, r, p); handled {
		http.Redirect(w, r, dest, http.StatusSeeOther)
		return
	}
	if a.isMember(p) {
		http.Redirect(w, r, "/", http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/welcome", http.StatusSeeOther)
}

// continuePending is filled in by Task 6.
func (a *app) continuePending(w http.ResponseWriter, r *http.Request, p Person) (string, bool) {
	return "", false
}

func (a *app) welcome(w http.ResponseWriter, r *http.Request) {
	p, _ := currentPerson(r)
	render(w, "welcome", map[string]any{"Email": p.Email})
}
  • [ ] Step 3: Router with the three tiers

Replace the router section of build in app.go (after a := &app{...}):

	writer, err := d.G.DB()
	if err != nil {
		return nil, nil, err
	}
	authPlugin, err := a.newAuthPlugin(writer)
	if err != nil {
		return nil, nil, err
	}

	r := chi.NewRouter()
	r.Use(csrf.Protect(rc.Origin))

	// Inside CSRF, outside the session: sign-in and, in Task 6, /j/.
	r.Get(signinPath, a.signin)
	r.Post(signinPath, a.gateBegin(authPlugin.Begin))
	r.Get("/auth/callback", authPlugin.Callback)
	r.Get("/auth/verify", authPlugin.Verify)
	r.Post("/signout", authPlugin.Signout)

	// Signed in, with a persons row.
	r.Group(func(r chi.Router) {
		r.Use(authPlugin.RequireSession)
		r.Use(a.requirePerson)
		r.Get("/continue", a.continueSignedIn)
		r.Get("/welcome", a.welcome)
		r.Group(func(r chi.Router) {
			r.Use(a.requireMember)
			r.Get("/", a.index)
		})
	})

	mux := http.NewServeMux()
	mux.Handle("GET /static/", assets.Handler())
	mux.Handle("/", r)
	return mux, a, nil
  • [ ] Step 4: Harness sign-in helper and tests

Add to harness_test.go:

// signIn asks for a magic link as address and follows it in this
// client, leaving the session cookie behind. It returns the Location of
// the post-sign-in redirect.
func (c *client) signIn(address string) string {
	c.t.Helper()
	rec := c.post("/signin", url.Values{"address": {address}})
	if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" {
		c.t.Fatalf("POST /signin: %d %s", rec.Code, rec.Header().Get("Location"))
	}
	to, link := c.x.mail.last()
	if to != address || link == "" {
		c.t.Fatalf("no magic link mailed to %s (last to %q)", address, to)
	}
	return c.follow(link)
}

// follow opens an emailed link in this client and returns where it led.
func (c *client) follow(link string) string {
	c.t.Helper()
	u, err := url.Parse(link)
	if err != nil {
		c.t.Fatal(err)
	}
	rec := c.get(u.RequestURI())
	if rec.Code != http.StatusSeeOther {
		c.t.Fatalf("GET %s: %d\n%s", u.RequestURI(), rec.Code, rec.Body.String())
	}
	loc := rec.Header().Get("Location")
	rec = c.get(loc)
	if rec.Code == http.StatusSeeOther {
		return rec.Header().Get("Location")
	}
	return loc
}

Create internal/callstest/signin_test.go:

package callstest

import (
	"net/http"
	"net/url"
	"strings"
	"testing"
)

func TestMemberSignsInAndLandsOnTheList(t *testing.T) {
	x := newHarness(t, "paul@example.com")
	c := x.browser(t)
	if dest := c.signIn("paul@example.com"); dest != "/" {
		t.Fatalf("member landed on %s, want /", dest)
	}
	if rec := c.get("/"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Meetings") {
		t.Fatalf("GET / after sign-in: %d", rec.Code)
	}
}

func TestOutsiderWithoutALinkGetsNoMailAndTheSameAnswer(t *testing.T) {
	x := newHarness(t, "paul@example.com")
	c := x.browser(t)
	rec := c.post("/signin", url.Values{"address": {"stranger@example.com"}})
	if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" {
		t.Fatalf("refusal differs from success: %d %s", rec.Code, rec.Header().Get("Location"))
	}
	if x.mail.count() != 0 {
		t.Fatal("mail was sent to a non-member with no link")
	}
}

func TestSignedOutIsRedirectedToSignin(t *testing.T) {
	x := newHarness(t, "paul@example.com")
	rec := x.browser(t).get("/")
	if rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/signin") {
		t.Fatalf("GET / signed out: %d %s", rec.Code, rec.Header().Get("Location"))
	}
}

func TestNonMemberSessionSeesWelcomeNotTheList(t *testing.T) {
	// Development with no allowlist admits every address at sign-in but
	// makes nobody a member; that is the shape the guest tests rely on.
	x := newHarness(t)
	c := x.browser(t)
	if dest := c.signIn("anyone@example.com"); dest != "/welcome" {
		t.Fatalf("non-member landed on %s, want /welcome", dest)
	}
	if rec := c.get("/"); rec.Code != http.StatusNotFound {
		t.Fatalf("non-member GET /: %d, want 404", rec.Code)
	}
	if rec := c.get("/welcome"); !strings.Contains(rec.Body.String(), "anyone@example.com") {
		t.Fatal("welcome page does not name the address")
	}
}
  • [ ] Step 5: Run the gate and commit

Run: make ci 2>&1 | tail -5 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".

git add -A
git commit -m "auth: magic-link sign-in, persons, continue and welcome

rastrillo/auth behind Docs' gate with Slides' same-answer wrapper,
Authorize admitting allowlist or a live pending row, SignedInPath at
/continue, requirePerson and requireMember middleware, the template
tree with rastrillo's ui partials.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

---

Task 5: Meetings: list, create, link once, replace, end

Files:

  • Create: internal/calls/handlers_meetings.go
  • Modify: internal/calls/templates/index.html, link_once.html, confirm.html, internal/calls/app.go, internal/calls/handlers.go (delete index from it)
  • Test: internal/callstest/meetings_test.go

Interfaces:

  • 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).
  • [ ] Step 1: Handlers

Create internal/calls/handlers_meetings.go:

package meet

import (
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"
)

const maxTitleLen = 200

// linkURL is built from configuration, never from the Host header.
func (a *app) linkURL(token string) string {
	return a.cfg.Origin + "/j/" + token
}

// parseStart reads the optional start time from the form in the
// instance zone (config.go). Returns a message when it is malformed.
func (a *app) parseStart(form url.Values) (*time.Time, string) {
	raw := strings.TrimSpace(form.Get("starts_at"))
	if raw == "" {
		return nil, ""
	}
	t, err := time.ParseInLocation("2006-01-02T15:04", raw, a.cfg.zone)
	if err != nil {
		return nil, "Enter the start as a date and time, or leave it blank to start now."
	}
	u := t.UTC()
	return &u, ""
}

func (a *app) fmtStart(t *time.Time) string {
	if t == nil {
		return ""
	}
	return t.In(a.cfg.zone).Format("Mon 2 Jan 2006, 15:04") + " " + a.cfg.Zone
}

func (a *app) index(w http.ResponseWriter, r *http.Request) {
	a.renderIndex(w, r, "", "")
}

func (a *app) renderIndex(w http.ResponseWriter, r *http.Request, titleErr, startErr string) {
	views, err := a.store.listMeetings(r.Context())
	if err != nil {
		a.logger.Error("calls: list meetings", "err", err)
		ErrorPage(w, r, http.StatusInternalServerError, "")
		return
	}
	type row struct {
		Sqid, Title, Starts string
		Ended, LinkPresent  bool
	}
	var upcoming, ended []row
	for _, v := range views {
		rw := row{Sqid: v.Sqid, Title: v.Title, Starts: a.fmtStart(v.StartsAt), Ended: v.EndedAt != nil, LinkPresent: v.LinkPresent}
		if rw.Ended {
			ended = append(ended, rw)
		} else {
			upcoming = append(upcoming, rw)
		}
	}
	render(w, "index", map[string]any{
		"Upcoming": upcoming, "Ended": ended, "Zone": a.cfg.Zone,
		"TitleError": titleErr, "StartError": startErr,
	})
}

func (a *app) createMeeting(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	title := strings.TrimSpace(r.FormValue("title"))
	var titleErr string
	switch {
	case title == "":
		titleErr = "Give the meeting a title."
	case len(title) > maxTitleLen:
		titleErr = "Keep the title under 200 characters."
	}
	start, startErr := a.parseStart(r.Form)
	if titleErr != "" || startErr != "" {
		w.WriteHeader(http.StatusUnprocessableEntity)
		a.renderIndex(w, r, titleErr, startErr)
		return
	}
	p, _ := currentPerson(r)
	m, token, err := a.store.createMeeting(r.Context(), title, start, actorFor(p))
	if err != nil {
		a.logger.Error("calls: create meeting", "err", err)
		ErrorPage(w, r, http.StatusInternalServerError, "")
		return
	}
	a.renderLinkOnce(w, m, token, false)
}

// renderLinkOnce answers with a page, not a redirect: a token in a
// query string lands in the edge's access logs (Sheets' ruling), and
// the page is the one time the token exists in plaintext.
func (a *app) renderLinkOnce(w http.ResponseWriter, m Meeting, token string, replaced bool) {
	render(w, "link_once", map[string]any{
		"Sqid": m.Sqid, "Title": a.store.title(m), "Starts": a.fmtStart(m.StartsAt),
		"Link": a.linkURL(token), "Replaced": replaced,
	})
}

// meetingForMember loads the meeting in the URL or answers 404.
func (a *app) meetingForMember(w http.ResponseWriter, r *http.Request) (Meeting, bool) {
	m, ok, err := a.store.meetingBySqid(r.Context(), chi.URLParam(r, "sqid"))
	if err != nil {
		a.logger.Error("calls: load meeting", "err", err)
		ErrorPage(w, r, http.StatusInternalServerError, "")
		return Meeting{}, false
	}
	if !ok {
		ErrorPage(w, r, http.StatusNotFound, "")
		return Meeting{}, false
	}
	return m, true
}

func (a *app) confirmReplaceLink(w http.ResponseWriter, r *http.Request) {
	m, ok := a.meetingForMember(w, r)
	if !ok {
		return
	}
	render(w, "confirm", map[string]any{
		"Title":  "Replace the link?",
		"Body":   "Everyone holding the current link for \"" + a.store.title(m) + "\" will get a not-found page. You will see the new link once.",
		"Action": "/meetings/" + m.Sqid + "/links", "Label": "Replace the link",
		"CancelHref": "/", "Sqid": m.Sqid,
	})
}

func (a *app) replaceLink(w http.ResponseWriter, r *http.Request) {
	m, ok := a.meetingForMember(w, r)
	if !ok {
		return
	}
	if m.EndedAt != nil {
		ErrorPage(w, r, http.StatusNotFound, "")
		return
	}
	p, _ := currentPerson(r)
	token, err := a.store.replaceLink(r.Context(), m.ID, actorFor(p))
	if err != nil {
		a.logger.Error("calls: replace link", "err", err)
		ErrorPage(w, r, http.StatusInternalServerError, "")
		return
	}
	a.renderLinkOnce(w, m, token, true)
}

func (a *app) confirmEnd(w http.ResponseWriter, r *http.Request) {
	m, ok := a.meetingForMember(w, r)
	if !ok {
		return
	}
	render(w, "confirm", map[string]any{
		"Title":  "End the meeting?",
		"Body":   "\"" + a.store.title(m) + "\" will close for everyone and its link will stop working.",
		"Action": "/meetings/" + m.Sqid + "/end", "Label": "End the meeting",
		"CancelHref": "/", "Sqid": m.Sqid,
	})
}

func (a *app) endMeeting(w http.ResponseWriter, r *http.Request) {
	m, ok := a.meetingForMember(w, r)
	if !ok {
		return
	}
	p, _ := currentPerson(r)
	if err := a.store.endMeeting(r.Context(), m.ID, actorFor(p)); err != nil {
		a.logger.Error("calls: end meeting", "err", err)
		ErrorPage(w, r, http.StatusInternalServerError, "")
		return
	}
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

Delete internal/calls/handlers.go (its index now lives above).

  • [ ] Step 2: Templates

Replace internal/calls/templates/index.html:

{{define "title"}}Meetings{{end}}
{{define "content"}}
{{template "page-header" dict "Title" "Meetings"}}
<section>
<h2>New meeting</h2>
<form rst-form method="post" action="/meetings">
  {{template "field" dict "ID" "title" "Name" "title" "Label" "Title" "Required" true "Maxlength" "200" "Error" .TitleError "Autofocus" true}}
  {{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}}
  <div rst-form-actions><button type="submit" rst-btn="primary lg">Create meeting</button></div>
</form>
</section>
<section>
<h2>Upcoming</h2>
{{if .Upcoming}}
<div rst-list>
{{range .Upcoming}}
<article>
  <h3><a href="/m/{{.Sqid}}/join">{{.Title}}</a></h3>
  <p>{{if .Starts}}Starts {{.Starts}}{{else}}Starts now{{end}}{{if not .LinkPresent}} · no live link{{end}}</p>
  <p><a href="/meetings/{{.Sqid}}/links/replace">Replace the link</a> · <a href="/meetings/{{.Sqid}}/end">End</a></p>
</article>
{{end}}
</div>
{{else}}
{{template "empty-state" dict "Body" "No meetings yet. Make one above and share its link."}}
{{end}}
</section>
{{if .Ended}}
<details>
<summary>Ended meetings ({{len .Ended}})</summary>
<div rst-list>
{{range .Ended}}<article><h3>{{.Title}}</h3><p>Ended</p></article>{{end}}
</div>
</details>
{{end}}
{{end}}

Replace internal/calls/templates/link_once.html:

{{define "title"}}Your meeting link{{end}}
{{define "content"}}
{{template "back-nav" dict "Href" "/" "Label" "Meetings"}}
{{template "page-header" dict "Title" .Title}}
{{if .Starts}}<p>Starts {{.Starts}}.</p>{{end}}
<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>
<p><input rst-input readonly value="{{.Link}}" size="80" aria-label="Meeting link"></p>
<p><a href="/m/{{.Sqid}}/join" rst-btn="primary">Go to the meeting</a></p>
{{end}}

Replace internal/calls/templates/confirm.html:

{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
{{template "back-nav" dict "Href" .CancelHref "Label" "Meetings"}}
{{template "page-header" dict "Title" .Title}}
<div rst-box>
  <p>{{.Body}}</p>
  {{template "confirm-form" dict "Action" .Action "Label" .Label "Danger" true "CancelHref" .CancelHref "CancelLabel" "Cancel"}}
</div>
{{end}}
  • [ ] Step 3: Routes

In app.go, inside the requireMember group replace r.Get("/", a.index) with:

			r.Get("/", a.index)
			r.Post("/meetings", a.createMeeting)
			r.Get("/meetings/{sqid}/links/replace", a.confirmReplaceLink)
			r.Post("/meetings/{sqid}/links", a.replaceLink)
			r.Get("/meetings/{sqid}/end", a.confirmEnd)
			r.Post("/meetings/{sqid}/end", a.endMeeting)
  • [ ] Step 4: Tests, including the one rule

Create internal/callstest/meetings_test.go:

package callstest

import (
	"bytes"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"testing"
)

var linkInPage = regexp.MustCompile(`http://app\.test/j/([A-Za-z0-9_-]{43})`)

func createMeeting(t *testing.T, c *client, title, starts string) (sqid, token string) {
	t.Helper()
	rec := c.post("/meetings", url.Values{"title": {title}, "starts_at": {starts}})
	if rec.Code != http.StatusOK {
		t.Fatalf("POST /meetings: %d\n%s", rec.Code, rec.Body.String())
	}
	m := linkInPage.FindStringSubmatch(rec.Body.String())
	if m == nil {
		t.Fatalf("no link on the page:\n%s", rec.Body.String())
	}
	s := regexp.MustCompile(`/m/([23456789a-z]{10})/join`).FindStringSubmatch(rec.Body.String())
	return s[1], m[1]
}

func TestCreateShowsTheLinkOnceAndTheListNever(t *testing.T) {
	x := newHarness(t, "paul@example.com")
	c := x.browser(t)
	c.signIn("paul@example.com")
	_, token := createMeeting(t, c, "Board meeting", "")
	rec := c.get("/")
	if strings.Contains(rec.Body.String(), token) {
		t.Fatal("the list shows a link token")
	}
	if !strings.Contains(rec.Body.String(), "Board meeting") {
		t.Fatal("the list does not show the title")
	}
}

func TestCreateRefusesAnEmptyTitleInPlace(t *testing.T) {
	x := newHarness(t, "paul@example.com")
	c := x.browser(t)
	c.signIn("paul@example.com")
	rec := c.post("/meetings", url.Values{"title": {"  "}})
	if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "Give the meeting a title") {
		t.Fatalf("empty title: %d", rec.Code)
	}
}

func TestReplaceLinkNeedsItsConfirmPageAndKillsTheOldLink(t *testing.T) {
	x := newHarness(t, "paul@example.com")
	c := x.browser(t)
	c.signIn("paul@example.com")
	sqid, old := createMeeting(t, c, "x", "")
	if rec := c.get("/meetings/" + sqid + "/links/replace"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Replace the link") {
		t.Fatalf("confirm page: %d", rec.Code)
	}
	rec := c.post("/meetings/"+sqid+"/links", nil)
	if rec.Code != http.StatusOK || !linkInPage.MatchString(rec.Body.String()) {
		t.Fatalf("replace: %d", rec.Code)
	}
	if rec := x.browser(t).get("/j/" + old); rec.Code != http.StatusNotFound {
		t.Fatalf("old link after replace: %d, want 404", rec.Code)
	}
}

func TestEndMeetingNeedsItsConfirmPageAndRefusesJoinsAfter(t *testing.T) {
	x := newHarness(t, "paul@example.com")
	c := x.browser(t)
	c.signIn("paul@example.com")
	sqid, tok := createMeeting(t, c, "x", "")
	if rec := c.get("/meetings/" + sqid + "/end"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "End the meeting") {
		t.Fatalf("confirm page: %d", rec.Code)
	}
	if rec := c.post("/meetings/"+sqid+"/end", nil); rec.Code != http.StatusSeeOther {
		t.Fatalf("end: %d", rec.Code)
	}
	if rec := x.browser(t).get("/j/" + tok); rec.Code != http.StatusNotFound {
		t.Fatalf("link after end: %d, want 404", rec.Code)
	}
	if rec := c.get("/"); !strings.Contains(rec.Body.String(), "Ended meetings (1)") {
		t.Fatal("ended meeting not in the collapsed section")
	}
}

func TestCrossOriginPostIsRefused(t *testing.T) {
	x := newHarness(t, "paul@example.com")
	c := x.browser(t)
	c.signIn("paul@example.com")
	sqid, _ := createMeeting(t, c, "x", "")
	req := httptestPost("/meetings/"+sqid+"/end", nil)
	req.Header.Set("Origin", "https://evil.example")
	rec := c.do(req)
	if rec.Code != http.StatusForbidden {
		t.Fatalf("cross-origin end: %d, want 403", rec.Code)
	}
}

// The one rule (spec §0.1): the database file and its WAL never hold a
// plaintext title.
func TestTheDatabaseNeverHoldsAPlaintextTitle(t *testing.T) {
	x := newHarness(t, "paul@example.com")
	c := x.browser(t)
	c.signIn("paul@example.com")
	const secret = "Quarterly board meeting about the acquisition"
	createMeeting(t, c, secret, "")
	grep := func(when string) {
		for _, suffix := range []string{"", "-wal", "-shm"} {
			b, err := os.ReadFile(x.dbPath + suffix)
			if err != nil {
				continue
			}
			if bytes.Contains(b, []byte(secret)) {
				t.Errorf("%s: title in plaintext in %s", when, filepath.Base(x.dbPath+suffix))
			}
		}
	}
	grep("before checkpoint")
	if _, err := x.sqlDB().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
		t.Fatal(err)
	}
	grep("after checkpoint")
}

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:

func httptestPost(target string, form url.Values) *http.Request {
	req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	return req
}

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.

  • [ ] Step 5: Run the gate and commit

Run: make ci 2>&1 | tail -5 Expected: green.

git add -A
git commit -m "meetings: list, create, link once, replace and end

Sealed titles listed and opened through the store only; the link is
answered on a page, once; replace and end have their confirm pages;
the database and WAL never hold a plaintext title, tested before and
after a checkpoint.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

---

Task 6: Following a link: /j/, the pending-join cookie, the exception, budgets, /continue

Files:

  • Create: internal/calls/joincookie.go, internal/calls/budget.go, internal/calls/handlers_join.go
  • Modify: internal/calls/auth.go (mayRequestLink, continuePending), internal/calls/app.go
  • Test: internal/calls/joincookie_test.go, internal/calls/budget_test.go, internal/callstest/join_test.go

Interfaces:

  • 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.
  • Consumes: store.liveLink, store.writePending, store.latestPending, store.liveLinkByID, store.mintGrant, store.deletePending, standingFor.
  • [ ] Step 1: The cookie

Create internal/calls/joincookie.go:

package meet

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"net/http"
	"strconv"
	"strings"
	"time"
)

const joinCookieName = "calls_join"

// joinCookie tells POST /signin which live link this browser followed.
// It carries the token's hash, not the token, so a cookie leak is not a
// link leak, and it is signed under the instance key so it cannot be
// forged. Its only job is that one hand-off (spec §3.2).
type joinCookie struct {
	TokenHash string
	Expires   time.Time
}

func (a *app) joinMAC(payload string) string {
	mac := hmac.New(sha256.New, []byte("meet/join\x00"+a.cfg.instanceKey))
	mac.Write([]byte(payload))
	return hex.EncodeToString(mac.Sum(nil))
}

func (a *app) setJoinCookie(w http.ResponseWriter, tokenHash string) {
	exp := a.store.now().Add(pendingLife)
	payload := tokenHash + "|" + strconv.FormatInt(exp.Unix(), 10)
	value := base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + a.joinMAC(payload)
	http.SetCookie(w, &http.Cookie{
		Name: joinCookieName, Value: value, Path: "/", HttpOnly: true,
		Secure: strings.HasPrefix(a.cfg.Origin, "https://"), SameSite: http.SameSiteLaxMode,
		MaxAge: int(pendingLife.Seconds()),
	})
}

func (a *app) clearJoinCookie(w http.ResponseWriter) {
	http.SetCookie(w, &http.Cookie{Name: joinCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
}

func (a *app) readJoinCookie(r *http.Request) (joinCookie, bool) {
	ck, err := r.Cookie(joinCookieName)
	if err != nil {
		return joinCookie{}, false
	}
	parts := strings.SplitN(ck.Value, ".", 2)
	if len(parts) != 2 {
		return joinCookie{}, false
	}
	raw, err := base64.RawURLEncoding.DecodeString(parts[0])
	if err != nil {
		return joinCookie{}, false
	}
	payload := string(raw)
	if !hmac.Equal([]byte(a.joinMAC(payload)), []byte(parts[1])) {
		return joinCookie{}, false
	}
	hash, expStr, ok := strings.Cut(payload, "|")
	if !ok || len(hash) != 64 {
		return joinCookie{}, false
	}
	exp, err := strconv.ParseInt(expStr, 10, 64)
	if err != nil {
		return joinCookie{}, false
	}
	c := joinCookie{TokenHash: hash, Expires: time.Unix(exp, 0)}
	if !c.Expires.After(a.store.now()) {
		return joinCookie{}, false
	}
	return c, true
}

Create internal/calls/joincookie_test.go:

package meet

import (
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"
)

func cookieApp(now time.Time) *app {
	return &app{cfg: resolved{instanceKey: "k", Config: Config{Origin: "http://app.test"}}, store: &store{now: func() time.Time { return now }}}
}

func TestJoinCookieRoundTripsAndRejectsTampering(t *testing.T) {
	now := time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC)
	a := cookieApp(now)
	rec := httptest.NewRecorder()
	hash := strings.Repeat("ab", 32)
	a.setJoinCookie(rec, hash)
	ck := rec.Result().Cookies()[0]
	req := httptest.NewRequest(http.MethodGet, "/", nil)
	req.AddCookie(ck)
	got, ok := a.readJoinCookie(req)
	if !ok || got.TokenHash != hash {
		t.Fatalf("round trip failed: %+v %v", got, ok)
	}
	bad := *ck
	bad.Value = strings.Replace(bad.Value, ".", "x.", 1)
	req2 := httptest.NewRequest(http.MethodGet, "/", nil)
	req2.AddCookie(&bad)
	if _, ok := a.readJoinCookie(req2); ok {
		t.Fatal("tampered cookie accepted")
	}
	later := cookieApp(now.Add(pendingLife + time.Second))
	if _, ok := later.readJoinCookie(req); ok {
		t.Fatal("expired cookie accepted")
	}
}
  • [ ] Step 2: The budget

Create internal/calls/budget.go:

package meet

import (
	"sync"
	"time"
)

// budget is a sliding-window counter: at most limit events per key per
// window. It counts attempts, refused or not, because the point is to
// keep outsider traffic away from rastrillo's shared limiter (spec
// §3.2), and an attempt that reaches Begin is what consumes that.
type budget struct {
	mu     sync.Mutex
	limit  int
	window time.Duration
	hits   map[string][]time.Time
}

func newBudget(limit int, window time.Duration) *budget {
	return &budget{limit: limit, window: window, hits: map[string][]time.Time{}}
}

// allow records an attempt and reports whether it was within budget.
func (b *budget) allow(key string, now time.Time) bool {
	b.mu.Lock()
	defer b.mu.Unlock()
	cut := now.Add(-b.window)
	kept := b.hits[key][:0]
	for _, t := range b.hits[key] {
		if t.After(cut) {
			kept = append(kept, t)
		}
	}
	if len(kept) >= b.limit {
		b.hits[key] = kept
		return false
	}
	b.hits[key] = append(kept, now)
	return true
}

Create internal/calls/budget_test.go:

package meet

import (
	"testing"
	"time"
)

func TestBudgetSlidesItsWindow(t *testing.T) {
	b := newBudget(2, time.Minute)
	now := time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC)
	if !b.allow("k", now) || !b.allow("k", now) {
		t.Fatal("first two refused")
	}
	if b.allow("k", now) {
		t.Fatal("third allowed")
	}
	if !b.allow("other", now) {
		t.Fatal("other key refused")
	}
	if !b.allow("k", now.Add(61*time.Second)) {
		t.Fatal("refused after the window slid")
	}
}
  • [ ] Step 3: The exception in the gate and the pending branch of /continue

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).

Replace mayRequestLink:

// mayRequestLink decides whether Begin is reached (ruling 12). A member
// address always may. Any other address may only when the browser
// carries a valid pending-join cookie for a live link, within two
// budgets counted before Begin: five attempts per link and ten across
// all links per fifteen minutes. When it may, a pending_joins row is
// written first, for the member too (it carries their destination).
func (a *app) mayRequestLink(w http.ResponseWriter, r *http.Request, address string) bool {
	address = normalizeAddress(address)
	member := a.cfg.allow.allows(address)
	ck, hasCookie := a.readJoinCookie(r)
	if !hasCookie {
		return member
	}
	link, _, live, err := a.store.liveLinkByHash(r.Context(), ck.TokenHash)
	if err != nil {
		a.logger.Error("calls: link for cookie", "err", err)
		return member
	}
	if !live {
		return member
	}
	if !member {
		now := a.store.now()
		if !a.outsiderBudget.allow("all", now) || !a.linkBudget.allow(ck.TokenHash, now) {
			a.logger.Warn("calls: outsider sign-in attempts over budget", "link", ck.TokenHash[:8])
			return false
		}
	}
	if address == "" {
		return member
	}
	if err := a.store.writePending(r.Context(), address, link); err != nil {
		a.logger.Error("calls: write pending join", "err", err)
		return member
	}
	return true
}

Add to store.go:

func (s *store) liveLinkByHash(ctx context.Context, hash string) (MeetingLink, Meeting, bool, error) {
	var l MeetingLink
	err := s.g.WithContext(ctx).Where("token_hash = ?", hash).Take(&l).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return MeetingLink{}, Meeting{}, false, nil
	}
	if err != nil {
		return MeetingLink{}, Meeting{}, false, err
	}
	return s.liveLinkRow(ctx, l)
}

and make liveLink call liveLinkByHash(ctx, hashLinkToken(token)) after its empty-token check.

Replace continuePending:

// continuePending is /continue's first branch (spec §3.2): the most
// recent unexpired pending row for this person, re-checked against its
// link and meeting in one transaction with the grant, then deleted.
func (a *app) continuePending(w http.ResponseWriter, r *http.Request, p Person) (string, bool) {
	a.clearJoinCookie(w)
	pend, ok, err := a.store.latestPending(r.Context(), p.Email)
	if err != nil {
		a.logger.Error("calls: latest pending", "err", err)
		return "", false
	}
	if !ok {
		return "", false
	}
	dest, err := a.store.consumePending(r.Context(), p, pend, a.isMember(p))
	if err != nil {
		a.logger.Error("calls: consume pending", "err", err)
		return "", false
	}
	return dest, dest != ""
}

Add to store.go:

// consumePending re-checks the pending row's link and meeting as
// GET /j/{token} would, mints the grant for a non-member, and deletes
// every pending row for the address, all in one transaction. It returns
// the holding-screen path, or "" when the re-check failed.
func (s *store) consumePending(ctx context.Context, p Person, pend PendingJoin, member bool) (string, error) {
	dest := ""
	err := s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		ts := &store{g: tx, sealer: s.sealer, now: s.now}
		if err := ts.deletePending(ctx, p.Email); err != nil {
			return err
		}
		if !pend.ExpiresAt.After(s.now()) {
			return nil
		}
		_, m, live, err := ts.liveLinkByID(ctx, pend.LinkID)
		if err != nil || !live {
			return err
		}
		if !member {
			if _, err := ts.mintGrant(ctx, m, p.ID); err != nil {
				return err
			}
		}
		dest = "/m/" + m.Sqid + "/join"
		return nil
	})
	return dest, err
}
  • [ ] Step 4: GET /j/{token}

Create internal/calls/handlers_join.go:

package meet

import (
	"net/http"

	"github.com/go-chi/chi/v5"

	rauth "amadan.net/rastrillo/rastrillo/auth"
)

// followLink is GET /j/{token}: one 404 for unknown, revoked, ended or
// expired; otherwise one of three outcomes (spec §3.2). It is mounted
// outside the session group because the person is often not signed in.
func (a *app) followLink(w http.ResponseWriter, r *http.Request) {
	link, m, live, err := a.store.liveLink(r.Context(), chi.URLParam(r, "token"))
	if err != nil {
		a.logger.Error("calls: follow link", "err", err)
		ErrorPage(w, r, http.StatusInternalServerError, "")
		return
	}
	if !live {
		ErrorPage(w, r, http.StatusNotFound, "")
		return
	}
	id, signedIn := a.auth.SessionFrom(r)
	if !signedIn {
		a.setJoinCookie(w, link.TokenHash)
		http.Redirect(w, r, signinPath, http.StatusSeeOther)
		return
	}
	p, err := a.store.ensurePerson(r.Context(), id.Address)
	if err != nil {
		a.logger.Error("calls: ensure person", "err", err)
		ErrorPage(w, r, http.StatusInternalServerError, "")
		return
	}
	if !a.isMember(p) {
		if _, err := a.store.mintGrant(r.Context(), m, p.ID); err != nil {
			a.logger.Error("calls: mint grant", "err", err)
			ErrorPage(w, r, http.StatusInternalServerError, "")
			return
		}
	}
	http.Redirect(w, r, "/m/"+m.Sqid+"/join", http.StatusSeeOther)
}

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).

  • [ ] Step 5: Tests

Create internal/callstest/join_test.go:

package callstest

import (
	"net/http"
	"net/url"
	"strings"
	"testing"
	"time"
)

func memberWithMeeting(t *testing.T) (*harness, string, string) {
	t.Helper()
	x := newHarness(t, "paul@example.com")
	c := x.browser(t)
	c.signIn("paul@example.com")
	sqid, tok := createMeeting(t, c, "Board meeting", "")
	return x, sqid, tok
}

func TestUnknownRevokedAndEndedLinksAreOne404(t *testing.T) {
	x, _, _ := memberWithMeeting(t)
	rec := x.browser(t).get("/j/" + strings.Repeat("A", 43))
	if rec.Code != http.StatusNotFound {
		t.Fatalf("unknown: %d", rec.Code)
	}
}

func TestSignedOutOutsiderSignsInThroughTheLinkInTheSameBrowser(t *testing.T) {
	x, sqid, tok := memberWithMeeting(t)
	g := x.browser(t)
	rec := g.get("/j/" + tok)
	if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin" {
		t.Fatalf("follow signed out: %d %s", rec.Code, rec.Header().Get("Location"))
	}
	if _, ok := g.cookies["calls_join"]; !ok {
		t.Fatal("no pending-join cookie")
	}
	if dest := g.signIn("guest@example.com"); dest != "/m/"+sqid+"/join" {
		t.Fatalf("outsider landed on %s", dest)
	}
	if rec := g.get("/m/" + sqid + "/join"); rec.Code != http.StatusOK {
		t.Fatalf("holding page as guest: %d", rec.Code)
	}
	if rec := g.get("/"); rec.Code != http.StatusNotFound {
		t.Fatal("guest can see the meeting list")
	}
}

func TestEmailedLinkWorksInAnotherBrowser(t *testing.T) {
	x, sqid, tok := memberWithMeeting(t)
	phone := x.browser(t)
	phone.get("/j/" + tok)
	rec := phone.post("/signin", url.Values{"address": {"guest@example.com"}})
	if rec.Header().Get("Location") != "/signin?sent=1" {
		t.Fatal("outsider with cookie refused")
	}
	_, link := x.mail.last()
	laptop := x.browser(t) // no cookie here
	if dest := laptop.follow(link); dest != "/m/"+sqid+"/join" {
		t.Fatalf("other-browser click landed on %s", dest)
	}
}

func TestLinkReplacedBetweenMailAndClickAdmitsNobody(t *testing.T) {
	x, sqid, tok := memberWithMeeting(t)
	g := x.browser(t)
	g.get("/j/" + tok)
	g.post("/signin", url.Values{"address": {"guest@example.com"}})
	_, link := x.mail.last()
	member := x.browser(t)
	member.signIn("paul@example.com")
	member.post("/meetings/"+sqid+"/links", nil)
	u, _ := url.Parse(link)
	rec := g.get(u.RequestURI())
	if rec.Code != http.StatusForbidden {
		t.Fatalf("outsider's stale click: %d, want 403 from Authorize", rec.Code)
	}
	if rec := g.get("/m/" + sqid + "/join"); rec.Code == http.StatusOK {
		t.Fatal("a grant was minted through a replaced link")
	}
}

func TestSignedOutMemberFollowsALinkAndLandsOnItsHoldingPage(t *testing.T) {
	x, sqid, tok := memberWithMeeting(t)
	m := x.browser(t)
	m.get("/j/" + tok)
	if dest := m.signIn("paul@example.com"); dest != "/m/"+sqid+"/join" {
		t.Fatalf("member landed on %s", dest)
	}
}

func TestOutsiderWithoutCookieStillGetsNoMail(t *testing.T) {
	x, _, _ := memberWithMeeting(t)
	before := x.mail.count()
	x.browser(t).post("/signin", url.Values{"address": {"guest@example.com"}})
	if x.mail.count() != before {
		t.Fatal("mail sent to an outsider with no link")
	}
}

func TestOutsiderBudgetsStopMailAtFivePerLinkAndTenOverall(t *testing.T) {
	x, _, tok := memberWithMeeting(t)
	before := x.mail.count()
	for i := 0; i < 7; i++ {
		g := x.browser(t)
		g.get("/j/" + tok)
		g.post("/signin", url.Values{"address": {"g" + string(rune('a'+i)) + "@example.com"}})
	}
	if got := x.mail.count() - before; got != 5 {
		t.Fatalf("mails through one link: %d, want 5", got)
	}
	x.advance(16 * time.Minute)
	c := x.browser(t)
	c.signIn("paul@example.com")
	var toks []string
	for i := 0; i < 3; i++ {
		_, t2 := createMeeting(t, c, "m", "")
		toks = append(toks, t2)
	}
	before = x.mail.count()
	for i := 0; i < 12; i++ {
		g := x.browser(t)
		g.get("/j/" + toks[i%3])
		g.post("/signin", url.Values{"address": {"h" + string(rune('a'+i)) + "@example.com"}})
	}
	if got := x.mail.count() - before; got != 10 {
		t.Fatalf("mails across links: %d, want 10", got)
	}
}

func TestExpiredGrantIsRefusedEntry(t *testing.T) {
	x, sqid, tok := memberWithMeeting(t)
	g := x.browser(t)
	g.get("/j/" + tok)
	g.signIn("guest@example.com")
	x.advance(2*time.Hour + time.Minute)
	if rec := g.get("/m/" + sqid + "/join"); rec.Code != http.StatusNotFound {
		t.Fatalf("expired guest at the holding page: %d, want 404", rec.Code)
	}
}

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.

  • [ ] Step 6: Run the gate and commit (after Task 7's route exists, rerun)

Run: make ci 2>&1 | tail -5

git add -A
git commit -m "join: /j/, the pending-join cookie, ruling 12 and /continue

Following a live link sets a signed cookie and redirects to sign-in;
the gate lets a non-member address through only with that cookie,
within two budgets, and writes the pending row before Begin so the
emailed link admits it anywhere; /continue re-checks the link and
mints the grant in one transaction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

---

Task 7: The holding page

Files:

  • Modify: internal/calls/handlers_join.go, internal/calls/templates/join.html, internal/calls/app.go
  • Test: internal/callstest/holding_test.go

Interfaces:

  • 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}.
  • [ ] Step 1: Handler

Append to handlers_join.go:

// holding is GET /m/{sqid}/join, authorized by standing (spec §2.5). In
// this slice it renders the server-side half of the holding screen and
// says the room is the next slice.
func (a *app) holding(w http.ResponseWriter, r *http.Request) {
	m, ok, err := a.store.meetingBySqid(r.Context(), chi.URLParam(r, "sqid"))
	if err != nil {
		a.logger.Error("calls: load meeting", "err", err)
		ErrorPage(w, r, http.StatusInternalServerError, "")
		return
	}
	if !ok || m.EndedAt != nil {
		ErrorPage(w, r, http.StatusNotFound, "")
		return
	}
	p, _ := currentPerson(r)
	st, err := a.standingFor(r.Context(), p, m)
	if err != nil {
		a.logger.Error("calls: standing", "err", err)
		ErrorPage(w, r, http.StatusInternalServerError, "")
		return
	}
	if st == StandingNone {
		ErrorPage(w, r, http.StatusNotFound, "")
		return
	}
	render(w, "join", map[string]any{
		"Sqid": m.Sqid, "Title": a.store.title(m), "Starts": a.fmtStart(m.StartsAt),
		"Name": p.Name, "Standing": st.String(), "IsMember": st == StandingMember,
		"Floor": !a.cfg.RelayConfigured,
	})
}

Route, inside the requirePerson group but outside requireMember: r.Get("/m/{sqid}/join", a.holding).

  • [ ] Step 2: Template

Replace internal/calls/templates/join.html:

{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
{{if .IsMember}}{{template "back-nav" dict "Href" "/" "Label" "Meetings"}}{{end}}
{{template "page-header" dict "Title" .Title}}
{{if .Starts}}<p>Starts {{.Starts}}.</p>{{end}}
<p>You are {{.Name}}, joining as a {{.Standing}}.</p>
{{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}}
<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>
{{if .IsMember}}<p><a href="/meetings/{{.Sqid}}/end">End the meeting</a></p>{{end}}
{{end}}
  • [ ] Step 3: Tests

Create internal/callstest/holding_test.go:

package callstest

import (
	"net/http"
	"strings"
	"testing"
)

func TestHoldingPageShowsTitleStandingAndFloor(t *testing.T) {
	x, sqid, _ := memberWithMeeting(t)
	c := x.browser(t)
	c.signIn("paul@example.com")
	rec := c.get("/m/" + sqid + "/join")
	body := rec.Body.String()
	for _, want := range []string{"Board meeting", "joining as a member", "no relay yet", "End the meeting"} {
		if !strings.Contains(body, want) {
			t.Errorf("holding page lacks %q", want)
		}
	}
}

func TestHoldingPageIs404ForNoStandingAndForEndedMeetings(t *testing.T) {
	x, sqid, _ := memberWithMeeting(t)
	stranger := x.browser(t)
	stranger.signIn("nobody@example.com") // admitted in development, no standing
	if rec := stranger.get("/m/" + sqid + "/join"); rec.Code != http.StatusNotFound {
		t.Fatalf("no standing: %d", rec.Code)
	}
	m := x.browser(t)
	m.signIn("paul@example.com")
	m.post("/meetings/"+sqid+"/end", nil)
	if rec := m.get("/m/" + sqid + "/join"); rec.Code != http.StatusNotFound {
		t.Fatalf("ended: %d", rec.Code)
	}
}

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:

func TestHoldingPageIs404ForNoStanding(t *testing.T) {
	x := newHarness(t) // no team: everyone signs in, nobody is a member
	c := x.browser(t)
	c.signIn("nobody@example.com")
	if rec := c.get("/m/2345678abc/join"); rec.Code != http.StatusNotFound {
		t.Fatalf("no standing: %d", rec.Code)
	}
}

and keep the ended-meeting half in its own test using memberWithMeeting.

  • [ ] Step 4: Run the whole gate, including Task 6's tests, and commit

Run: make ci 2>&1 | tail -5 Expected: green, every test in join_test.go, holding_test.go, meetings_test.go, signin_test.go.

git add -A
git commit -m "holding page: standing-gated, scriptless, says the room is next

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

---

Task 8: Copy list, AGENTS.md notes, branch record

Files:

  • Create: docs/superpowers/copy/2026-09-08-slice-1-strings.md
  • Modify: AGENTS.md (one paragraph under Layout naming the packages this slice added and the store-only sealing rule)
  • [ ] Step 1: Collect every string

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".

  • [ ] Step 2: AGENTS.md

Append under ## Layout:

- `internal/calls/seal/` — the app's only encryption surface. A title
  is sealed by the store and opened by the store; no handler holds a
  plaintext title it did not get from `store.title`. An outsider's
  address is stored only as a keyed fingerprint.
- `internal/calls/store.go` — every database access. `standing.go` is
  the entry authority (spec §2.3), computed per request, never stored.
- Sign-in: `auth.go` is Docs' shape with the pending-join exception of
  spec §3.2 and Slides' same-answer wrapper. Every answer from
  `POST /signin` is `303 /signin?sent=1`.
  • [ ] Step 3: Final gate, push, record

Run: make ci 2>&1 | tail -3 && make release VERSION=v0.0.0-slice1-check && ls releases/ Expected: green, and a stamped binary in releases/ (delete it afterwards; releases/ is gitignored).

git add -A
git commit -m "docs: slice 1 strings for copy review; AGENTS.md notes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
git push origin slice/1-meetings-links-signin

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.

---

Self-review

Spec coverage for slice 1 (§12 item 1): persons, meetings, meetinglinks, guestgrants, pendingjoins (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, `pendingjoins, 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).

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.

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.