oficina / calendar Public

app: the model set, the seal, and what a time means

Ruling 1, and everything follows from it: an event's record is the
wall-clock time the person typed plus the IANA zone they meant it in.
The UTC columns are DERIVED, an index for range queries and nothing
more. Store the instant as truth and a tzdata update moves somebody's
standup by an hour with nobody able to say what was meant; store
"09:00 Europe/Dublin" and only the index moves.

DST has no correct answer and Go does not promise one, so clock.go
documents what actually happens and clock_test.go pins it to real unix
numbers. The two cases resolve differently, which is why both are
tested: the spring gap takes the pre-transition offset and moves an
event forward, the autumn fold takes the second of the two occurrences.
An earlier version of that comment claimed one rule covered both — true
of the gap, false of the fold. The probe that produced the numbers is
what corrected it.

Days are not 24 hours. DayBounds uses AddDate, and a test asserts 23,
24 and 25 on the three days that matter.

All-day events are dates, not instants at UTC midnight — the bug that
shows a birthday on the wrong day for everybody west of Greenwich.
Every range is half-open, everywhere, so nothing double-counts a
boundary.

Sealing is lifted from sheets with a new label. A copy rather than a
shared module on purpose: the label is part of the ciphertext's
meaning, so two apps sharing it would have to agree on a prefix scheme
forever. A third app is the trigger to extract it.

The migration test earns its place. The generator wrote ALTER TABLE ...
ADD ... NOT NULL with no DEFAULT, which SQLite documents as a refusal,
and every existing test would have missed it because they all start from
an empty database. It now writes and reads a row of every table instead
of trusting that the tables exist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QG3FX6qzynri8yTgMUm7q7
Paul Campbell pushed by paul@keymail.dev 9efaeb3e89262c070db3e1506036f889ce47a2e7 parent 21558fd
16 files changed, +1157 −27
  • cmd/calendar/main.go +1 −0
  • go.mod +3 −2
  • go.sum +9 −0
  • internal/calendar/app.go +16 −2
  • internal/calendar/clock.go +186 −0
  • internal/calendar/config.go +38 −0
  • internal/calendar/migrations/0002_alter_people.sql +58 −0
  • internal/calendar/migrations/schema.sql +51 −1
  • internal/calendar/models.go +139 −13
  • internal/calendar/models_event.go +229 −0
  • internal/calendar/person.go +58 −0
  • internal/calendar/seal/seal.go +140 −0
  • internal/calendar/store.go +20 −9
  • internal/calendar/testing.go +12 −0
  • internal/calendartest/clock_test.go +128 −0
  • internal/calendartest/migrate_test.go +69 −0
