Integrate Home sign-in with current Meet calls and agent API
Preserve both browser identity linking and bearer-only agent routes. Verify existing principals across Home, calls, settings, API and signout.
18 files changed,
+754
−17
Makefile+2 −0cmd/meet/main.go+2 −0docs/superpowers/specs/2026-09-07-meet-design.md+33 −1go.mod+5 −1go.sum+8 −2internal/meet/app.go+32 −7internal/meet/auth.go+12 −3internal/meet/config.go+7 −0internal/meet/handlers_join.go+7 −1internal/meet/handlers_meetings.go+1 −1internal/meet/home.go+123 −0internal/meet/home_release_test.go+77 −0internal/meet/home_router_test.go+331 −0internal/meet/home_security_test.go+92 −0internal/meet/render.go+5 −1internal/meet/store.go+14 −0internal/meet/templates/home.html+2 −0internal/meet/templates/index.html+1 −0
CI passed — run details
diff --git a/Makefile b/Makefile| index 383fa5e..737bd0b 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -1,3 +1,5 @@ |
| +export GOPRIVATE := $(GOPRIVATE),amadan.net/oficina/estilo.git |
| + |
| APP := meet |
| # What `carlos ship -target` defaults to, and therefore what a release |
diff --git a/cmd/meet/main.go b/cmd/meet/main.go| index 31a34c0..d598e07 100644 |
| --- a/cmd/meet/main.go |
| +++ b/cmd/meet/main.go |
| @@ -13,6 +13,7 @@ import ( |
| "amadan.net/rastrillo/rastrillo/db" |
| "amadan.net/rastrillo/rastrillo/mail" |
| + "amadan.net/oficina/home/connect" |
| "amadan.net/oficina/meet/internal/meet" |
| ) |
| @@ -51,6 +52,7 @@ func main() { |
| mailer := newMailer(logger) |
| cfg := meet.Config{ |
| + Home: connect.CredentialsFromEnv("MEET"), |
| Env: *env, |
| Origin: os.Getenv("MEET_ORIGIN"), |
| InstanceKey: os.Getenv("MEET_INSTANCE_KEY"), |
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md| index b32eb49..9888270 100644 |
| --- a/docs/superpowers/specs/2026-09-07-meet-design.md |
| +++ b/docs/superpowers/specs/2026-09-07-meet-design.md |
| @@ -868,6 +868,38 @@ outside `-env development`. This file already lives in Docs, Calendar |
| and Slides; Meet's is the fourth copy, and the extraction task Slides |
| recorded stands. Meet adds none. |
| +### 7.1.1 Optional Home sign-in (2026-09-10) |
| + |
| +The operator can enable Home with all four `MEET_HOME_ISSUER`, |
| +`MEET_HOME_CONSUMER_ID`, `MEET_HOME_KEYS_BASE64` and |
| +`MEET_HOME_DEK_BASE64` settings. Missing all four keeps standalone |
| +sign-in; partial settings refuse startup. The Home key is separate |
| +from the meeting-title key. |
| + |
| +Home links an existing member after fresh independent local sign-in, |
| +a fresh Home proof and explicit confirmation. It never creates a person, |
| +matches a Home email claim or grants membership. Both protected routes |
| +and the public meeting-link handler resolve Home subjects to the existing |
| +person ID, checking current membership. Existing meeting actors remain |
| +`person:<id>`. Person IDs are AUTOINCREMENT and cannot be recycled; |
| +any future account-deletion operation must also delete its Home links in |
| +the same transaction. |
| + |
| +`GET /signin` starts Home unless the browser has a pending guest-join |
| +cookie or is returning a local sign-in result. `GET /signin/local` is |
| +independent local sign-in. Its verification finishes at |
| +`/auth/local/complete`, then `/continue`, preserving the pending-join |
| +rules above. An explicit account-link continuation takes precedence. |
| +Guests still verify locally and cannot access `/account/home`. |
| + |
| +Shared `home/connect` owns `/auth/start`, `POST /auth/assertion`, |
| +`/auth/finish`, `/signed-out` and the GET/POST account-link routes |
| +`/account/home` and `/account/home/confirm`. Only the exact signed |
| +assertion callback bypasses ordinary CSRF protection; its issuer and |
| +proof are checked by the shared protocol. CSP preserves the framework's |
| +other directives and adds only the configured Home issuer to form-action. |
| +All account-link screens use the shared templates and work without scripts. |
| + |
| ### 7.2 Actors |
| Every write records an actor: `person:<id>`, or `agent:<id>` for the |
| @@ -998,7 +1030,7 @@ are drafts for that review, not approved copy. |
| Recurring meetings. Calendar integration. Recording. Attendance |
| records. A relay of Meet's own. Rooms above four. Persisted chat. |
| -Reactions. Dial-in. Captions. Home integration. Multi-host signalling. |
| +Reactions. Dial-in. Captions. Multi-host signalling. |
| Push-to-talk. Virtual backgrounds. A native client and its sign-in |
| (§8.4). Denying a knock (a guest who is not |
| admitted simply waits or leaves). Each is named so the next session |
diff --git a/go.mod b/go.mod| index a20abe5..63dbe54 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -3,15 +3,19 @@ module amadan.net/oficina/meet |
| go 1.26.0 |
| require ( |
| - amadan.net/rastrillo/rastrillo v0.26.0 |
| + amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f |
| + amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| + amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd |
| github.com/go-chi/chi/v5 v5.3.2 |
| golang.org/x/crypto v0.56.0 |
| gorm.io/gorm v1.31.2 |
| ) |
| require ( |
| + amadan.net/oficina/estilo.git v0.0.0-20260909234655-dec3908b237c // indirect |
| github.com/BurntSushi/toml v1.6.0 // indirect |
| github.com/dustin/go-humanize v1.0.1 // indirect |
| + github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect |
| github.com/google/uuid v1.6.0 // indirect |
| github.com/jinzhu/inflection v1.0.0 // indirect |
| github.com/jinzhu/now v1.1.5 // indirect |
diff --git a/go.sum b/go.sum| index c8aae48..e5fab7f 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,5 +1,11 @@ |
| -amadan.net/rastrillo/rastrillo v0.26.0 h1:I7UkiDbT304q9wXnmgabe84P6RDFTjMbKU174i1QZDo= |
| -amadan.net/rastrillo/rastrillo v0.26.0/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| +amadan.net/oficina/estilo.git v0.0.0-20260909234655-dec3908b237c h1:LgE7WLm6zMG/qZScoAHb4X2gguzorctB4XGjPYavXts= |
| +amadan.net/oficina/estilo.git v0.0.0-20260909234655-dec3908b237c/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY= |
| +amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f h1:lkw3tjzHfZxe74pNfo+tGDz4mkZLg+D6R+FTkmsym6M= |
| +amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI= |
| +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= |
| +amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= |
| github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= |
| github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E= |
diff --git a/internal/meet/app.go b/internal/meet/app.go| index a587440..d880201 100644 |
| --- a/internal/meet/app.go |
| +++ b/internal/meet/app.go |
| @@ -31,6 +31,7 @@ type app struct { |
| store *store |
| logger *slog.Logger |
| auth *rauth.Auth |
| + home *homeIntegration |
| linkBudget *budget |
| outsiderBudget *budget |
| hub *hub.Hub |
| @@ -382,6 +383,9 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| return nil, nil, err |
| } |
| a.auth = authPlugin |
| + if err := a.wireHome(); err != nil { |
| + return nil, nil, err |
| + } |
| // Bounded, not open-ended: a boot sweep runs on the caller's ctx but |
| // must still finish, so an app never fails to start (or hangs a |
| @@ -402,14 +406,29 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| r.Group(a.apiRoutes) |
| r.Group(func(r chi.Router) { |
| - r.Use(csrf.Protect(rc.Origin)) |
| - |
| - // Inside CSRF, outside the session: sign-in and /j/. |
| + if a.home == nil { |
| + r.Use(csrf.Protect(rc.Origin)) |
| + } else { |
| + r.Use(a.home.routes.Middleware) |
| + } |
| 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) |
| + if a.home == nil { |
| + r.Post(signinPath, a.gateBegin(authPlugin.Begin)) |
| + r.Get("/auth/callback", authPlugin.Callback) |
| + r.Get("/auth/verify", authPlugin.Verify) |
| + r.Post("/signout", authPlugin.Signout) |
| + } else { |
| + r.Post(signinPath, a.home.routes.LocalGuard(a.gateBegin(authPlugin.Begin))) |
| + r.Get("/auth/callback", a.home.routes.LocalGuard(authPlugin.Callback)) |
| + r.Get("/auth/verify", a.home.routes.LocalGuard(authPlugin.Verify)) |
| + r.Post("/signout", a.home.routes.Signout) |
| + r.Get("/signin/local", a.localSignin) |
| + r.Get("/signed-out", a.home.routes.SignedOut) |
| + r.Get("/auth/start", a.home.bridge.Start) |
| + r.Post("/auth/assertion", a.home.bridge.Callback) |
| + r.Get("/auth/finish", a.home.bridge.Finish) |
| + r.Get("/auth/local/complete", a.home.routes.LocalComplete) |
| + } |
| r.Get("/j/{token}", a.followLink) |
| // Signed in, with a persons row. |
| @@ -426,6 +445,12 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| r.Post("/m/{sqid}/takeover", a.takeover) |
| r.Group(func(r chi.Router) { |
| r.Use(a.requireMember) |
| + if a.home != nil { |
| + r.Get("/account/home", a.home.routes.Account) |
| + r.Post("/account/home", a.home.routes.LinkStart) |
| + r.Get("/account/home/confirm", a.home.bridge.ConfirmPage) |
| + r.Post("/account/home/confirm", a.home.routes.ConfirmPost) |
| + } |
| r.Get("/", a.index) |
| r.Post("/meetings/start", a.startCall) |
| r.Post("/meetings", a.createMeeting) |
diff --git a/internal/meet/auth.go b/internal/meet/auth.go| index db82a0b..d6ffc06 100644 |
| --- a/internal/meet/auth.go |
| +++ b/internal/meet/auth.go |
| @@ -3,6 +3,7 @@ package meet |
| import ( |
| "context" |
| "database/sql" |
| + "errors" |
| "net/http" |
| "net/url" |
| "strings" |
| @@ -20,6 +21,10 @@ const sentLocation = signinPath + "?sent=1" |
| // 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) { |
| + landing := "/continue" |
| + if a.cfg.home != nil { |
| + landing = "/auth/local/complete" |
| + } |
| return rauth.New(rauth.Config{ |
| DB: writer, |
| Origin: a.cfg.Origin, |
| @@ -27,7 +32,7 @@ func (a *app) newAuthPlugin(writer *sql.DB) (*rauth.Auth, error) { |
| Mailer: a.cfg.mailer, |
| Authorize: a.authorize, |
| SigninPath: signinPath, |
| - SignedInPath: "/continue", |
| + SignedInPath: landing, |
| Logger: a.logger, |
| }) |
| } |
| @@ -152,7 +157,11 @@ func (a *app) requirePerson(next http.Handler) http.Handler { |
| a.ErrorPage(w, r, http.StatusInternalServerError, "") |
| return |
| } |
| - p, err := a.store.ensurePerson(r.Context(), s.Subject) |
| + p, err := a.personForSession(r.Context(), s) |
| + if errors.Is(err, errHomeIdentity) { |
| + a.ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| if err != nil { |
| a.logger.Error("meet: ensure person", "err", err) |
| a.ErrorPage(w, r, http.StatusInternalServerError, "") |
| @@ -179,7 +188,7 @@ func (a *app) requireMember(next http.Handler) http.Handler { |
| }) |
| } |
| -func (a *app) signin(w http.ResponseWriter, r *http.Request) { |
| +func (a *app) localSignin(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": |
diff --git a/internal/meet/config.go b/internal/meet/config.go| index a09b88b..742ca59 100644 |
| --- a/internal/meet/config.go |
| +++ b/internal/meet/config.go |
| @@ -7,6 +7,7 @@ import ( |
| "strings" |
| "time" |
| + "amadan.net/oficina/home/connect" |
| "amadan.net/rastrillo/rastrillo/mail" |
| "amadan.net/oficina/meet/internal/meet/relay" |
| @@ -22,6 +23,7 @@ const EnvDevelopment = "development" |
| // secret or a boundary is a startup refusal when it is missing outside |
| // development, per spec §7.1. |
| type Config struct { |
| + Home *connect.Credentials |
| // Env is "development" or anything else (treated as production). |
| Env string |
| // Origin is the app's external origin, scheme included |
| @@ -60,6 +62,7 @@ var devDEK = make([]byte, 32) |
| // resolved is Config after every refusal has been checked and every |
| // default applied. |
| type resolved struct { |
| + home *connect.DecodedCredentials |
| Config |
| instanceKey string |
| dek []byte |
| @@ -154,5 +157,9 @@ func resolve(cfg Config) (resolved, error) { |
| } |
| out.zone = loc |
| out.Zone = zone |
| + out.home, err = connect.DecodeCredentials(cfg.Home, out.Origin) |
| + if err != nil { |
| + return out, err |
| + } |
| return out, nil |
| } |
diff --git a/internal/meet/handlers_join.go b/internal/meet/handlers_join.go| index 5a2b68f..9a989f5 100644 |
| --- a/internal/meet/handlers_join.go |
| +++ b/internal/meet/handlers_join.go |
| @@ -1,6 +1,8 @@ |
| package meet |
| import ( |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| + "errors" |
| "net/http" |
| "github.com/go-chi/chi/v5" |
| @@ -26,7 +28,11 @@ func (a *app) followLink(w http.ResponseWriter, r *http.Request) { |
| http.Redirect(w, r, signinPath, http.StatusSeeOther) |
| return |
| } |
| - p, err := a.store.ensurePerson(r.Context(), id.Address) |
| + p, err := a.personForSession(r.Context(), sessions.Session{Subject: id.Address, Method: string(id.Method), AuthTime: id.AuthTime, At: id.At}) |
| + if errors.Is(err, errHomeIdentity) { |
| + a.ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| if err != nil { |
| a.logger.Error("meet: ensure person", "err", err) |
| a.ErrorPage(w, r, http.StatusInternalServerError, "") |
diff --git a/internal/meet/handlers_meetings.go b/internal/meet/handlers_meetings.go| index d9313bb..a764386 100644 |
| --- a/internal/meet/handlers_meetings.go |
| +++ b/internal/meet/handlers_meetings.go |
| @@ -89,7 +89,7 @@ func (a *app) renderIndex(w http.ResponseWriter, r *http.Request, titleErr, star |
| } |
| render(w, "index", a.page(r, map[string]any{ |
| "Upcoming": upcoming, "Ended": ended, "Zone": a.cfg.Zone, |
| - "TitleError": titleErr, "StartError": startErr, |
| + "TitleError": titleErr, "StartError": startErr, "HomeEnabled": a.showHomeConnection(r), |
| })) |
| } |
diff --git a/internal/meet/home.go b/internal/meet/home.go| new file mode 100644 |
| index 0000000..28026d4 |
| --- /dev/null |
| +++ b/internal/meet/home.go |
| @@ -0,0 +1,123 @@ |
| +package meet |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "database/sql" |
| + "errors" |
| + "net/http" |
| + |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/oficina/home/connect/linkstore" |
| + "amadan.net/oficina/home/handoff" |
| + "amadan.net/rastrillo/rastrillo/csrf" |
| + "amadan.net/rastrillo/rastrillo/migrate" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| +) |
| + |
| +var errHomeIdentity = errors.New("meet: Home identity refused") |
| + |
| +type homeIntegration struct { |
| + bridge *connect.Bridge |
| + routes *connect.Routes |
| + sessions *sessions.Sessions |
| +} |
| + |
| +func (a *app) wireHome() error { |
| + h := a.cfg.home |
| + if h == nil { |
| + return nil |
| + } |
| + if _, err := migrate.Apply(context.Background(), a.db, migrate.Merge(migrate.MustFromFS(handoff.Migrations, "oficina_handoff"), migrate.MustFromFS(linkstore.Migrations, linkstore.SchemaNamespace))); err != nil { |
| + return errHomeIdentity |
| + } |
| + sess, err := sessions.New(sessions.Config{DB: a.db.Writer(), Origin: a.cfg.Origin, SigninPath: signinPath, Logger: a.logger}) |
| + if err != nil || sess.CookieName() != a.auth.SessionCookie() { |
| + return errHomeIdentity |
| + } |
| + read := func(r *http.Request) (*connect.Session, error) { |
| + s, ok := sess.From(r) |
| + if !ok { |
| + return nil, nil |
| + } |
| + return &connect.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime, At: s.At}, nil |
| + } |
| + b, err := connect.New(connect.Config{DB: a.db.Writer(), Origin: a.cfg.Origin, Issuer: h.Issuer, ConsumerID: h.ConsumerID, Namespace: "oficina/meet", SessionCookie: sess.CookieName(), Keys: h.Keys, DEK: h.DEK, ReadSession: read, |
| + MintHomeSession: func(w http.ResponseWriter, r *http.Request, s connect.Session) error { |
| + return sess.SignIn(w, r, sessions.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime}) |
| + }, |
| + LookupLocalSession: func(ctx context.Context, s connect.Session) (connect.Person, error) { |
| + if s.Method != "magiclink" && s.Method != "keymail" { |
| + return connect.Person{}, errHomeIdentity |
| + } |
| + p, err := a.store.existingHomePerson(ctx, s.Subject) |
| + if err != nil || !a.isMember(p) { |
| + return connect.Person{}, errHomeIdentity |
| + } |
| + return connect.Person{ID: p.ID, Label: p.Email}, nil |
| + }, |
| + LookupPerson: func(ctx context.Context, q linkstore.Queryer, id int64) (string, error) { |
| + p, err := a.store.homePersonByID(ctx, q, id) |
| + if err != nil { |
| + return "", err |
| + } |
| + if !a.isMember(p) { |
| + return "", sql.ErrNoRows |
| + } |
| + return p.Email, nil |
| + }, Render: a.renderHome}) |
| + if err != nil { |
| + return errHomeIdentity |
| + } |
| + routes, err := connect.NewRoutes(b, connect.RoutesConfig{LocalLandingPath: "/continue", Namespace: "oficina/meet", Origin: a.cfg.Origin, Issuer: h.Issuer, SessionCookie: sess.CookieName(), DEK: h.DEK, ReadSession: read, SignOut: sess.SignOut, LocalSignin: a.localSignin, Protect: csrf.Protect(a.cfg.Origin), Render: a.renderHome}) |
| + if err != nil { |
| + return errHomeIdentity |
| + } |
| + a.home = &homeIntegration{b, routes, sess} |
| + return nil |
| +} |
| + |
| +func (a *app) personForSession(ctx context.Context, s sessions.Session) (Person, error) { |
| + if s.Method == connect.HomeMethod { |
| + if a.home == nil { |
| + return Person{}, errHomeIdentity |
| + } |
| + linked, err := a.home.bridge.Resolve(ctx, s.Subject) |
| + if err != nil { |
| + return Person{}, errHomeIdentity |
| + } |
| + p, err := a.store.homePersonByID(ctx, a.db.Writer(), linked.ID) |
| + if err != nil || !a.isMember(p) { |
| + return Person{}, errHomeIdentity |
| + } |
| + return p, nil |
| + } |
| + if s.Method != "magiclink" && s.Method != "keymail" { |
| + return Person{}, errHomeIdentity |
| + } |
| + return a.store.ensurePerson(ctx, s.Subject) |
| +} |
| +func (a *app) signin(w http.ResponseWriter, r *http.Request) { |
| + if a.home != nil { |
| + if _, pending := a.readJoinCookie(r); !pending { |
| + a.home.sessions.Middleware(http.HandlerFunc(a.home.routes.Signin)).ServeHTTP(w, r) |
| + return |
| + } |
| + } |
| + a.localSignin(w, r) |
| +} |
| +func (a *app) renderHome(w http.ResponseWriter, r *http.Request, state, label string, status int) { |
| + var body bytes.Buffer |
| + if err := pages["home"].ExecuteTemplate(&body, "layout", a.page(r, map[string]any{"Home": connect.Page(state, label)})); err != nil { |
| + a.ErrorPage(w, r, 500, "") |
| + return |
| + } |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.WriteHeader(status) |
| + _, _ = w.Write(body.Bytes()) |
| +} |
| +func (a *app) showHomeConnection(r *http.Request) bool { |
| + s, ok := sessions.Current(r) |
| + return a.home != nil && ok && (s.Method == "magiclink" || s.Method == "keymail") |
| +} |
diff --git a/internal/meet/home_release_test.go b/internal/meet/home_release_test.go| new file mode 100644 |
| index 0000000..b3a8da2 |
| --- /dev/null |
| +++ b/internal/meet/home_release_test.go |
| @@ -0,0 +1,77 @@ |
| +package meet |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "regexp" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestHomeConnectionKeepsCurrentCallAndAgentRoutes(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.link(t) |
| + started := f.do("POST", "/meetings/start", "") |
| + if started.Code != http.StatusOK { |
| + t.Fatalf("start call: %d", started.Code) |
| + } |
| + links := regexp.MustCompile(`href="(/m/[A-Za-z0-9]+/join)"`).FindStringSubmatch(started.Body.String()) |
| + if len(links) != 2 { |
| + t.Fatal("missing holding page link") |
| + } |
| + target := links[1] |
| + if !strings.HasPrefix(target, "/m/") { |
| + t.Fatal("start call lost room destination") |
| + } |
| + if w := f.do("GET", target, ""); w.Code != 200 { |
| + t.Fatalf("call page: %d", w.Code) |
| + } |
| + if w := f.do("GET", "/settings", ""); w.Code != 200 || !strings.Contains(w.Body.String(), "alice@example.test") { |
| + t.Fatalf("settings lost existing principal: %d", w.Code) |
| + } |
| + created := f.do("POST", "/settings/agents", "name=home-integration-test") |
| + if created.Code != 200 { |
| + t.Fatalf("create agent: %d", created.Code) |
| + } |
| + token := regexp.MustCompile(`value="(mt_[A-Za-z0-9_-]+)"`).FindStringSubmatch(created.Body.String()) |
| + if len(token) != 2 { |
| + t.Fatal("missing once-shown agent token") |
| + } |
| + api := func(method, path, body, bearer string, cookies bool) *httptest.ResponseRecorder { |
| + r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(body)) |
| + r.Header.Set("Content-Type", "application/json") |
| + if bearer != "" { |
| + r.Header.Set("Authorization", "Bearer "+bearer) |
| + } |
| + if cookies { |
| + for _, c := range f.cookies { |
| + r.AddCookie(c) |
| + } |
| + } |
| + w := httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, r) |
| + return w |
| + } |
| + if w := api("GET", "/api/v1/meetings", "", token[1], false); w.Code != 200 { |
| + t.Fatalf("bearer API with Home enabled: %d", w.Code) |
| + } |
| + if w := api("POST", "/api/v1/meetings", `{"title":"Agent call"}`, token[1], false); w.Code != 201 { |
| + t.Fatalf("bearer mutation incorrectly requires browser proof: %d", w.Code) |
| + } |
| + if w := api("GET", "/api/v1/meetings", "", "", true); w.Code != 401 { |
| + t.Fatalf("API accepted ambient Home cookie: %d", w.Code) |
| + } |
| + r := httptest.NewRequest("POST", homeTestOrigin+"/meetings/start", nil) |
| + for _, c := range f.cookies { |
| + r.AddCookie(c) |
| + } |
| + w := httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, r) |
| + if w.Code != 403 { |
| + t.Fatalf("browser mutation lost CSRF protection: %d", w.Code) |
| + } |
| + homeRedirect(t, f.do("POST", "/signout", "")) |
| + if w := f.do("GET", target, ""); w.Code != 303 { |
| + t.Fatalf("room survives signout: %d", w.Code) |
| + } |
| +} |
diff --git a/internal/meet/home_router_test.go b/internal/meet/home_router_test.go| new file mode 100644 |
| index 0000000..4382892 |
| --- /dev/null |
| +++ b/internal/meet/home_router_test.go |
| @@ -0,0 +1,331 @@ |
| +package meet |
| + |
| +import ( |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/rastrillo/rastrillo/assertion" |
| + assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "path/filepath" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +const homeTestOrigin = "https://meet.example.test" |
| +const homeTestIssuer = "https://id.example.test" |
| + |
| +type homeTestMail struct{ body string } |
| + |
| +func (m *homeTestMail) Send(_ context.Context, _, _, body string) error { m.body = body; return nil } |
| + |
| +type homeRouterFixture struct { |
| + app *app |
| + handler http.Handler |
| + key *assertioncrypto.Keypair |
| + mail *homeTestMail |
| + cookies map[string]*http.Cookie |
| +} |
| + |
| +func newHomeRouterFixture(t *testing.T) *homeRouterFixture { |
| + t.Helper() |
| + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| + d, err := db.Open(filepath.Join(t.TempDir(), "meet.db"), logger) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + key, err := assertioncrypto.Generate() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sum := sha256.Sum256(key.SignPub()) |
| + id := hex.EncodeToString(sum[:]) |
| + raw, _ := json.Marshal(map[string]string{id: base64.StdEncoding.EncodeToString(key.SignPub())}) |
| + mail := &homeTestMail{} |
| + mux, a, err := build(t.Context(), d, Config{Origin: homeTestOrigin, InstanceKey: "test-only-instance", DEK: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{3}, 32)), AllowedAddresses: []string{"alice@example.test"}, Mailer: mail, Logger: logger, Home: &connect.Credentials{Issuer: homeTestIssuer, ConsumerID: "meet-test", KeysBase64: base64.StdEncoding.EncodeToString(raw), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))}}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return &homeRouterFixture{a, mux, key, mail, map[string]*http.Cookie{}} |
| +} |
| + |
| +func homeKeyID(key *assertioncrypto.Keypair) string { |
| + sum := sha256.Sum256(key.SignPub()) |
| + return hex.EncodeToString(sum[:]) |
| +} |
| +func (f *homeRouterFixture) do(method, path, body string) *httptest.ResponseRecorder { |
| + r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(body)) |
| + for _, c := range f.cookies { |
| + r.AddCookie(c) |
| + } |
| + if method == "POST" { |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + r.Header.Set("Origin", homeTestOrigin) |
| + if path == "/auth/assertion" { |
| + r.Header.Set("Origin", homeTestIssuer) |
| + } |
| + } |
| + w := httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, r) |
| + for _, c := range w.Result().Cookies() { |
| + if c.MaxAge < 0 { |
| + delete(f.cookies, c.Name) |
| + } else { |
| + f.cookies[c.Name] = c |
| + } |
| + } |
| + return w |
| +} |
| +func homeRedirect(t *testing.T, w *httptest.ResponseRecorder) string { |
| + t.Helper() |
| + if w.Code != 303 { |
| + t.Fatalf("redirect status %d: %s", w.Code, w.Body.String()) |
| + } |
| + return w.Header().Get("Location") |
| +} |
| +func (f *homeRouterFixture) local(t *testing.T) { |
| + t.Helper() |
| + homeRedirect(t, f.do("POST", "/signin", "address=alice%40example.test&force=1")) |
| + link := regexp.MustCompile(`https://[^\s<>\"]+/auth/verify\?[^\s<>\"]+`).FindString(f.mail.body) |
| + u, err := url.Parse(link) |
| + if err != nil || link == "" { |
| + t.Fatal("no captured local verification link") |
| + } |
| + landing := homeRedirect(t, f.do("GET", u.RequestURI(), "")) |
| + if landing != "/auth/local/complete" { |
| + t.Fatal("wrong local landing", landing) |
| + } |
| + landing = homeRedirect(t, f.do("GET", landing, "")) |
| + if landing != "/continue" { |
| + t.Fatal("lost local continue", landing) |
| + } |
| + homeRedirect(t, f.do("GET", landing, "")) |
| +} |
| +func (f *homeRouterFixture) exchange(t *testing.T, start, subject string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + target := homeRedirect(t, f.do("GET", start, "")) |
| + u, err := url.Parse(target) |
| + if err != nil || u.Query().Get("consumer") != "meet-test" { |
| + t.Fatal("bad issuer redirect") |
| + } |
| + nonce := u.Query().Get("nonce") |
| + now := time.Now().Unix() |
| + jti, err := assertion.RandomID() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + token, err := assertion.Sign(f.key, assertion.Claims{Version: 1, Issuer: homeTestIssuer, Audience: homeTestOrigin, Subject: subject, KeyID: homeKeyID(f.key), ID: jti, Nonce: nonce, IssuedAt: now, ExpiresAt: now + 90, AuthTime: now}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + callback := f.do("POST", "/auth/assertion", url.Values{"assertion": {token}}.Encode()) |
| + finish := homeRedirect(t, callback) |
| + return f.do("GET", finish, "") |
| +} |
| +func (f *homeRouterFixture) link(t *testing.T) { |
| + f.local(t) |
| + if w := f.do("GET", "/account/home", ""); w.Code != 200 { |
| + t.Fatal("account", w.Code) |
| + } |
| + start := homeRedirect(t, f.do("POST", "/account/home", "")) |
| + if next := homeRedirect(t, f.exchange(t, start, "opaque-home-alice")); next != "/account/home/confirm" { |
| + t.Fatal("confirmation", next) |
| + } |
| + homeRedirect(t, f.do("POST", "/account/home/confirm", "")) |
| + homeRedirect(t, f.do("POST", "/signout", "")) |
| + start = homeRedirect(t, f.do("GET", "/signin", "")) |
| + homeRedirect(t, f.exchange(t, start, "opaque-home-alice")) |
| +} |
| +func TestHomeLinksExistingMemberAndKeepsMeetings(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + created := f.do("POST", "/meetings", "title=Existing+private+meeting") |
| + if created.Code != 200 && created.Code != 303 { |
| + t.Fatal(created.Code, created.Body.String()) |
| + } |
| + var before string |
| + if err := f.app.db.Writer().QueryRow("SELECT created_by FROM meetings LIMIT 1").Scan(&before); err != nil { |
| + t.Fatal(err) |
| + } |
| + homeRedirect(t, f.do("POST", "/signout", "")) |
| + f.link(t) |
| + w := f.do("GET", "/", "") |
| + if w.Code != 200 || !strings.Contains(w.Body.String(), "Existing private meeting") { |
| + t.Fatal("lost existing meeting", w.Code, w.Body.String()) |
| + } |
| + var after string |
| + f.app.db.Writer().QueryRow("SELECT created_by FROM meetings LIMIT 1").Scan(&after) |
| + if after != before { |
| + t.Fatal("changed attribution") |
| + } |
| + var count int |
| + f.app.db.Writer().QueryRow("SELECT count(*) FROM persons").Scan(&count) |
| + if count != 1 { |
| + t.Fatal("Home created person", count) |
| + } |
| + f.app.cfg.allow = allowlist{} |
| + if w = f.do("GET", "/", ""); w.Code != 404 { |
| + t.Fatal("removed member accepted", w.Code) |
| + } |
| +} |
| +func TestUnknownHomeDoesNotCreatePerson(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + start := homeRedirect(t, f.do("GET", "/signin", "")) |
| + w := f.exchange(t, start, "unknown-home-person") |
| + if w.Code != 403 { |
| + t.Fatal(w.Code) |
| + } |
| + var n int |
| + f.app.db.Writer().QueryRow("SELECT count(*) FROM persons").Scan(&n) |
| + if n != 0 { |
| + t.Fatal("Home allocated person", n) |
| + } |
| +} |
| +func TestHomeSessionFailsWhenDisabled(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.link(t) |
| + f.app.home = nil |
| + if w := f.do("GET", "/", ""); w.Code != 404 { |
| + t.Fatal("disabled accepted", w.Code) |
| + } |
| +} |
| +func TestHomeEnabledPreservesInvitedGuestFlow(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + made := f.do("POST", "/meetings", "title=Guest+meeting") |
| + token := regexp.MustCompile(`/j/([A-Za-z0-9_-]{43})`).FindStringSubmatch(made.Body.String()) |
| + sqid := regexp.MustCompile(`/m/([23456789a-z]{10})/join`).FindStringSubmatch(made.Body.String()) |
| + if token == nil || sqid == nil { |
| + t.Fatal("missing meeting links") |
| + } |
| + homeRedirect(t, f.do("POST", "/signout", "")) |
| + path := homeRedirect(t, f.do("GET", "/j/"+token[1], "")) |
| + if path != "/signin" { |
| + t.Fatal(path) |
| + } |
| + if w := f.do("GET", path, ""); w.Code != 200 { |
| + t.Fatal("guest redirected to Home", w.Code) |
| + } |
| + homeRedirect(t, f.do("POST", "/signin", "address=guest%40example.test")) |
| + link := regexp.MustCompile(`https://[^\s<>\"]+/auth/verify\?[^\s<>\"]+`).FindString(f.mail.body) |
| + u, _ := url.Parse(link) |
| + landing := homeRedirect(t, f.do("GET", u.RequestURI(), "")) |
| + if landing != "/auth/local/complete" { |
| + t.Fatal(landing) |
| + } |
| + landing = homeRedirect(t, f.do("GET", landing, "")) |
| + if landing != "/continue" { |
| + t.Fatal("lost pending guest landing", landing) |
| + } |
| + landing = homeRedirect(t, f.do("GET", landing, "")) |
| + if landing != "/m/"+sqid[1]+"/join" { |
| + t.Fatal("lost grant", landing) |
| + } |
| + if w := f.do("GET", landing, ""); w.Code != 200 { |
| + t.Fatal("holding", w.Code) |
| + } |
| + for _, path := range []string{"/", "/account/home"} { |
| + if w := f.do("GET", path, ""); w.Code != 404 { |
| + t.Fatal("guest gained member privilege", path, w.Code) |
| + } |
| + } |
| +} |
| +func TestHomePublicJoinResolvesExistingMember(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.link(t) |
| + made := f.do("POST", "/meetings", "title=Home+meeting") |
| + token := regexp.MustCompile(`/j/([A-Za-z0-9_-]{43})`).FindStringSubmatch(made.Body.String()) |
| + if token == nil { |
| + t.Fatal("missing link") |
| + } |
| + if w := f.do("GET", "/j/"+token[1], ""); w.Code != 303 { |
| + t.Fatal("Home member follow", w.Code) |
| + } |
| + var n int |
| + f.app.db.Writer().QueryRow("SELECT count(*) FROM persons").Scan(&n) |
| + if n != 1 { |
| + t.Fatal("opaque Home subject became person", n) |
| + } |
| + f.app.cfg.allow = allowlist{} |
| + if w := f.do("GET", "/j/"+token[1], ""); w.Code != 404 { |
| + t.Fatal("removed member gained guest grant", w.Code) |
| + } |
| + f.app.db.Writer().QueryRow("SELECT count(*) FROM guest_grants").Scan(&n) |
| + if n != 0 { |
| + t.Fatal("removed Home member acquired grant", n) |
| + } |
| +} |
| +func TestHomeRemovedMemberSigninIsRefusedWithoutServiceError(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.link(t) |
| + homeRedirect(t, f.do("POST", "/signout", "")) |
| + f.app.cfg.allow = allowlist{} |
| + start := homeRedirect(t, f.do("GET", "/signin", "")) |
| + if w := f.exchange(t, start, "opaque-home-alice"); w.Code != 403 { |
| + t.Fatal("removed member should be unknown, not unavailable", w.Code) |
| + } |
| +} |
| +func TestHomeLookupDatabaseFailureKeepsSession(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.link(t) |
| + var before int |
| + f.app.db.Writer().QueryRow("SELECT count(*) FROM sessions").Scan(&before) |
| + if _, err := f.app.db.Writer().Exec("ALTER TABLE persons RENAME TO temporarily_unavailable_persons"); err != nil { |
| + t.Fatal(err) |
| + } |
| + w := f.do("GET", "/", "") |
| + if w.Code == 200 { |
| + t.Fatal("database failure admitted") |
| + } |
| + var after int |
| + f.app.db.Writer().QueryRow("SELECT count(*) FROM sessions").Scan(&after) |
| + if before != after { |
| + t.Fatal("transient lookup failure revoked session") |
| + } |
| + if _, err := f.app.db.Writer().Exec("ALTER TABLE temporarily_unavailable_persons RENAME TO persons"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if w = f.do("GET", "/", ""); w.Code != 200 { |
| + t.Fatal("recovered lookup did not preserve session", w.Code) |
| + } |
| +} |
| + |
| +func TestHomeSigninClearsOnlyDefinitivelyStaleSession(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + cookieName := f.app.home.sessions.CookieName() |
| + saved := f.cookies[cookieName].Value |
| + if _, err := f.app.db.Writer().Exec("ALTER TABLE sessions RENAME TO unavailable_sessions"); err != nil { |
| + t.Fatal(err) |
| + } |
| + homeRedirect(t, f.do("GET", "/signin", "")) |
| + if f.cookies[cookieName] == nil || f.cookies[cookieName].Value != saved { |
| + t.Fatal("temporary storage failure cleared valid cookie") |
| + } |
| + if _, err := f.app.db.Writer().Exec("ALTER TABLE unavailable_sessions RENAME TO sessions"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.app.db.Writer().Exec("DELETE FROM sessions"); err != nil { |
| + t.Fatal(err) |
| + } |
| + start := homeRedirect(t, f.do("GET", "/signin", "")) |
| + if f.cookies[cookieName] != nil { |
| + t.Fatal("stale cookie not cleared") |
| + } |
| + target := homeRedirect(t, f.do("GET", start, "")) |
| + if !strings.HasPrefix(target, homeTestIssuer+"/") { |
| + t.Fatal("stale cookie prevented Home sign-in", target) |
| + } |
| +} |
diff --git a/internal/meet/home_security_test.go b/internal/meet/home_security_test.go| new file mode 100644 |
| index 0000000..55afff7 |
| --- /dev/null |
| +++ b/internal/meet/home_security_test.go |
| @@ -0,0 +1,92 @@ |
| +package meet |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/rastrillo" |
| +) |
| + |
| +func TestHomeCSPPreservesPinnedFrameworkDirectives(t *testing.T) { |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) |
| + baselineHandler, closeBaseline, err := rastrillo.Handler(rastrillo.Options{Mux: mux}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if closeBaseline != nil { |
| + defer closeBaseline() |
| + } |
| + rec := httptest.NewRecorder() |
| + baselineHandler.ServeHTTP(rec, httptest.NewRequest("GET", homeTestOrigin+"/", nil)) |
| + baseline := rec.Header().Get("Content-Security-Policy") |
| + want := strings.Replace(baseline, "form-action 'self'", "form-action 'self' "+homeTestIssuer, 1) |
| + if want == baseline { |
| + t.Fatal("framework form-action changed") |
| + } |
| + f := newHomeRouterFixture(t) |
| + wrapped, done, err := rastrillo.Handler(rastrillo.Options{Mux: f.handler.(*http.ServeMux)}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if done != nil { |
| + defer done() |
| + } |
| + for _, path := range []string{"/signin/local", "/auth/start", "/account/home", "/signed-out"} { |
| + rec := httptest.NewRecorder() |
| + wrapped.ServeHTTP(rec, httptest.NewRequest("GET", homeTestOrigin+path, nil)) |
| + if got := rec.Header().Get("Content-Security-Policy"); got != want { |
| + t.Fatalf("policy drift at %s: %q", path, got) |
| + } |
| + } |
| +} |
| +func TestHomeMutationOriginsAndDuplicateCookies(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + for _, path := range []string{"/signin", "/account/home", "/account/home/confirm", "/signout", "/meetings"} { |
| + r := httptest.NewRequest("POST", homeTestOrigin+path, strings.NewReader("address=alice%40example.test")) |
| + r.Header.Set("Origin", "https://other.example.test") |
| + for _, c := range f.cookies { |
| + r.AddCookie(c) |
| + } |
| + w := httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, r) |
| + if w.Code != 403 { |
| + t.Fatal("cross-origin accepted", path, w.Code) |
| + } |
| + } |
| + r := httptest.NewRequest("GET", homeTestOrigin+"/account/home", nil) |
| + for _, c := range f.cookies { |
| + r.AddCookie(c) |
| + r.AddCookie(c) |
| + } |
| + w := httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, r) |
| + if w.Code != 400 { |
| + t.Fatal("duplicate cookies accepted", w.Code) |
| + } |
| +} |
| +func TestHomeCredentialsFailClosedWhenPartial(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + cfg := f.app.cfg.Config |
| + for _, name := range []string{"issuer", "consumer", "keys", "dek"} { |
| + broken := *cfg.Home |
| + switch name { |
| + case "issuer": |
| + broken.Issuer = "" |
| + case "consumer": |
| + broken.ConsumerID = "" |
| + case "keys": |
| + broken.KeysBase64 = "" |
| + case "dek": |
| + broken.DEKBase64 = "" |
| + } |
| + cfg.Home = &broken |
| + if _, err := resolve(cfg); err == nil { |
| + t.Fatal("partial accepted", name) |
| + } |
| + cfg = f.app.cfg.Config |
| + } |
| +} |
diff --git a/internal/meet/render.go b/internal/meet/render.go| index 2d2d4fb..0583fcb 100644 |
| --- a/internal/meet/render.go |
| +++ b/internal/meet/render.go |
| @@ -1,6 +1,7 @@ |
| package meet |
| import ( |
| + "amadan.net/oficina/home/connect" |
| "bytes" |
| "embed" |
| "html/template" |
| @@ -17,13 +18,16 @@ var appFS embed.FS |
| var assets = rastrillo.NewAssets(appFS) |
| -var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join", "room", "settings", "agent_once"} |
| +var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join", "room", "settings", "agent_once", "home"} |
| var pages = map[string]*template.Template{} |
| func init() { |
| for _, name := range pageNames { |
| pages[name] = buildPage(name) |
| + if name == "home" { |
| + pages[name] = template.Must(pages[name].ParseFS(connect.Templates(), "templates/home_link.html")) |
| + } |
| } |
| } |
diff --git a/internal/meet/store.go b/internal/meet/store.go| index b02015b..b7626e4 100644 |
| --- a/internal/meet/store.go |
| +++ b/internal/meet/store.go |
| @@ -579,3 +579,17 @@ func (s *store) rastrilloTextExpiry(ctx context.Context, table string) (time.Tim |
| } |
| return t, nil |
| } |
| + |
| +// Identity rows use AUTOINCREMENT and are never recycled for another person. |
| +func (s *store) existingHomePerson(ctx context.Context, address string) (Person, error) { |
| + var p Person |
| + err := s.g.WithContext(ctx).Where("email = ?", normalizeAddress(address)).Take(&p).Error |
| + return p, err |
| +} |
| +func (s *store) homePersonByID(ctx context.Context, q interface { |
| + QueryRowContext(context.Context, string, ...any) *sql.Row |
| +}, id int64) (Person, error) { |
| + var p Person |
| + err := q.QueryRowContext(ctx, "SELECT id, email, name FROM persons WHERE id = ?", id).Scan(&p.ID, &p.Email, &p.Name) |
| + return p, err |
| +} |
diff --git a/internal/meet/templates/home.html b/internal/meet/templates/home.html| new file mode 100644 |
| index 0000000..4dafa74 |
| --- /dev/null |
| +++ b/internal/meet/templates/home.html |
| @@ -0,0 +1,2 @@ |
| +{{define "title"}}{{.Home.Title}} · Meet{{end}} |
| +{{define "content"}}{{template "home-connect" .Home}}{{end}} |
diff --git a/internal/meet/templates/index.html b/internal/meet/templates/index.html| index feb4ef9..85dfb4c 100644 |
| --- a/internal/meet/templates/index.html |
| +++ b/internal/meet/templates/index.html |
| @@ -1,6 +1,7 @@ |
| {{define "title"}}Meetings{{end}} |
| {{define "content"}} |
| {{template "page-header" dict "Title" "Meetings"}} |
| +{{if .HomeEnabled}}<p><a href="/account/home" rst-btn="ghost">Connect Home account</a></p>{{end}} |
| <section> |
| <form method="post" action="/meetings/start"><button type="submit" rst-btn="primary lg">Start a call</button></form> |
| </section> |