oficina / calendar Public

Close Calendar Home review conditions and bind account identities

Co-Authored-By: Codex <noreply@openai.com>
Paul Campbell pushed by paul@keymail.dev 20e6deeae60bf0e5781d8540188c0e17deb90d44 parent 462ea56
6 files changed, +37 −19
  • AGENTS.md +6 −1
  • go.mod +1 −1
  • go.sum +2 −2
  • internal/calendar/app.go +1 −1
  • internal/calendar/auth.go +4 −13
  • internal/calendar/home_router_test.go +23 −1

CI passed — run details

diff --git a/AGENTS.md b/AGENTS.md
index 04cd933..7561bf2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -60,8 +60,9 @@ pass by reading a file the data has not reached.
| 4 | The recurrence subset is the six a picker offers; anything else is stored verbatim, flagged, and not expanded | Refusing a rule we cannot expand — an importer that eats a rule it does not understand eats data | This session, 2026-09-02 |
| 5 | Reminders are per PERSON, not per event | Per-event reminders — two people on one meeting want different warnings, and the fix needs a person column anyway | This session, 2026-09-02 |
| 6 | Delivery is at-least-once; every ambiguous case resolves towards sending | At-most-once — a reminder that never arrives is worse than one that arrives twice | This session, 2026-09-02 |
-| 7 | Sign-in is a magic link, with no passkey second factor | Passkeys — `passkey.Config` derives its relying party from this instance's host, so one person in three team instances enrols three credentials. Same ruling docs made | This session, 2026-09-02 |
+| 7 | Local sign-in is a magic link, with no passkey second factor | Passkeys — `passkey.Config` derives its relying party from this instance's host, so one person in three team instances enrols three credentials. Same ruling docs made | This session, 2026-09-02 |
| 8 | A new event defaults to the person's PERSONAL calendar | Defaulting to the team calendar — the cost of forgetting to change a dropdown should fall on the side of privacy | This session, 2026-09-02 |
+| H1 | **Optional Home sign-in connects to an existing person after fresh local proof and explicit confirmation; local sign-in remains available** | Treating the Home subject as an email or allocating a new person | User-approved suite connection, 2026-09-10 |
| B1 | A booking is confirmed by a BUTTON on the page the mailed link opens — a same-origin POST — never on submit and never on GET | Instant confirmation; confirming on the GET, which every link-scanning mail gateway would press | Booking spec §1, 2026-09-07 |
| B2 | Everything the host can see is busy — every calendar they hold a grant on | Personal only | Booking spec §1, 2026-09-07 |
| B4 | The public identifier is a chosen plaintext `handle` plus a GENERATED page code | A chosen slug — prose joined to events | Booking spec §1, 2026-09-07 |
@@ -150,6 +151,10 @@ relying on remembering one, make it a test instead.
is an address, so it returns `(0, false)` and every query silently
scopes to person 0: a calendar that looks empty rather than broken.
Use `auth.From(r)`, and `requirePerson` for the id.
+- **A Home session subject is opaque.** Resolve its explicitly linked person ID
+ before any grant lookup. Never pass it to `EnsurePersonSetup` or use it as an
+ address. Recheck current admission and retain AUTOINCREMENT person IDs without
+ reuse or reassignment. Home proof storage uses a separate stable connection key.
- Migrations are additive-only. Never edit one that has shipped.
**Ticks**
diff --git a/go.mod b/go.mod
index 461f9c4..841e129 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module amadan.net/oficina/calendar
go 1.25.0
require (
- amadan.net/oficina/home/connect v0.0.0-20260909231415-9a780c687d4f
+ amadan.net/oficina/home/connect v0.0.0-20260909233352-868f189c5c77
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
github.com/carlosframework/rastrillo v0.23.0
diff --git a/go.sum b/go.sum
index a10871e..a6d6a51 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,5 @@
-amadan.net/oficina/home/connect v0.0.0-20260909231415-9a780c687d4f h1:DLcotgutTVa/LeYiCTmBH+MO0O8EEJ2RN97+5zFxndE=
-amadan.net/oficina/home/connect v0.0.0-20260909231415-9a780c687d4f/go.mod h1:jsd1+E2JaW2m1Afqh7CHjSbdxYSRk2UPAEbxeSQ8zyo=
+amadan.net/oficina/home/connect v0.0.0-20260909233352-868f189c5c77 h1:eKlXkS4kkVxsFTDtGxnVj0+rfVc50alQLw8o6TeSRQM=
+amadan.net/oficina/home/connect v0.0.0-20260909233352-868f189c5c77/go.mod h1:jsd1+E2JaW2m1Afqh7CHjSbdxYSRk2UPAEbxeSQ8zyo=
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM=
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k=
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc=
diff --git a/internal/calendar/app.go b/internal/calendar/app.go
index d8a8e82..a04ef05 100644
--- a/internal/calendar/app.go
+++ b/internal/calendar/app.go
@@ -61,7 +61,7 @@ func New(cfg Config) (*App, error) {
cfg.Origin = origin
homeSettings, err := connect.DecodeCredentials(cfg.Home, origin)
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("calendar: Home configuration: %w", err)
}
instanceKey, err := resolveInstanceKey(cfg)
diff --git a/internal/calendar/auth.go b/internal/calendar/auth.go
index 2cfb3cf..cbb91b3 100644
--- a/internal/calendar/auth.go
+++ b/internal/calendar/auth.go
@@ -1,14 +1,5 @@
-// Sign-in is a magic link and nothing else — rastrillo/auth's turnkey
-// plugin: an emailed, single-use link, no password, no credential
-// enrolled.
-//
-// Deliberately no passkey second factor. passkey.Config derives its
-// relying party ID from this instance's own host, so a person signed
-// into three team instances of this suite would enrol three separate
-// passkeys for what they experience as one product. A magic link
-// enrols no credential at all, so it creates no relying party and the
-// question never arises. This is a ruling, not an omission to improve
-// on later; the same one docs made, for the same reason.
+// Local verification uses an emailed single-use link. Optional Home sign-in
+// resolves an explicitly connected existing account; it never creates a person.
package calendar
import (
@@ -262,9 +253,9 @@ func gateBegin(next http.HandlerFunc, list allowlist, signinPath string, logger
// different things: gateBegin stops the mail going out, Authorize stops
// a link that was somehow minted for a non-permitted address (mailed
// before the allowlist changed, say) from minting a session.
-func newAuthPlugin(origin string, writer *sql.DB, instanceKey string, mailer mail.Sender, list allowlist, logger *slog.Logger, homeEnabled ...bool) (*rauth.Auth, error) {
+func newAuthPlugin(origin string, writer *sql.DB, instanceKey string, mailer mail.Sender, list allowlist, logger *slog.Logger, homeEnabled bool) (*rauth.Auth, error) {
signin, signedIn := "/signin", "/"
- if len(homeEnabled) > 0 && homeEnabled[0] {
+ if homeEnabled {
signin, signedIn = "/signin/local", connect.LocalCompletePath
}
return rauth.New(rauth.Config{
diff --git a/internal/calendar/home_router_test.go b/internal/calendar/home_router_test.go
index 17a0e8a..a18ab48 100644
--- a/internal/calendar/home_router_test.go
+++ b/internal/calendar/home_router_test.go
@@ -36,7 +36,7 @@ func newHomeRouterFixture(t *testing.T) *homeRouterFixture {
t.Fatal(err)
}
manifest, _ := json.Marshal(map[string]string{"key": base64.StdEncoding.EncodeToString(key.SignPub())})
- a, err := New(Config{DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "development", Origin: homeTestOrigin, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Home: &connect.Credentials{Issuer: homeTestIssuer, ConsumerID: "calendar-test", KeysBase64: base64.StdEncoding.EncodeToString(manifest), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))}})
+ a, err := New(Config{DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "development", Origin: homeTestOrigin, AllowedAddresses: "alice@example.test,replacement@example.test", Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Home: &connect.Credentials{Issuer: homeTestIssuer, ConsumerID: "calendar-test", KeysBase64: base64.StdEncoding.EncodeToString(manifest), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))}})
if err != nil {
t.Fatal(err)
}
@@ -172,6 +172,21 @@ func TestHomeRouterRetainsExistingCalendarIdentity(t *testing.T) {
if err != nil || len(reminders) != 1 || reminders[0].PersonID != person.ID {
t.Fatal("Home changed reminder owner", err)
}
+
+ if got := homeRedirect(t, f.do("GET", "/account/home", "")); got != "/signin/local" {
+ t.Fatal("Home counted as local proof", got)
+ }
+ savedHome := f.app.home
+ f.app.home = nil
+ if w := f.do("GET", "/day/2026-09-10", ""); w.Code != 404 {
+ t.Fatal("disabled Home accepted session", w.Code)
+ }
+ f.app.home = savedHome
+ delete(f.app.home.admitted.addrs, "alice@example.test")
+ if w := f.do("GET", "/day/2026-09-10", ""); w.Code != 404 {
+ t.Fatal("removed member admitted", w.Code)
+ }
+ f.app.home.admitted.addrs["alice@example.test"] = struct{}{}
if err := f.app.db.G.Delete(&Person{}, person.ID).Error; err != nil {
t.Fatal(err)
}
@@ -227,3 +242,10 @@ func TestHomeRouterRejectsCrossSiteMutationAndDuplicateSession(t *testing.T) {
t.Fatal("ambiguous cookie admitted", w.Code)
}
}
+
+func TestPartialHomeConfigurationRefusesBeforeOpeningDatabase(t *testing.T) {
+ _, err := New(Config{DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "development", Origin: homeTestOrigin, Home: &connect.Credentials{Issuer: homeTestIssuer}})
+ if err == nil {
+ t.Fatal("partial Home credentials accepted")
+ }
+}