diff --git a/cmd/calendar/main.go b/cmd/calendar/main.go
index 85af4b4..4ab068b 100644
--- a/cmd/calendar/main.go
+++ b/cmd/calendar/main.go
@@ -61,6 +61,7 @@ func main() {
Env: chooseEnv(envFlag, os.Getenv("CALENDAR_ENV")),
Origin: origin,
InstanceKey: os.Getenv("CALENDAR_INSTANCE_KEY"),
+ DEK: os.Getenv("CALENDAR_DEK"),
Mailer: mailer,
AllowedAddresses: os.Getenv("CALENDAR_ALLOWED_ADDRESSES"),
Logger: logger,
diff --git a/go.mod b/go.mod
index 440a7a7..776316c 100644
--- a/go.mod
+++ b/go.mod
@@ -25,8 +25,9 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- golang.org/x/sys v0.46.0 // indirect
- golang.org/x/text v0.20.0 // indirect
+ golang.org/x/crypto v0.55.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.41.0 // indirect
gorm.io/plugin/dbresolver v1.6.2 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
diff --git a/go.sum b/go.sum
index 9983f7d..9e6296c 100644
--- a/go.sum
+++ b/go.sum
@@ -46,17 +46,26 @@ github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhA
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
+golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
+golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
diff --git a/internal/calendar/app.go b/internal/calendar/app.go
index 99aed35..14ff8fd 100644
--- a/internal/calendar/app.go
+++ b/internal/calendar/app.go
@@ -12,6 +12,8 @@ import (
"github.com/carlosframework/rastrillo/csrf"
"github.com/carlosframework/rastrillo/db"
"github.com/carlosframework/rastrillo/migrate"
+
+ "amadan.net/oficina/calendar/internal/calendar/seal"
)
// App is the whole running app: the database it owns, the router it
@@ -68,6 +70,15 @@ func New(cfg Config) (*App, error) {
return nil, err
}
+ dek, err := resolveDEK(cfg)
+ if err != nil {
+ return nil, err
+ }
+ sealer, err := seal.New(dek)
+ if err != nil {
+ return nil, fmt.Errorf("calendar: %w", err)
+ }
+
d, err := db.Open(cfg.DBPath, logger)
if err != nil {
return nil, fmt.Errorf("calendar: open database: %w", err)
@@ -106,7 +117,7 @@ func New(cfg Config) (*App, error) {
return nil, fmt.Errorf("calendar: wire sign-in: %w", err)
}
- store := NewStore(d.G, logger)
+ store := NewStore(d.G, sealer, logger)
a := &App{
cfg: cfg,
db: d,
@@ -135,9 +146,12 @@ func New(cfg Config) (*App, error) {
// high-contrast theme should not have to sign in to get one.
r.Post("/mode", h.mode)
- // Signed-in.
+ // Signed-in. requirePerson runs after RequireSession and turns the
+ // verified address into this app's own person row, so every handler
+ // below can take an id rather than an address.
r.Group(func(r chi.Router) {
r.Use(authPlugin.RequireSession)
+ r.Use(a.requirePerson)
r.Get("/", h.index)
})
diff --git a/internal/calendar/clock.go b/internal/calendar/clock.go
new file mode 100644
index 0000000..281b298
--- /dev/null
+++ b/internal/calendar/clock.go
@@ -0,0 +1,186 @@
+package calendar
+
+import (
+ "fmt"
+ "strings"
+ "time"
+)
+
+// The two layouts every stored local time uses. Fixed, not configurable:
+// they are a storage format, not a display one, and every display format
+// is a catalog key resolved at render time.
+const (
+ // LayoutDate is an all-day event's date. No zone, no instant: the
+ // 2nd of September is the 2nd of September wherever you read it.
+ LayoutDate = "2006-01-02"
+
+ // LayoutLocal is a timed event's wall-clock time, in its own zone.
+ // Seconds are deliberately absent — a calendar that stores them
+ // invites a UI that shows them.
+ LayoutLocal = "2006-01-02T15:04"
+)
+
+// Ruling 1: store what the person said.
+//
+// An event's record is the wall-clock time they typed plus the IANA
+// zone they meant it in. The UTC instant is DERIVED, an index for range
+// queries and nothing more. This is not a preference — it is the only
+// storage that survives a tzdata update.
+//
+// The failure it prevents: somebody schedules a 09:00 standup in
+// Europe/Dublin for next March, a government moves the DST boundary,
+// and the release ships a new tzdata. Store the instant as truth and
+// the meeting is now at 08:00 or 10:00 and nobody can say what was
+// meant. Store "09:00 Europe/Dublin" and it is still 09:00; only the
+// derived index moves, and the boot sweep moves it.
+//
+// The gate is TestReDeriveSurvivesATzdataBump: it changes the stamp in
+// meta, boots, and asserts every UTC column was recomputed from the
+// local columns rather than carried forward.
+
+// zoneCache memoises time.LoadLocation. Loading a zone reads and parses
+// a file from the embedded tzdata; a month view of a team's calendar
+// asks for the same handful of zones hundreds of times, and the parse
+// is pure overhead after the first.
+//
+// Unbounded on purpose: the key space is the IANA database, about six
+// hundred names, and every one is small. A cache that could evict would
+// need a lock discipline this does not.
+var zoneCache = func() func(string) (*time.Location, error) {
+ type entry struct {
+ loc *time.Location
+ err error
+ }
+ cache := map[string]entry{}
+ return func(name string) (*time.Location, error) {
+ if e, ok := cache[name]; ok {
+ return e.loc, e.err
+ }
+ loc, err := time.LoadLocation(name)
+ cache[name] = entry{loc: loc, err: err}
+ return loc, err
+ }
+}()
+
+// Zone resolves an IANA name to a location.
+//
+// An empty name is UTC, not the server's local zone. A server's zone is
+// an accident of where it was provisioned, and inheriting it would
+// silently shift every unzoned event's day boundaries the first time
+// the fleet moved between regions.
+func Zone(name string) (*time.Location, error) {
+ name = strings.TrimSpace(name)
+ if name == "" || name == "UTC" {
+ return time.UTC, nil
+ }
+ loc, err := zoneCache(name)
+ if err != nil {
+ return nil, fmt.Errorf("calendar: unknown time zone %q: %w", name, err)
+ }
+ return loc, nil
+}
+
+// ParseLocal turns a stored local string into a time in loc.
+//
+// It accepts both layouts and reports which it read, because an all-day
+// event and a timed one are different kinds and the caller must not
+// have to guess from the string's length.
+func ParseLocal(s string, loc *time.Location) (t time.Time, allDay bool, err error) {
+ s = strings.TrimSpace(s)
+ if t, err = time.ParseInLocation(LayoutLocal, s, loc); err == nil {
+ return t, false, nil
+ }
+ if t, err = time.ParseInLocation(LayoutDate, s, loc); err == nil {
+ return t, true, nil
+ }
+ return time.Time{}, false, fmt.Errorf("calendar: %q is neither %q nor %q", s, LayoutLocal, LayoutDate)
+}
+
+// ToUTC derives the instant a local time in zone names.
+//
+// DST is where this earns its keep. There is no obviously right answer
+// for a wall-clock time that happens twice or not at all, and Go's
+// documentation says so in as many words: for those two cases "the
+// choice of time zone, and therefore the time, is not guaranteed". So
+// the rule here is not "this is correct", it is "this is what happens,
+// it is written down, and it cannot change without a test failing".
+//
+// Measured against Europe/Dublin 2026 (TestDSTGapAndFold pins both):
+//
+// - The GAP, the hour that does not exist on the spring-forward
+// night. Local 01:00 jumps to 02:00 on 29 March. "01:30" resolves
+// with the offset in force BEFORE the transition and lands at
+// 01:30 UTC, which reads back as 02:30 local — it moves FORWARD.
+// That is the half worth relying on: an event in the gap never
+// silently happens earlier than it was written.
+// - The FOLD, the hour that happens twice on 25 October. "01:30" is
+// ambiguous, and Go returns the SECOND of the two — 01:30 UTC at
+// the post-transition offset, not 00:30 UTC at the pre-transition
+// one.
+//
+// Note that those two are not one rule. An earlier version of this
+// comment claimed both took the pre-transition offset, which is true of
+// the gap and false of the fold; the probe that produced the numbers
+// above is what corrected it. Do not reason about the fold from the
+// gap's behaviour.
+func ToUTC(local string, zone string, allDay bool) (int64, error) {
+ loc, err := Zone(zone)
+ if err != nil {
+ return 0, err
+ }
+ // An all-day event is a date, and its instant is midnight at the
+ // start of that date in the viewing zone. It is an index for range
+ // queries, never a time anybody is shown.
+ if allDay {
+ t, err := time.ParseInLocation(LayoutDate, strings.TrimSpace(local), loc)
+ if err != nil {
+ return 0, fmt.Errorf("calendar: all-day time %q: %w", local, err)
+ }
+ return t.Unix(), nil
+ }
+ t, _, err := ParseLocal(local, loc)
+ if err != nil {
+ return 0, err
+ }
+ return t.Unix(), nil
+}
+
+// FormatLocal renders a time back into the stored form.
+func FormatLocal(t time.Time, allDay bool) string {
+ if allDay {
+ return t.Format(LayoutDate)
+ }
+ return t.Format(LayoutLocal)
+}
+
+// InZone converts a UTC instant into a wall clock in zone. This is the
+// one conversion a view does: everything is stored and queried in UTC,
+// and rendered in the viewer's own zone.
+func InZone(unix int64, zone string) (time.Time, error) {
+ loc, err := Zone(zone)
+ if err != nil {
+ return time.Time{}, err
+ }
+ return time.Unix(unix, 0).In(loc), nil
+}
+
+// DayBounds returns the half-open UTC range [start, end) covering the
+// local day containing t in zone.
+//
+// Half-open, always, everywhere in this app. An inclusive end is how a
+// calendar ends up showing a midnight event on both days, and how
+// "events between Monday and Tuesday" double-counts the boundary.
+func DayBounds(day time.Time, zone string) (startUTC, endUTC int64, err error) {
+ loc, err := Zone(zone)
+ if err != nil {
+ return 0, 0, err
+ }
+ d := day.In(loc)
+ start := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc)
+ // AddDate, not Add(24h). A day is not always 24 hours long: on a
+ // DST boundary it is 23 or 25, and adding a fixed duration would
+ // put the end of the day an hour into the next one, or an hour
+ // short of it.
+ end := start.AddDate(0, 0, 1)
+ return start.Unix(), end.Unix(), nil
+}
diff --git a/internal/calendar/config.go b/internal/calendar/config.go
index c0ed75a..11e0c26 100644
--- a/internal/calendar/config.go
+++ b/internal/calendar/config.go
@@ -1,6 +1,7 @@
package calendar
import (
+ "encoding/base64"
"fmt"
"log/slog"
"net/url"
@@ -51,6 +52,15 @@ type Config struct {
// is not a reminder.
Mailer mail.Sender
+ // DEK is the base64 data encryption key, exactly 32 bytes decoded
+ // ($CALENDAR_DEK, set with `carlos secrets set`). It seals every
+ // title, place and note, and it is never written to the database it
+ // decrypts. Empty is refused outside development: the one rule is
+ // not optional, and an instance that booted without a key would
+ // write plaintext into a file the platform replicates to object
+ // storage.
+ DEK string
+
// AllowedAddresses is the comma-separated admission allowlist
// ($CALENDAR_ALLOWED_ADDRESSES). Empty is refused outside
// development rather than defaulting to "admit nobody": an
@@ -79,6 +89,34 @@ func resolveOrigin(cfg Config) (string, error) {
return trimmed, nil
}
+// devDEK is resolveDEK's development-only fallback: a fixed, public,
+// all-zero key. Not a secret, and spelled out here rather than left
+// implicit so that nothing can mistake it for one. It seals nothing
+// outside a throwaway development database.
+var devDEK = make([]byte, 32)
+
+// resolveDEK applies Config.DEK's fail-closed rule. The length check is
+// exact rather than a minimum: AES-256 wants 32 bytes, and a shorter
+// value that got padded somewhere would produce ciphertext that looks
+// entirely fine and is not.
+func resolveDEK(cfg Config) ([]byte, error) {
+ raw := strings.TrimSpace(cfg.DEK)
+ if raw == "" {
+ if cfg.Env == "development" {
+ return devDEK, nil
+ }
+ return nil, fmt.Errorf("calendar: Config.DEK is empty and Env is %q, not %q — refusing to start: an event's contents are sealed at rest, and a boot without a key would write plaintext titles into a file the platform replicates to object storage; set $CALENDAR_DEK to 32 base64 bytes with `carlos secrets set`", cfg.Env, "development")
+ }
+ key, err := base64.StdEncoding.DecodeString(raw)
+ if err != nil {
+ return nil, fmt.Errorf("calendar: $CALENDAR_DEK is not valid base64: %w", err)
+ }
+ if len(key) != 32 {
+ return nil, fmt.Errorf("calendar: $CALENDAR_DEK decodes to %d bytes, want exactly 32", len(key))
+ }
+ return key, nil
+}
+
// validateOrigin rejects anything that is not a bare scheme://host[:port].
// A path, query or fragment on an origin is the tell that someone pasted
// a page URL into the setting; carried into a magic link's base or the
diff --git a/internal/calendar/migrations/0002_alter_people.sql b/internal/calendar/migrations/0002_alter_people.sql
new file mode 100644
index 0000000..c4ae3bb
--- /dev/null
+++ b/internal/calendar/migrations/0002_alter_people.sql
@@ -0,0 +1,58 @@
+ALTER TABLE `people` ADD `name_sealed` blob;
+
+ALTER TABLE `people` ADD `week_start` integer NOT NULL;
+
+ALTER TABLE `people` ADD `default_reminder_min` integer NOT NULL;
+
+ALTER TABLE `people` ADD `default_all_day_min` integer NOT NULL;
+
+CREATE TABLE `calendars` (`id` integer PRIMARY KEY AUTOINCREMENT,`name_sealed` blob,`kind` text NOT NULL,`owner_id` integer NOT NULL,`hue` integer NOT NULL,`created_at` datetime NOT NULL,`archived_at` datetime);
+
+CREATE INDEX `idx_calendars_archived_at` ON `calendars`(`archived_at`);
+
+CREATE INDEX `idx_calendars_owner_id` ON `calendars`(`owner_id`);
+
+CREATE TABLE `grants` (`id` integer PRIMARY KEY AUTOINCREMENT,`calendar_id` integer NOT NULL,`person_id` integer NOT NULL,`role` text NOT NULL,`granted_by` integer NOT NULL,`created_at` datetime NOT NULL);
+
+CREATE UNIQUE INDEX `idx_grant_cal_person` ON `grants`(`calendar_id`,`person_id`);
+
+CREATE TABLE `events` (`id` integer PRIMARY KEY AUTOINCREMENT,`calendar_id` integer NOT NULL,`uid_hash` text NOT NULL,`uid_sealed` blob,`series_id` integer,`recurrence_id_local` text NOT NULL,`title_sealed` blob,`location_sealed` blob,`notes_sealed` blob,`all_day` numeric NOT NULL,`start_local` text NOT NULL,`end_local` text NOT NULL,`tz` text NOT NULL,`start_utc` integer NOT NULL,`end_utc` integer NOT NULL,`rrule` text NOT NULL,`rrule_foreign` numeric NOT NULL,`until_utc` integer,`exdates` text NOT NULL,`kind` text NOT NULL,`status` text NOT NULL,`sequence` integer NOT NULL,`created_by` integer NOT NULL,`created_at` datetime NOT NULL,`updated_at` datetime NOT NULL,`deleted_at` datetime);
+
+CREATE INDEX `idx_events_deleted_at` ON `events`(`deleted_at`);
+
+CREATE INDEX `idx_events_until_utc` ON `events`(`until_utc`);
+
+CREATE INDEX `idx_events_end_utc` ON `events`(`end_utc`);
+
+CREATE INDEX `idx_events_start_utc` ON `events`(`start_utc`);
+
+CREATE INDEX `idx_events_series_id` ON `events`(`series_id`);
+
+CREATE INDEX `idx_events_uid_hash` ON `events`(`uid_hash`);
+
+CREATE INDEX `idx_events_calendar_id` ON `events`(`calendar_id`);
+
+CREATE TABLE `reminders` (`id` integer PRIMARY KEY AUTOINCREMENT,`event_id` integer NOT NULL,`person_id` integer NOT NULL,`offset_min` integer NOT NULL,`channel` text NOT NULL,`created_at` datetime NOT NULL);
+
+CREATE INDEX `idx_reminders_person_id` ON `reminders`(`person_id`);
+
+CREATE INDEX `idx_reminders_event_id` ON `reminders`(`event_id`);
+
+CREATE TABLE `reminder_queues` (`id` integer PRIMARY KEY AUTOINCREMENT,`reminder_id` integer NOT NULL,`occurrence_key` text NOT NULL,`due_at` integer NOT NULL,`claimed_at` integer,`fired_at` integer,`attempts` integer NOT NULL,`late_by_sec` integer NOT NULL);
+
+CREATE INDEX `idx_reminder_queues_due_at` ON `reminder_queues`(`due_at`);
+
+CREATE INDEX `idx_reminder_queues_occurrence_key` ON `reminder_queues`(`occurrence_key`);
+
+CREATE UNIQUE INDEX `idx_reminder_queues_reminder_id` ON `reminder_queues`(`reminder_id`);
+
+CREATE TABLE `feeds` (`id` integer PRIMARY KEY AUTOINCREMENT,`person_id` integer NOT NULL,`calendar_id` integer NOT NULL,`token_hash` text NOT NULL,`created_at` datetime NOT NULL,`revoked_at` datetime,`last_polled_at` datetime,`poll_count` integer NOT NULL);
+
+CREATE UNIQUE INDEX `idx_feeds_token_hash` ON `feeds`(`token_hash`);
+
+CREATE INDEX `idx_feeds_calendar_id` ON `feeds`(`calendar_id`);
+
+CREATE INDEX `idx_feeds_person_id` ON `feeds`(`person_id`);
+
+CREATE TABLE `meta` (`key` text,`value` text NOT NULL,PRIMARY KEY (`key`));
+
diff --git a/internal/calendar/migrations/schema.sql b/internal/calendar/migrations/schema.sql
index a7194a6..8de22b4 100644
--- a/internal/calendar/migrations/schema.sql
+++ b/internal/calendar/migrations/schema.sql
@@ -1,7 +1,57 @@
-- Generated by rastrillo migration generate; DO NOT EDIT.
-- The schema every migration in this directory adds up to.
-CREATE TABLE `people` (`id` integer PRIMARY KEY AUTOINCREMENT,`address` text NOT NULL,`zone` text NOT NULL,`created_at` datetime NOT NULL,`updated_at` datetime NOT NULL);
+CREATE TABLE `calendars` (`id` integer PRIMARY KEY AUTOINCREMENT,`name_sealed` blob,`kind` text NOT NULL,`owner_id` integer NOT NULL,`hue` integer NOT NULL,`created_at` datetime NOT NULL,`archived_at` datetime);
+
+CREATE TABLE `events` (`id` integer PRIMARY KEY AUTOINCREMENT,`calendar_id` integer NOT NULL,`uid_hash` text NOT NULL,`uid_sealed` blob,`series_id` integer,`recurrence_id_local` text NOT NULL,`title_sealed` blob,`location_sealed` blob,`notes_sealed` blob,`all_day` numeric NOT NULL,`start_local` text NOT NULL,`end_local` text NOT NULL,`tz` text NOT NULL,`start_utc` integer NOT NULL,`end_utc` integer NOT NULL,`rrule` text NOT NULL,`rrule_foreign` numeric NOT NULL,`until_utc` integer,`exdates` text NOT NULL,`kind` text NOT NULL,`status` text NOT NULL,`sequence` integer NOT NULL,`created_by` integer NOT NULL,`created_at` datetime NOT NULL,`updated_at` datetime NOT NULL,`deleted_at` datetime);
+
+CREATE TABLE `feeds` (`id` integer PRIMARY KEY AUTOINCREMENT,`person_id` integer NOT NULL,`calendar_id` integer NOT NULL,`token_hash` text NOT NULL,`created_at` datetime NOT NULL,`revoked_at` datetime,`last_polled_at` datetime,`poll_count` integer NOT NULL);
+
+CREATE TABLE `grants` (`id` integer PRIMARY KEY AUTOINCREMENT,`calendar_id` integer NOT NULL,`person_id` integer NOT NULL,`role` text NOT NULL,`granted_by` integer NOT NULL,`created_at` datetime NOT NULL);
+
+CREATE TABLE `meta` (`key` text,`value` text NOT NULL,PRIMARY KEY (`key`));
+
+CREATE TABLE `people` (`id` integer PRIMARY KEY AUTOINCREMENT,`address` text NOT NULL,`zone` text NOT NULL,`created_at` datetime NOT NULL,`updated_at` datetime NOT NULL, `name_sealed` blob, `week_start` integer NOT NULL, `default_reminder_min` integer NOT NULL, `default_all_day_min` integer NOT NULL);
+
+CREATE TABLE `reminder_queues` (`id` integer PRIMARY KEY AUTOINCREMENT,`reminder_id` integer NOT NULL,`occurrence_key` text NOT NULL,`due_at` integer NOT NULL,`claimed_at` integer,`fired_at` integer,`attempts` integer NOT NULL,`late_by_sec` integer NOT NULL);
+
+CREATE TABLE `reminders` (`id` integer PRIMARY KEY AUTOINCREMENT,`event_id` integer NOT NULL,`person_id` integer NOT NULL,`offset_min` integer NOT NULL,`channel` text NOT NULL,`created_at` datetime NOT NULL);
+
+CREATE INDEX `idx_calendars_archived_at` ON `calendars`(`archived_at`);
+
+CREATE INDEX `idx_calendars_owner_id` ON `calendars`(`owner_id`);
+
+CREATE INDEX `idx_events_calendar_id` ON `events`(`calendar_id`);
+
+CREATE INDEX `idx_events_deleted_at` ON `events`(`deleted_at`);
+
+CREATE INDEX `idx_events_end_utc` ON `events`(`end_utc`);
+
+CREATE INDEX `idx_events_series_id` ON `events`(`series_id`);
+
+CREATE INDEX `idx_events_start_utc` ON `events`(`start_utc`);
+
+CREATE INDEX `idx_events_uid_hash` ON `events`(`uid_hash`);
+
+CREATE INDEX `idx_events_until_utc` ON `events`(`until_utc`);
+
+CREATE INDEX `idx_feeds_calendar_id` ON `feeds`(`calendar_id`);
+
+CREATE INDEX `idx_feeds_person_id` ON `feeds`(`person_id`);
+
+CREATE UNIQUE INDEX `idx_feeds_token_hash` ON `feeds`(`token_hash`);
+
+CREATE UNIQUE INDEX `idx_grant_cal_person` ON `grants`(`calendar_id`,`person_id`);
CREATE UNIQUE INDEX `idx_people_address` ON `people`(`address`);
+CREATE INDEX `idx_reminder_queues_due_at` ON `reminder_queues`(`due_at`);
+
+CREATE INDEX `idx_reminder_queues_occurrence_key` ON `reminder_queues`(`occurrence_key`);
+
+CREATE UNIQUE INDEX `idx_reminder_queues_reminder_id` ON `reminder_queues`(`reminder_id`);
+
+CREATE INDEX `idx_reminders_event_id` ON `reminders`(`event_id`);
+
+CREATE INDEX `idx_reminders_person_id` ON `reminders`(`person_id`);
+
diff --git a/internal/calendar/models.go b/internal/calendar/models.go
index 7121512..c50defb 100644
--- a/internal/calendar/models.go
+++ b/internal/calendar/models.go
@@ -3,37 +3,163 @@ package calendar
import "time"
// Models is every model the schema generator manages. Keep it in step
-// with the structs below: `rastrillo migration generate` reads it to
-// work out what the database should look like, and `rastrillo
-// migration check` fails CI when it and the migrations disagree.
+// with the structs below and in models_event.go: `rastrillo migration
+// generate` reads it to work out what the database should look like,
+// and `rastrillo migration check` fails CI when it and the migrations
+// disagree.
var Models = []any{
&Person{},
+ &Calendar{},
+ &Grant{},
+ &Event{},
+ &Reminder{},
+ &ReminderQueue{},
+ &Feed{},
+ &Meta{},
}
// Person is an address that has signed in here, and the row everything
// owned is scoped to.
//
// The indirection is not ceremony. Sign-in is a magic link, so the
-// session Subject is a verified email address, not a numeric id —
-// which is exactly why sessions.UserID is unusable under this plugin
-// and returns (0, false). Owning rows by an address column instead
-// would scatter a piece of personal data across every table and make
-// a change of address a migration; owning them by this row's id keeps
-// the address in one place.
+// session Subject is a verified email address, not a numeric id — which
+// is why sessions.UserID is unusable under this plugin and returns
+// (0, false). Owning rows by an address column instead would scatter a
+// piece of personal data across every table and make a change of
+// address a migration; owning them by this row's id keeps the address
+// in one place.
+//
+// Named Person rather than the brainstorm's Member, matching docs.
+// Sheets calls the same row Member. Nothing turns on it, and the table
+// shipped before the difference was noticed, so it stays: a rename is
+// indistinguishable from a drop plus an add, and this one buys nothing.
type Person struct {
ID int64 `gorm:"primaryKey"`
// Address is the verified sign-in address, normalised (trimmed,
// lowercased) by normalizeAddress before it is ever written or
// compared, so two spellings of one person cannot become two rows.
+ //
+ // Plaintext, deliberately. The team already knows it, a reminder
+ // email needs it to be deliverable, and the leak list says so.
Address string `gorm:"column:address;uniqueIndex;not null"`
- // Zone is the IANA time zone this person's calendar is read in
- // ("Europe/Dublin"). It is a display and interpretation setting,
- // not a property of any event: an event carries its own zone, and
- // this is the one a viewer sees it converted into.
+ // NameSealed is a display name, if they set one. Sealed: it is
+ // content, and sheets seals the equivalent.
+ NameSealed []byte `gorm:"column:name_sealed"`
+
+ // Zone is the IANA zone this person reads their calendar in
+ // ("Europe/Dublin"). Stated, never sniffed — see the settings page.
+ // A browser's zone is a guess about a device, not a statement about
+ // a person, and a calendar that silently re-times everything
+ // because somebody opened it on holiday is a calendar that lies.
Zone string `gorm:"column:zone;not null"`
+ // WeekStart is 1 for Monday (the default), 7 for Sunday, 6 for
+ // Saturday — time.Weekday's numbering with Sunday as 7 rather than
+ // 0, so "unset" is distinguishable from "Sunday".
+ WeekStart int `gorm:"column:week_start;not null"`
+
+ // The defaults applied to events this person creates. Separate for
+ // timed and all-day because "10 minutes before" is useless for a
+ // birthday and "the evening before" is useless for a standup.
+ DefaultReminderMin int `gorm:"column:default_reminder_min;not null"`
+ DefaultAllDayMin int `gorm:"column:default_all_day_min;not null"`
+
CreatedAt time.Time `gorm:"column:created_at;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;not null"`
}
+
+// Calendar is a container of events: one team calendar created with the
+// instance, plus a personal calendar per person.
+type Calendar struct {
+ ID int64 `gorm:"primaryKey"`
+
+ // NameSealed is content, like a sheet's title.
+ NameSealed []byte `gorm:"column:name_sealed"`
+
+ // Kind is "team" or "personal". The team calendar is the one every
+ // person is granted editor on at creation; a personal one is
+ // private until shared.
+ Kind string `gorm:"column:kind;not null"`
+
+ OwnerID int64 `gorm:"column:owner_id;not null;index"`
+
+ // Hue is where this calendar's colour sits on the wheel, 0-360. An
+ // intent rather than a hex value: the theme resolves it to a pair
+ // that clears the contrast floor in both light and dark, which a
+ // stored hex cannot do.
+ //
+ // Colour never carries meaning alone here — every chip prints its
+ // own title and time, and the calendar's name is on the page.
+ Hue int `gorm:"column:hue;not null"`
+
+ CreatedAt time.Time `gorm:"column:created_at;not null"`
+ ArchivedAt *time.Time `gorm:"column:archived_at;index"`
+}
+
+// Grant is the calendar-to-person join, and the one place authorisation
+// is decided.
+//
+// Explicit rows even for the team calendar, where every person gets one
+// at creation. One query shape then serves every calendar, and
+// "a row that isn't yours 404s" has exactly one home rather than a
+// special case that someone will later forget.
+type Grant struct {
+ ID int64 `gorm:"primaryKey"`
+ CalendarID int64 `gorm:"column:calendar_id;not null;uniqueIndex:idx_grant_cal_person"`
+ PersonID int64 `gorm:"column:person_id;not null;uniqueIndex:idx_grant_cal_person"`
+
+ // Role is "owner", "editor" or "viewer".
+ Role string `gorm:"column:role;not null"`
+
+ GrantedBy int64 `gorm:"column:granted_by;not null"`
+ CreatedAt time.Time `gorm:"column:created_at;not null"`
+}
+
+// The roles, as constants, because a role compared against a literal is
+// a role that will eventually be compared against a typo.
+const (
+ RoleOwner = "owner"
+ RoleEditor = "editor"
+ RoleViewer = "viewer"
+)
+
+// CanWrite reports whether a role may change what it names.
+func CanWrite(role string) bool {
+ return role == RoleOwner || role == RoleEditor
+}
+
+// Week start, using time.Weekday's numbering except that Sunday is 7
+// rather than 0 — so a zero column means "never set" and is
+// distinguishable from a deliberate Sunday.
+const (
+ WeekStartMonday = 1
+ WeekStartSaturday = 6
+ WeekStartSunday = 7
+)
+
+// The reminder defaults a new person gets.
+//
+// Ten minutes before a timed event is enough to walk to a room and not
+// so much that it arrives while you are still in the previous thing.
+// An all-day event is reminded the evening before at 18:00 — fifteen
+// hours ahead of its midnight — because a birthday you learn about at
+// midnight is a birthday you have already half-missed.
+const (
+ DefaultReminderMin = 10
+ DefaultAllDayMin = 6 * 60
+)
+
+// Meta is the instance's own key/value scratch: the tzdata stamp the
+// re-derive sweep compares against, and anything else that is a fact
+// about this database rather than about anybody's calendar.
+type Meta struct {
+ Key string `gorm:"primaryKey"`
+ Value string `gorm:"column:value;not null"`
+}
+
+// The Meta keys, spelled once.
+const (
+ MetaTzdata = "tzdata_version"
+)
diff --git a/internal/calendar/models_event.go b/internal/calendar/models_event.go
new file mode 100644
index 0000000..feabd0f
--- /dev/null
+++ b/internal/calendar/models_event.go
@@ -0,0 +1,229 @@
+package calendar
+
+import "time"
+
+// Event is one table for three things: a single event, a series master,
+// and an override of one occurrence of a series.
+//
+// One table rather than three because they are the same thing to every
+// query a view makes — "what is in this range" does not care — and
+// because the RFC models them as one VEVENT shape distinguished by the
+// presence of RRULE and RECURRENCE-ID. Three tables would mean three
+// range queries and a union at every view.
+//
+// Which is which:
+//
+// single RRule == "" SeriesID == nil
+// master RRule != "" SeriesID == nil
+// override RRule == "" SeriesID != nil, RecurrenceIDLocal set
+type Event struct {
+ ID int64 `gorm:"primaryKey"`
+ CalendarID int64 `gorm:"column:calendar_id;not null;index"`
+
+ // Identity for ICS round-trips. The hash is the lookup key that
+ // re-import dedupes on; the sealed copy is what exports.
+ //
+ // Sealed, because some generators build a UID out of the title —
+ // "standup-with-jane@example.com" is a real shape — so a plaintext
+ // UID column would be a plaintext title column wearing a hat.
+ UIDHash string `gorm:"column:uid_hash;not null;index"`
+ UIDSealed []byte `gorm:"column:uid_sealed"`
+
+ // Series linkage. An override has both of these; a master and a
+ // single have neither.
+ SeriesID *int64 `gorm:"column:series_id;index"`
+
+ // RecurrenceIDLocal names which occurrence this overrides, by its
+ // ORIGINAL local start ("2026-09-02T09:00") — never its new one.
+ //
+ // That distinction is the whole bug behind an occurrence that
+ // vanishes when you drag it: the expander skips the original slot,
+ // so an override keyed by its new time leaves the original
+ // unskipped and itself unmatched, and the day shows both the
+ // unmoved occurrence and the moved one. Overrides are matched by
+ // this column and rendered at their new time.
+ RecurrenceIDLocal string `gorm:"column:recurrence_id_local;not null"`
+
+ TitleSealed []byte `gorm:"column:title_sealed"`
+ LocationSealed []byte `gorm:"column:location_sealed"`
+ NotesSealed []byte `gorm:"column:notes_sealed"`
+
+ // The record, per ruling 1: what the person said.
+ AllDay bool `gorm:"column:all_day;not null"`
+
+ // StartLocal and EndLocal are wall-clock strings in TZ —
+ // "2026-09-02T09:00", or "2026-09-02" when AllDay.
+ //
+ // EndLocal is EXCLUSIVE, always, for both kinds. A one-day event on
+ // the 2nd ends on the 3rd. That is the RFC's rule and it is also
+ // the only one that makes range arithmetic work without a special
+ // case; an inclusive end is how a calendar ends up showing a
+ // one-day event across two days, and how a week query
+ // double-counts its boundary.
+ StartLocal string `gorm:"column:start_local;not null"`
+ EndLocal string `gorm:"column:end_local;not null"`
+
+ // TZ is the IANA zone the local strings are written in. Empty means
+ // floating — accepted on import, never creatable in the UI, because
+ // a floating event on a shared calendar means different instants
+ // for different readers, which is a calendar telling two people
+ // different things about one meeting.
+ //
+ // Ignored when AllDay: a date has no zone.
+ TZ string `gorm:"column:tz;not null"`
+
+ // Derived from the three fields above, index only, rebuilt by the
+ // re-derive sweep whenever tzdata changes. Never the record.
+ StartUTC int64 `gorm:"column:start_utc;not null;index"`
+ EndUTC int64 `gorm:"column:end_utc;not null;index"`
+
+ // Recurrence. Plaintext: it is structure rather than prose, and it
+ // reveals nothing the instants do not already.
+ RRule string `gorm:"column:rrule;not null"`
+
+ // RRuleForeign marks a rule outside the subset this app can expand.
+ // Stored verbatim, shown once on the event's page, never refused
+ // and never silently dropped — an import that eats a rule it does
+ // not understand is an import that eats data.
+ RRuleForeign bool `gorm:"column:rrule_foreign;not null"`
+
+ // UntilUTC is the series' last possible instant, indexed so a range
+ // query can skip masters that ended before the window. Nil for a
+ // series with no end.
+ UntilUTC *int64 `gorm:"column:until_utc;index"`
+
+ // Exdates is the comma-separated local datetimes this series skips
+ // — a deleted single occurrence. Deletions are EXDATEs rather than
+ // tombstone rows because that is what exports, and because a
+ // deleted occurrence has nothing else to say.
+ Exdates string `gorm:"column:exdates;not null"`
+
+ // Kind is "meeting", "commitment" or "note" — the app's own
+ // vocabulary, not the RFC's. See the event form for why three.
+ Kind string `gorm:"column:kind;not null"`
+
+ // Status is "confirmed", "tentative" or "cancelled".
+ Status string `gorm:"column:status;not null"`
+
+ // Sequence is the RFC's SEQUENCE, bumped on every edit. It is what
+ // lets a re-import tell a newer version of an event from an older
+ // one rather than guessing from timestamps.
+ Sequence int `gorm:"column:sequence;not null"`
+
+ CreatedBy int64 `gorm:"column:created_by;not null"`
+ CreatedAt time.Time `gorm:"column:created_at;not null"`
+
+ // UpdatedAt exports as DTSTAMP and LAST-MODIFIED.
+ UpdatedAt time.Time `gorm:"column:updated_at;not null"`
+ DeletedAt *time.Time `gorm:"column:deleted_at;index"`
+}
+
+// The three kinds an event can be. Not the RFC's vocabulary — the RFC
+// has no opinion — but the distinction a person actually makes.
+//
+// A meeting has other people in it and being late matters. A commitment
+// is time you have promised yourself: it occupies the day, and the
+// difference between it and a meeting is who is inconvenienced when it
+// moves. A note is a thing that is true of a day without occupying any
+// of it — a birthday, a deadline, a bin collection.
+//
+// They differ in three ways that would otherwise need three settings:
+// reminder defaults, whether they count toward the day's booked hours,
+// and how they render.
+const (
+ KindMeeting = "meeting"
+ KindCommitment = "commitment"
+ KindNote = "note"
+)
+
+// Busy reports whether a kind occupies time in the day's arithmetic. A
+// note does not: a birthday does not make your Tuesday fuller.
+func Busy(kind string) bool { return kind != KindNote }
+
+const (
+ StatusConfirmed = "confirmed"
+ StatusTentative = "tentative"
+ StatusCancelled = "cancelled"
+)
+
+// Reminder is the RULE, and it belongs to a PERSON rather than to an
+// event.
+//
+// "Remind me ten minutes before" is a fact about me. Two people on one
+// meeting want different warnings, and a reminder stored on the event
+// would either give them the same one or need a person column anyway —
+// at which point it is this table with a worse name.
+type Reminder struct {
+ ID int64 `gorm:"primaryKey"`
+ EventID int64 `gorm:"column:event_id;not null;index"`
+ PersonID int64 `gorm:"column:person_id;not null;index"`
+
+ // OffsetMin is minutes BEFORE the start. Negative means after,
+ // which is what a "did that happen?" nudge is.
+ OffsetMin int `gorm:"column:offset_min;not null"`
+
+ // Channel is "email" today. A column rather than an assumption, so
+ // web push arrives as a value rather than a schema change.
+ Channel string `gorm:"column:channel;not null"`
+
+ CreatedAt time.Time `gorm:"column:created_at;not null"`
+}
+
+// ReminderQueue holds the NEXT un-fired occurrence per rule, and only
+// that one.
+//
+// This is what keeps a forever-daily standup costing one row instead of
+// infinity. When a row fires, the next occurrence of its rule is
+// computed and written; nothing is ever materialised ahead.
+type ReminderQueue struct {
+ ID int64 `gorm:"primaryKey"`
+
+ // At most one pending row per rule — the uniqueness is the
+ // invariant, enforced by the index rather than by remembering.
+ ReminderID int64 `gorm:"column:reminder_id;not null;uniqueIndex"`
+
+ // OccurrenceKey is "{event_id}/{recurrence_id_local}" — the
+ // idempotency key. Delivery is at-least-once, so the question a
+ // retry has to answer is "have I sent THIS occurrence", and the
+ // wall clock cannot answer it.
+ OccurrenceKey string `gorm:"column:occurrence_key;not null;index"`
+
+ DueAt int64 `gorm:"column:due_at;not null;index"`
+
+ // ClaimedAt is the lease: a drain claims a row before sending, so
+ // two drains racing cannot both send. It expires, because a
+ // process killed mid-send must not leave a reminder claimed
+ // forever — better twice than never.
+ ClaimedAt *int64 `gorm:"column:claimed_at"`
+ FiredAt *int64 `gorm:"column:fired_at"`
+ Attempts int `gorm:"column:attempts;not null"`
+
+ // LateBySec is the honesty column. An instance that was asleep
+ // fires late, and the Today page says so rather than pretending the
+ // reminder arrived on time.
+ LateBySec int64 `gorm:"column:late_by_sec;not null"`
+}
+
+// Feed is a subscription capability: a URL somebody pastes into Google
+// or Apple Calendar.
+//
+// The token is hashed, like a share link. A plaintext token in this
+// table would be a live read capability sitting in the backup bucket,
+// which is the one thing the bucket is not allowed to hold.
+type Feed struct {
+ ID int64 `gorm:"primaryKey"`
+ PersonID int64 `gorm:"column:person_id;not null;index"`
+ CalendarID int64 `gorm:"column:calendar_id;not null;index"`
+ TokenHash string `gorm:"column:token_hash;not null;uniqueIndex"`
+
+ CreatedAt time.Time `gorm:"column:created_at;not null"`
+ RevokedAt *time.Time `gorm:"column:revoked_at"`
+
+ // LastPolledAt and PollCount are shown on the share page, because a
+ // feed is not free: every poll wakes a hibernating instance, and
+ // Apple polls roughly every fifteen minutes forever. Somebody
+ // deciding whether to keep a feed should be able to see what it
+ // costs.
+ LastPolledAt *time.Time `gorm:"column:last_polled_at"`
+ PollCount int64 `gorm:"column:poll_count;not null"`
+}
diff --git a/internal/calendar/person.go b/internal/calendar/person.go
new file mode 100644
index 0000000..4f58e80
--- /dev/null
+++ b/internal/calendar/person.go
@@ -0,0 +1,58 @@
+package calendar
+
+import (
+ "context"
+ "net/http"
+)
+
+// personCtxKey carries the resolved Person down the request. A private
+// key type, so nothing outside this package can set it and pretend to
+// be somebody.
+type personCtxKey struct{}
+
+// requirePerson turns the session's verified ADDRESS into this app's
+// own person ROW, creating it on first sign-in, and puts it on the
+// request.
+//
+// It runs inside the session group, after the auth plugin's own
+// RequireSession, and it is the single seam where an address becomes an
+// id. Every owner-scoped query downstream takes that id — never the
+// address — so a change of address is one row, not a migration across
+// every table.
+//
+// It is a middleware rather than a call in each handler for the reason
+// every such seam is: a handler that forgot it would not fail, it would
+// silently scope to the zero person and show an empty calendar. An
+// empty calendar looks like a new account, not like a bug.
+func (a *App) requirePerson(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ id, ok := viewer(r)
+ if !ok {
+ // RequireSession runs first and would have refused, so
+ // reaching here means the two disagree. Refuse rather than
+ // continue with no viewer.
+ a.logger.Error("calendar: session with no identity reached requirePerson", "path", r.URL.Path)
+ ErrorPage(w, r, http.StatusInternalServerError, "")
+ return
+ }
+ p, err := a.store.EnsurePerson(r.Context(), id.Address)
+ if err != nil {
+ // No address in the log line: the error is about this
+ // request, and `carlos logs` is platform-held storage we
+ // cannot grep afterwards.
+ a.logger.Error("calendar: ensure person", "err", err)
+ ErrorPage(w, r, http.StatusInternalServerError, "")
+ return
+ }
+ next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), personCtxKey{}, p)))
+ })
+}
+
+// CurrentPerson is the signed-in person on r. Past requirePerson the
+// bool is always true; it is returned anyway so a handler mounted
+// outside that group cannot read a zero Person and mistake it for
+// somebody.
+func CurrentPerson(r *http.Request) (Person, bool) {
+ p, ok := r.Context().Value(personCtxKey{}).(Person)
+ return p, ok
+}
diff --git a/internal/calendar/seal/seal.go b/internal/calendar/seal/seal.go
new file mode 100644
index 0000000..28d9dbe
--- /dev/null
+++ b/internal/calendar/seal/seal.go
@@ -0,0 +1,140 @@
+// Package seal is the app's only encryption surface.
+//
+// The one rule: an event's contents are sealed at rest, and the bucket
+// never holds a plaintext title, place or note. The DEK arrives as
+// platform config and is never written to the database it decrypts.
+//
+// Purpose keys are derived with HKDF so that a value sealed as a title
+// cannot be opened as a note. That is not paranoia: they are adjacent
+// columns of the same row, and a bug that swapped them would be
+// invisible without this.
+//
+// Lifted from sheets' seal package unchanged except for the label
+// prefix. Deliberately a copy rather than a shared module: the label is
+// part of the ciphertext's meaning, so two apps sharing this code would
+// have to agree on a prefix scheme forever. estilo's rule applies — the
+// second implementation is what tells you the shape the first got
+// wrong, and a third app is the trigger to extract it.
+package seal
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "crypto/sha256"
+ "errors"
+ "hash"
+ "io"
+
+ "golang.org/x/crypto/hkdf"
+)
+
+// label prefixes every derived key. Changing it makes every sealed
+// value in every existing database unreadable, so it is a constant with
+// a warning rather than a setting.
+const label = "oficina/calendar:"
+
+// The purposes this app seals under. A constant per purpose, rather
+// than a string at each call site, because a typo in a literal would
+// derive a different key and silently produce a value nothing can open.
+const (
+ PurposeEventTitle = "event.title"
+ PurposeEventLocation = "event.location"
+ PurposeEventNotes = "event.notes"
+ PurposeEventUID = "event.uid"
+ PurposeCalendarName = "calendar.name"
+ PurposeMemberName = "member.name"
+)
+
+func sha256New() hash.Hash { return sha256.New() }
+
+// Sealer holds the data encryption key. One per process.
+type Sealer struct{ dek []byte }
+
+// New returns a Sealer. The DEK must be exactly 32 bytes.
+func New(dek []byte) (*Sealer, error) {
+ if len(dek) != 32 {
+ return nil, errors.New("seal: DEK must be 32 bytes")
+ }
+ c := make([]byte, 32)
+ copy(c, dek)
+ return &Sealer{dek: c}, nil
+}
+
+// purposeKey derives the AEAD key for purpose via HKDF over the DEK.
+//
+// A fixed 32-byte read is always inside HKDF-SHA256's 8160-byte expand
+// limit, so this cannot fail today — but the error is still checked and
+// propagated. This is the app's only encryption surface, and a silently
+// discarded read here would degrade into a short or zero-padded key if
+// the key length ever changed: the kind of bug that leaves ciphertext
+// looking perfectly fine while being trivially breakable.
+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
+}
+
+// aeadFor derives the purpose key and returns its AEAD.
+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 encrypts plaintext under purpose. The nonce is prepended.
+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
+}
+
+// Open decrypts a value sealed under the same purpose.
+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 and OpenString are the forms every call site actually
+// wants. An empty plaintext seals to nil rather than to a ciphertext,
+// so an absent note costs no bytes and reads back as absent — and an
+// empty column is never mistaken for a decryption failure.
+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
+}
diff --git a/internal/calendar/store.go b/internal/calendar/store.go
index 7a16c47..616b5f1 100644
--- a/internal/calendar/store.go
+++ b/internal/calendar/store.go
@@ -8,6 +8,8 @@ import (
"time"
"gorm.io/gorm"
+
+ "amadan.net/oficina/calendar/internal/calendar/seal"
)
// defaultZone is the zone a person gets before they have chosen one.
@@ -18,14 +20,16 @@ const defaultZone = "UTC"
// Store is every database access this app makes. Handlers never touch
// gorm directly — one seam, so owner scoping is applied in one place
-// and can be tested in one place.
+// and can be tested in one place, and so every sealed column is sealed
+// and opened on the way past rather than at each call site.
type Store struct {
g *gorm.DB
+ seal *seal.Sealer
logger *slog.Logger
}
-func NewStore(g *gorm.DB, logger *slog.Logger) *Store {
- return &Store{g: g, logger: logger}
+func NewStore(g *gorm.DB, sealer *seal.Sealer, logger *slog.Logger) *Store {
+ return &Store{g: g, seal: sealer, logger: logger}
}
// EnsurePerson returns the Person row for a verified address, creating
@@ -41,15 +45,22 @@ func (s *Store) EnsurePerson(ctx context.Context, address string) (Person, error
return Person{}, errors.New("calendar: EnsurePerson: empty address")
}
now := time.Now().UTC()
- p := Person{Address: addr, Zone: defaultZone, CreatedAt: now, UpdatedAt: now}
+ var p Person
- // OnConflict DoNothing, then read back: the row we want may be the
- // one this statement wrote or the one a concurrent sign-in wrote,
- // and either is correct. Reading back unconditionally is what makes
- // the two cases indistinguishable to the caller.
+ // FirstOrCreate on the unique index, then read back: the row we
+ // want may be the one this statement wrote or the one a concurrent
+ // sign-in wrote, and either is correct. Two links clicked at the
+ // same moment would otherwise both read "no row" and both insert.
if err := s.g.WithContext(ctx).
Where(Person{Address: addr}).
- Attrs(Person{Zone: defaultZone, CreatedAt: now, UpdatedAt: now}).
+ Attrs(Person{
+ Zone: defaultZone,
+ WeekStart: WeekStartMonday,
+ DefaultReminderMin: DefaultReminderMin,
+ DefaultAllDayMin: DefaultAllDayMin,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }).
FirstOrCreate(&p).Error; err != nil {
return Person{}, fmt.Errorf("calendar: ensure person: %w", err)
}
diff --git a/internal/calendar/testing.go b/internal/calendar/testing.go
index b993b5c..51fb612 100644
--- a/internal/calendar/testing.go
+++ b/internal/calendar/testing.go
@@ -1,6 +1,7 @@
package calendar
import (
+ "database/sql"
"fmt"
"io"
"log/slog"
@@ -113,6 +114,17 @@ func RequestForTest(session *http.Cookie, method, target string, form url.Values
return req
}
+// DBForTest is the raw writer handle, for tests that need to assert
+// something about the schema itself rather than about a page. Nothing
+// in the app uses it.
+func (a *App) DBForTest() *sql.DB {
+ db, err := a.db.G.DB()
+ if err != nil {
+ panic("calendar: DBForTest: " + err.Error())
+ }
+ return db
+}
+
// LastMailForTest returns the body of the most recent message this app
// sent, or an error outside development where nothing records one.
func (a *App) LastMailForTest() (string, error) {
diff --git a/internal/calendartest/clock_test.go b/internal/calendartest/clock_test.go
new file mode 100644
index 0000000..d5c4ad7
--- /dev/null
+++ b/internal/calendartest/clock_test.go
@@ -0,0 +1,128 @@
+package calendartest
+
+import (
+ "testing"
+ "time"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+// The two hours a year that break calendars, pinned against
+// Europe/Dublin 2026 — DST starts 29 March and ends 25 October, both at
+// 01:00 UTC.
+//
+// There is no correct answer here and Go does not promise one, so this
+// test does not assert correctness. It asserts THE ANSWER, so that a Go
+// upgrade or a tzdata bump that changes it fails loudly here rather
+// than moving somebody's meeting quietly.
+//
+// The two cases resolve differently and that is the point of testing
+// both: the gap takes the pre-transition offset, the fold takes the
+// post-transition one.
+func TestDSTGapAndFold(t *testing.T) {
+ const dublin = "Europe/Dublin"
+
+ t.Run("the gap moves forward", func(t *testing.T) {
+ // Local 01:00 jumps to 02:00, so 01:30 never happens.
+ got, err := calendar.ToUTC("2026-03-29T01:30", dublin, false)
+ if err != nil {
+ t.Fatalf("ToUTC: %v", err)
+ }
+ if want := int64(1774747800); got != want {
+ t.Errorf("gap 01:30: unix = %d, want %d", got, want)
+ }
+ // Reading it back must land AFTER the written time, never
+ // before: an event in the gap that moved backwards would
+ // happen before the person expected it.
+ back, err := calendar.InZone(got, dublin)
+ if err != nil {
+ t.Fatalf("InZone: %v", err)
+ }
+ if h, m := back.Hour(), back.Minute(); h != 2 || m != 30 {
+ t.Errorf("gap 01:30 reads back as %02d:%02d, want 02:30", h, m)
+ }
+ })
+
+ t.Run("the fold takes the second occurrence", func(t *testing.T) {
+ // Local 02:00 falls back to 01:00, so 01:30 happens twice:
+ // once at 00:30 UTC (offset +1) and once at 01:30 UTC
+ // (offset 0). Go returns the second.
+ got, err := calendar.ToUTC("2026-10-25T01:30", dublin, false)
+ if err != nil {
+ t.Fatalf("ToUTC: %v", err)
+ }
+ if want := int64(1792891800); got != want {
+ t.Errorf("fold 01:30: unix = %d, want %d (the second occurrence)", got, want)
+ }
+ })
+}
+
+// A day is not always 24 hours long. On the two DST nights it is 23 or
+// 25, and a day view that added a fixed duration would run an hour into
+// the next day or stop an hour short of the end of this one.
+func TestDayBoundsFollowDSTRatherThanTwentyFourHours(t *testing.T) {
+ const dublin = "Europe/Dublin"
+ for _, tc := range []struct {
+ name string
+ day time.Time
+ hours float64
+ }{
+ {"an ordinary day is 24 hours", time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC), 24},
+ {"the spring-forward day is 23", time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC), 23},
+ {"the fall-back day is 25", time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC), 25},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ start, end, err := calendar.DayBounds(tc.day, dublin)
+ if err != nil {
+ t.Fatalf("DayBounds: %v", err)
+ }
+ if got := float64(end-start) / 3600; got != tc.hours {
+ t.Errorf("day length = %v hours, want %v", got, tc.hours)
+ }
+ })
+ }
+}
+
+// An all-day event is a date, not an instant. Storing it as a timed
+// event at midnight UTC is the bug that shows a birthday on the wrong
+// day for everybody west of Greenwich.
+func TestAllDayIsMidnightInItsOwnZone(t *testing.T) {
+ dublin, err := calendar.ToUTC("2026-09-02", "Europe/Dublin", true)
+ if err != nil {
+ t.Fatalf("ToUTC Dublin: %v", err)
+ }
+ tokyo, err := calendar.ToUTC("2026-09-02", "Asia/Tokyo", true)
+ if err != nil {
+ t.Fatalf("ToUTC Tokyo: %v", err)
+ }
+ if dublin == tokyo {
+ t.Error("the same date in two zones produced the same instant; all-day is being stored as UTC midnight")
+ }
+ // Tokyo is ahead, so its midnight comes first.
+ if tokyo >= dublin {
+ t.Errorf("Tokyo midnight (%d) should precede Dublin midnight (%d)", tokyo, dublin)
+ }
+}
+
+// An empty zone is UTC, never the server's local zone — which is an
+// accident of provisioning and would shift every unzoned event's day
+// boundaries the first time the fleet moved region.
+func TestEmptyZoneIsUTCNotTheServers(t *testing.T) {
+ empty, err := calendar.ToUTC("2026-09-02T09:00", "", false)
+ if err != nil {
+ t.Fatalf("ToUTC: %v", err)
+ }
+ utc, err := calendar.ToUTC("2026-09-02T09:00", "UTC", false)
+ if err != nil {
+ t.Fatalf("ToUTC: %v", err)
+ }
+ if empty != utc {
+ t.Errorf("empty zone = %d, UTC = %d; they must agree", empty, utc)
+ }
+}
+
+func TestUnknownZoneIsRefused(t *testing.T) {
+ if _, err := calendar.ToUTC("2026-09-02T09:00", "Mars/Olympus_Mons", false); err == nil {
+ t.Error("an unknown zone was accepted")
+ }
+}
diff --git a/internal/calendartest/migrate_test.go b/internal/calendartest/migrate_test.go
new file mode 100644
index 0000000..9ca3752
--- /dev/null
+++ b/internal/calendartest/migrate_test.go
@@ -0,0 +1,69 @@
+package calendartest
+
+import (
+ "database/sql"
+ "testing"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+// The generator wrote `ALTER TABLE people ADD week_start integer NOT
+// NULL` with no DEFAULT, and SQLite's documented rule is that a column
+// added NOT NULL must have a default other than NULL. A migration that
+// is refused, or applied to a table that then rejects every insert, is
+// the failure this stands in front of — and the app's own tests would
+// not otherwise notice, because every one of them starts from an empty
+// database where nothing has been inserted yet.
+//
+// So: apply the schema, then actually write and read a row of every
+// table. A schema that exists but cannot hold a row is not a schema.
+func TestSchemaAppliesAndHoldsARow(t *testing.T) {
+ app := newApp(t)
+
+ // The person row is written by requirePerson on the first
+ // authenticated page view, not by sign-in itself — signing in mints
+ // a session and redirects, and nothing here follows redirects. So
+ // ask for a page, which is what exercises the columns 0002 added by
+ // writing the defaults into them.
+ session := app.SignInForTest(t, "someone@example.com")
+ if session == nil {
+ t.Fatal("sign-in minted no session")
+ }
+ if rec := getAs(t, app.Handler(), session, "/"); rec.Code != 200 {
+ t.Fatalf("GET /: status %d, want 200", rec.Code)
+ }
+
+ db := app.DBForTest()
+ for _, table := range []string{
+ "people", "calendars", "grants", "events",
+ "reminders", "reminder_queues", "feeds", "meta",
+ } {
+ var n int
+ // A count is enough: it fails if the table is absent, and
+ // succeeds only if SQLite can actually read it.
+ if err := db.QueryRow("SELECT count(*) FROM " + table).Scan(&n); err != nil {
+ t.Errorf("table %s: %v", table, err)
+ }
+ }
+
+ // The columns 0002 added to an already-created table are the ones
+ // at risk, so name them rather than trusting the table's existence.
+ var (
+ weekStart int
+ reminder int
+ allDay int
+ nameSealed []byte
+ )
+ err := db.QueryRow(`SELECT week_start, default_reminder_min, default_all_day_min, name_sealed
+ FROM people LIMIT 1`).
+ Scan(&weekStart, &reminder, &allDay, &nameSealed)
+ if err != nil && err != sql.ErrNoRows {
+ t.Fatalf("reading the columns migration 0002 added: %v", err)
+ }
+ if err == sql.ErrNoRows {
+ t.Fatal("sign-in wrote no person row, so this proved nothing")
+ }
+ if weekStart != calendar.WeekStartMonday {
+ t.Errorf("week_start = %d, want %d (Monday)", weekStart, calendar.WeekStartMonday)
+ }
+}