oficina / meet Public
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; frameworkamadan.net/rastrillo/rastrillo v0.26.0(not the oldgithub.com/carlosframeworkpath Docs and Calendar still use). CGO_ENABLED=0everywhere;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
.Selectallowlists (AGENTS.md). - Destructive actions have their own confirm page rendered with
ui/confirm-formand a POST behindcsrf.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-1withGOCACHE=$TMPDIR/gocacheexported (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); constantsseal.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
Appto takeConfig, andmain.goto 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;; \
esacRemove 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), regenerateschema.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)withtype 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)— onefalsefor 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 intwithStandingNone, 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).requirePersonandcurrentPerson(r) (Person, bool);ErrorPage(w, r, status, ref);render(w, name, data)withuifunctions available; harnesssignIn(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, butAuthorizeis 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(deleteindexfrom 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);budgetwithnewBudget(limit int, window time.Duration) *budgetand(*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}/joinunderrequirePerson, 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-signinThen, 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.