Close Calendar Home review conditions and bind account identities
Co-Authored-By: Codex <noreply@openai.com>
6 files changed,
+37
−19
AGENTS.md+6 −1go.mod+1 −1go.sum+2 −2internal/calendar/app.go+1 −1internal/calendar/auth.go+4 −13internal/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") |
| + } |
| +} |