Merge branch 'feat/meet-access-settings' into main
41 files changed,
+724
−33
README.md+21 −0internal/meet/app.go+11 −3internal/meet/guest.go+78 −0internal/meet/handlers_join.go+17 −0internal/meet/handlers_room.go+5 −1internal/meet/handlers_settings.go+45 −1internal/meet/home.go+12 −0internal/meet/home_router_test.go+1 −0internal/meet/hub/admission.go+35 −2internal/meet/hub/hub.go+5 −2internal/meet/hub/waiting_room_test.go+59 −0internal/meet/migrations/0004_alter_persons.sql+11 −0internal/meet/migrations/schema.sql+4 −2internal/meet/models.go+22 −10internal/meet/native_auth.go+19 −2internal/meet/native_auth_test.go+60 −0internal/meet/render.go+2 −2internal/meet/standing.go+4 −1internal/meet/store.go+27 −1internal/meet/templates/guest.html+11 −0internal/meet/templates/index.html+1 −1internal/meet/templates/join.html+4 −2internal/meet/templates/meeting_settings.html+12 −0internal/meet/templates/native.html+5 −0internal/meet/templates/partials/meeting-options.html+6 −0internal/meet/templates/settings.html+9 −0internal/meettest/browser_test.go+1 −0internal/meettest/chat_test.go+1 −0internal/meettest/ice_test.go+2 −0internal/meettest/isolation_test.go+1 −0internal/meettest/join_test.go+3 −0internal/meettest/meeting_settings_test.go+132 −0internal/meettest/signal_test.go+2 −0native/apple/Sources/MeetKit/MeetLobby.swift+3 −3native/apple/Sources/MeetKit/MeetModel.swift+27 −0native/apple/Sources/MeetMac/MeetMac.swift+1 −0native/apple/Tests/MeetKitTests/MeetingLinkTests.swift+28 −0native/ios/Sources/MeetPhone.swift+1 −0native/ios/project.yml+6 −0native/scripts/bundle-mac.py+2 −0test/browser/call.mjs+28 −0
CI passed — run details
diff --git a/README.md b/README.md| index 53753fb..eec09c5 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -173,3 +173,24 @@ and what it deliberately leaves out; `AGENTS.md` covers the |
| conventions the code is held to. Spec §15.18 is the disposition of |
| record for what slice 6 delivered, deferred (the acknowledgements |
| page) and declined (the Estilo theme pin, the production promotion). |
| + |
| +### Meeting access settings |
| + |
| +Team members can set **Meeting defaults** in `/settings` and change either |
| +option for an individual meeting from its **Settings** link. New meetings |
| +permit guests and have no waiting room unless the team changes those defaults. |
| +Defaults apply to every creation path, including agents. Existing meetings keep |
| +their original sign-in and waiting-room requirements during migration. |
| + |
| +When sign-in is off, an invitation holder can enter a display name to join as |
| +a guest. Guest sessions cannot create meetings, change settings, or admit other |
| +guests. With the waiting room on, a team member admits guests; with it off, |
| +guests enter directly, subject to the same call capacity limit. Changes apply |
| +at entry; admitted participants keep their participation. Turning the waiting room off |
| +admits queued guests up to the call capacity. |
| + |
| +Invitation and browser holding pages offer **Join in the app**, using the |
| +`oficina-meet://join?url=…` URL scheme registered by the Mac and iOS bundles. |
| +The app opens the meeting link in its lobby for review, then uses the existing |
| +browser authorization flow (including anonymous guest sessions). Browser joining |
| +remains available when the app is not installed. |
diff --git a/internal/meet/app.go b/internal/meet/app.go| index e306538..52e244e 100644 |
| --- a/internal/meet/app.go |
| +++ b/internal/meet/app.go |
| @@ -371,6 +371,10 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| // own lock: hubStanding is a database read. |
| a.hub = hub.New(hub.Config{ |
| Now: st.now, Logger: rc.Logger, StandingFor: a.hubStanding, |
| + WaitingRoom: func(ctx context.Context, sqid string) (bool, error) { |
| + m, _, err := st.meetingBySqid(ctx, sqid) |
| + return m.WaitingRoom, err |
| + }, |
| }) |
| a.signalLimiter = newRateLimiter(30, 60, maxSignalBuckets) |
| a.apiLimiter = newRateLimiter(apiRatePerSecond, apiBurst, maxAgentBuckets) |
| @@ -440,11 +444,12 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| r.Get("/auth/local/complete", a.home.routes.LocalComplete) |
| } |
| r.Get("/j/{token}", a.followLink) |
| + r.Post("/j/{token}/guest", a.joinGuest) |
| - // Signed in, with a persons row. |
| + // A verified or guest session, with a persons row. Guest sessions are |
| + // restricted to participation and native authorization routes. |
| r.Group(func(r chi.Router) { |
| - r.Use(authPlugin.RequireSession) |
| - r.Use(a.requirePerson) |
| + r.Use(a.requireParticipant) |
| r.Get("/", a.index) |
| r.Get("/native/complete", a.nativeComplete) |
| r.Get("/native/authorize", a.nativeAuthorize) |
| @@ -474,6 +479,9 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| r.Get("/meetings/{sqid}/end", a.confirmEnd) |
| r.Post("/meetings/{sqid}/end", a.endMeeting) |
| r.Get("/settings", a.settings) |
| + r.Post("/settings/defaults", a.saveDefaults) |
| + r.Get("/meetings/{sqid}/settings", a.meetingSettings) |
| + r.Post("/meetings/{sqid}/settings", a.saveSettings) |
| r.Post("/settings/agents", a.createAgentHandler) |
| r.Get("/settings/agents/{id}/revoke", a.confirmRevokeAgent) |
| r.Post("/settings/agents/{id}/revoke", a.revokeAgentHandler) |
diff --git a/internal/meet/guest.go b/internal/meet/guest.go| new file mode 100644 |
| index 0000000..4c373e5 |
| --- /dev/null |
| +++ b/internal/meet/guest.go |
| @@ -0,0 +1,78 @@ |
| +package meet |
| + |
| +import ( |
| + "net/http" |
| + "strconv" |
| + "strings" |
| + "unicode/utf8" |
| + |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| + "github.com/go-chi/chi/v5" |
| +) |
| + |
| +// Guest sessions use the ordinary revocable session store, with a distinct |
| +// identity method. They can never resolve to a verified address or team member. |
| +func (a *app) requireParticipant(next http.Handler) http.Handler { |
| + return a.auth.RequireSession(a.requirePerson(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + p, _ := currentPerson(r) |
| + path := r.URL.Path |
| + if p.Anonymous && !strings.HasPrefix(path, "/m/") && path != "/native/authorize" && path != "/native/complete" { |
| + a.ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| + next.ServeHTTP(w, r) |
| + }))) |
| +} |
| +func (a *app) guestPage(w http.ResponseWriter, r *http.Request, m Meeting, token, name, message string) { |
| + nativeHeaders(w) |
| + render(w, "guest", map[string]any{"Title": a.store.title(m), "Token": token, "Name": name, "NameError": message, "NativeURL": nativeJoinURL(a.cfg.Origin + "/j/" + token)}) |
| +} |
| +func (a *app) joinGuest(w http.ResponseWriter, r *http.Request) { |
| + nativeHeaders(w) |
| + if _, signedIn := a.auth.SessionFrom(r); signedIn { |
| + a.followLink(w, r) |
| + return |
| + } |
| + token := chi.URLParam(r, "token") |
| + link, m, live, err := a.store.liveLink(r.Context(), token) |
| + if err != nil { |
| + a.ErrorPage(w, r, 500, "") |
| + return |
| + } |
| + if !live { |
| + a.ErrorPage(w, r, 404, "") |
| + return |
| + } |
| + if m.RequireSignin { |
| + a.setJoinCookie(w, link.TokenHash) |
| + http.Redirect(w, r, signinPath, 303) |
| + return |
| + } |
| + name := strings.TrimSpace(r.PostFormValue("name")) |
| + if name == "" || utf8.RuneCountInString(name) > 80 { |
| + w.WriteHeader(422) |
| + a.guestPage(w, r, m, token, name, "Enter your name (up to 80 characters).") |
| + return |
| + } |
| + p, err := a.store.createGuest(r.Context(), name) |
| + if err != nil { |
| + a.ErrorPage(w, r, 500, "") |
| + return |
| + } |
| + _, live, err = a.store.mintGrantViaLink(r.Context(), link.ID, p.ID) |
| + if err != nil { |
| + a.ErrorPage(w, r, 500, "") |
| + return |
| + } |
| + if !live { |
| + a.ErrorPage(w, r, 404, "") |
| + return |
| + } |
| + value, err := a.nativeAuth.sessions.Mint(sessions.Session{Subject: strconv.FormatInt(p.ID, 10), Method: "guest", AuthTime: a.store.now(), At: a.store.now()}) |
| + if err != nil { |
| + a.ErrorPage(w, r, 500, "") |
| + return |
| + } |
| + http.SetCookie(w, &http.Cookie{Name: a.auth.SessionCookie(), Value: value, Path: "/", HttpOnly: true, Secure: strings.HasPrefix(a.cfg.Origin, "https:"), SameSite: http.SameSiteLaxMode}) |
| + http.Redirect(w, r, "/m/"+m.Sqid+"/join", 303) |
| +} |
diff --git a/internal/meet/handlers_join.go b/internal/meet/handlers_join.go| index 9a989f5..93c72b0 100644 |
| --- a/internal/meet/handlers_join.go |
| +++ b/internal/meet/handlers_join.go |
| @@ -3,7 +3,9 @@ package meet |
| import ( |
| "amadan.net/rastrillo/rastrillo/sessions" |
| "errors" |
| + "html/template" |
| "net/http" |
| + "net/url" |
| "github.com/go-chi/chi/v5" |
| ) |
| @@ -25,6 +27,10 @@ func (a *app) followLink(w http.ResponseWriter, r *http.Request) { |
| id, signedIn := a.auth.SessionFrom(r) |
| if !signedIn { |
| a.setJoinCookie(w, link.TokenHash) |
| + if !m.RequireSignin { |
| + a.guestPage(w, r, m, chi.URLParam(r, "token"), "", "") |
| + return |
| + } |
| http.Redirect(w, r, signinPath, http.StatusSeeOther) |
| return |
| } |
| @@ -38,6 +44,11 @@ func (a *app) followLink(w http.ResponseWriter, r *http.Request) { |
| a.ErrorPage(w, r, http.StatusInternalServerError, "") |
| return |
| } |
| + if p.Anonymous && m.RequireSignin { |
| + a.setJoinCookie(w, link.TokenHash) |
| + http.Redirect(w, r, signinPath, 303) |
| + return |
| + } |
| if !a.isMember(p) { |
| // Via the link, not the meeting: this handler read the link |
| // before it resolved the person, and a member may have replaced |
| @@ -77,5 +88,11 @@ func (a *app) holding(w http.ResponseWriter, r *http.Request) { |
| "Sqid": m.Sqid, "Title": a.store.title(m), "Starts": a.fmtStart(m.StartsAt), |
| "Name": p.Name, "Standing": st.String(), "IsMember": st == StandingMember, |
| "Floor": !a.relayConfigured(), "OpenElsewhere": openElsewhere, |
| + "NativeURL": nativeJoinURL(a.cfg.Origin + "/m/" + m.Sqid), |
| })) |
| } |
| + |
| +// Only app-constructed meeting URLs are promoted to the custom URL scheme. |
| +func nativeJoinURL(meeting string) template.URL { |
| + return template.URL("oficina-meet://join?url=" + url.QueryEscape(meeting)) |
| +} |
diff --git a/internal/meet/handlers_room.go b/internal/meet/handlers_room.go| index 7a6c808..f5c5176 100644 |
| --- a/internal/meet/handlers_room.go |
| +++ b/internal/meet/handlers_room.go |
| @@ -45,7 +45,11 @@ func (a *app) roomAccess(w http.ResponseWriter, r *http.Request) (Meeting, Perso |
| // longer a way into this page. Relabelling one as a guest would |
| // hand an ex-member the room for as long as the hub happened to |
| // hold their participation, which is the opposite of the rule. |
| - _, kind, live := a.hub.LiveKindForSession(sqid, session) |
| + peer, kind, live := a.hub.LiveKindForSession(sqid, session) |
| + if p.Anonymous && m.RequireSignin { |
| + participant, found := a.hub.Find(sqid, session, peer) |
| + live = live && found && participant.Admitted |
| + } |
| if !live || kind == hub.KindMember { |
| a.ErrorPage(w, r, http.StatusNotFound, "") |
| return Meeting{}, Person{}, StandingNone, false |
diff --git a/internal/meet/handlers_settings.go b/internal/meet/handlers_settings.go| index 777cb3a..a3d2842 100644 |
| --- a/internal/meet/handlers_settings.go |
| +++ b/internal/meet/handlers_settings.go |
| @@ -50,9 +50,14 @@ func (a *app) renderSettings(w http.ResponseWriter, r *http.Request, status int, |
| Revoked: ag.RevokedAt != nil, Principal: principal(ag, p), |
| }) |
| } |
| + defaults, err := a.store.meetingDefaults(r.Context()) |
| + if err != nil { |
| + a.ErrorPage(w, r, 500, "") |
| + return |
| + } |
| w.WriteHeader(status) |
| render(w, "settings", a.page(r, map[string]any{ |
| - "Agents": rows, "Name": name, "NameError": nameErr, |
| + "Defaults": defaults, "Saved": r.URL.Query().Get("saved") == "1", "Agents": rows, "Name": name, "NameError": nameErr, |
| })) |
| } |
| @@ -146,3 +151,42 @@ func (a *app) revokeAgentHandler(w http.ResponseWriter, r *http.Request) { |
| } |
| http.Redirect(w, r, "/settings", http.StatusSeeOther) |
| } |
| + |
| +func (a *app) saveDefaults(w http.ResponseWriter, r *http.Request) { |
| + if err := a.store.saveMeetingDefaults(r.Context(), r.PostFormValue("require_signin") == "on", r.PostFormValue("waiting_room") == "on"); err != nil { |
| + a.ErrorPage(w, r, 500, "") |
| + return |
| + } |
| + http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther) |
| +} |
| +func (a *app) meetingSettings(w http.ResponseWriter, r *http.Request) { |
| + m, ok := a.meetingForMember(w, r) |
| + if !ok { |
| + return |
| + } |
| + if m.EndedAt != nil { |
| + a.ErrorPage(w, r, 404, "") |
| + return |
| + } |
| + render(w, "meeting_settings", a.page(r, map[string]any{"Meeting": m, "Title": a.store.title(m), "Saved": r.URL.Query().Get("saved") == "1"})) |
| +} |
| +func (a *app) saveSettings(w http.ResponseWriter, r *http.Request) { |
| + m, ok := a.meetingForMember(w, r) |
| + if !ok { |
| + return |
| + } |
| + if m.EndedAt != nil { |
| + a.ErrorPage(w, r, 404, "") |
| + return |
| + } |
| + if err := a.store.saveMeetingSettings(r.Context(), m.ID, r.PostFormValue("require_signin") == "on", r.PostFormValue("waiting_room") == "on"); err != nil { |
| + a.ErrorPage(w, r, 500, "") |
| + return |
| + } |
| + if r.PostFormValue("waiting_room") != "on" { |
| + if err := a.hub.RefreshWaiting(r.Context(), m.Sqid); err != nil { |
| + a.logger.Error("meet: refresh waiting room", "err", err) |
| + } |
| + } |
| + http.Redirect(w, r, "/meetings/"+m.Sqid+"/settings?saved=1", http.StatusSeeOther) |
| +} |
diff --git a/internal/meet/home.go b/internal/meet/home.go| index 28026d4..15d7d38 100644 |
| --- a/internal/meet/home.go |
| +++ b/internal/meet/home.go |
| @@ -6,6 +6,7 @@ import ( |
| "database/sql" |
| "errors" |
| "net/http" |
| + "strconv" |
| "amadan.net/oficina/home/connect" |
| "amadan.net/oficina/home/connect/linkstore" |
| @@ -78,6 +79,17 @@ func (a *app) wireHome() error { |
| } |
| func (a *app) personForSession(ctx context.Context, s sessions.Session) (Person, error) { |
| + if s.Method == "guest" { |
| + id, err := strconv.ParseInt(s.Subject, 10, 64) |
| + if err != nil { |
| + return Person{}, errHomeIdentity |
| + } |
| + p, ok, err := a.store.personByID(ctx, id) |
| + if err != nil || !ok || !p.Anonymous { |
| + return Person{}, errHomeIdentity |
| + } |
| + return p, nil |
| + } |
| if s.Method == connect.HomeMethod { |
| if a.home == nil { |
| return Person{}, errHomeIdentity |
diff --git a/internal/meet/home_router_test.go b/internal/meet/home_router_test.go| index 71058ca..620ee70 100644 |
| --- a/internal/meet/home_router_test.go |
| +++ b/internal/meet/home_router_test.go |
| @@ -204,6 +204,7 @@ func TestHomeSessionFailsWhenDisabled(t *testing.T) { |
| func TestHomeEnabledPreservesInvitedGuestFlow(t *testing.T) { |
| f := newHomeRouterFixture(t) |
| f.local(t) |
| + f.do("POST", "/settings/defaults", "require_signin=on") |
| 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()) |
diff --git a/internal/meet/hub/admission.go b/internal/meet/hub/admission.go| index 9b96e41..3a97e62 100644 |
| --- a/internal/meet/hub/admission.go |
| +++ b/internal/meet/hub/admission.go |
| @@ -205,6 +205,13 @@ func (h *Hub) Join(ctx context.Context, sqid, session string, peer PeerID) error |
| if err != nil { |
| return err |
| } |
| + waiting := true |
| + if h.cfg.WaitingRoom != nil { |
| + waiting, err = h.cfg.WaitingRoom(ctx, sqid) |
| + if err != nil { |
| + return err |
| + } |
| + } |
| h.mu.Lock() |
| defer h.mu.Unlock() |
| p, found := h.findLocked(sqid, session, peer) |
| @@ -230,7 +237,7 @@ func (h *Hub) Join(ctx context.Context, sqid, session string, peer PeerID) error |
| // Waiting. A guest waits on; a peer the tick promoted to member |
| // while the room was full may retry here, which is the retry |
| // §2.4 promises them. |
| - if kind != KindMember { |
| + if kind != KindMember && waiting { |
| return nil |
| } |
| if !h.admitLocked(r, p) { |
| @@ -239,7 +246,7 @@ func (h *Hub) Join(ctx context.Context, sqid, session string, peer PeerID) error |
| } |
| return nil |
| } |
| - if kind == KindMember { |
| + if kind == KindMember || !waiting { |
| if !h.admitLocked(r, p) { |
| h.sendLocked(p, Message{Type: TypeFull}) |
| return ErrMeshFull |
| @@ -471,3 +478,29 @@ func (h *Hub) End(sqid string) { |
| // §4.5's terminal 410 "ended" into a 404, which sends a browser off |
| // to open a fresh stream on a meeting that is over. |
| } |
| + |
| +// RefreshWaiting rechecks queued guests through the normal entry gate after |
| +// meeting settings change. Database callbacks must run outside the hub lock. |
| +func (h *Hub) RefreshWaiting(ctx context.Context, sqid string) error { |
| + type waitingPeer struct { |
| + session string |
| + peer PeerID |
| + } |
| + h.mu.Lock() |
| + var peers []waitingPeer |
| + if room := h.rooms[sqid]; room != nil { |
| + for _, p := range room.peers { |
| + if p.Joined && !p.Admitted { |
| + peers = append(peers, waitingPeer{p.Session, p.Peer}) |
| + } |
| + } |
| + } |
| + h.mu.Unlock() |
| + for _, p := range peers { |
| + err := h.Join(ctx, sqid, p.session, p.peer) |
| + if err != nil && err != ErrMeshFull && err != ErrUnknownPeer && err != ErrNotYours { |
| + return err |
| + } |
| + } |
| + return nil |
| +} |
diff --git a/internal/meet/hub/hub.go b/internal/meet/hub/hub.go| index 1d0ed84..22c579c 100644 |
| --- a/internal/meet/hub/hub.go |
| +++ b/internal/meet/hub/hub.go |
| @@ -64,8 +64,11 @@ var ( |
| ) |
| type Config struct { |
| - Now func() time.Time |
| - Logger *slog.Logger |
| + // WaitingRoom is read at join time, outside the lock. Nil preserves the |
| + // hub’s original behavior: guests wait for admission. |
| + WaitingRoom func(context.Context, string) (bool, error) |
| + Now func() time.Time |
| + Logger *slog.Logger |
| // StandingFor re-derives a person's kind and whether they still hold |
| // entry authority for the room. ok=false means standing none. |
| StandingFor func(ctx context.Context, room string, personID int64) (kind Kind, ok bool, err error) |
diff --git a/internal/meet/hub/waiting_room_test.go b/internal/meet/hub/waiting_room_test.go| new file mode 100644 |
| index 0000000..ab389cf |
| --- /dev/null |
| +++ b/internal/meet/hub/waiting_room_test.go |
| @@ -0,0 +1,59 @@ |
| +package hub |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestDirectAdmissionKeepsGuestsAndEnforcesCapacity(t *testing.T) { |
| + now := time.Now() |
| + h := testHub(&now) |
| + h.cfg.WaitingRoom = func(context.Context, string) (bool, error) { return false, nil } |
| + for i := 0; i < MaxAdmitted; i++ { |
| + session := fmt.Sprintf("guest-%d", i) |
| + o, err := h.Open("r", session, int64(100+i), "Guest", KindGuest) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := h.Join(testCtx, "r", session, o.Peer); err != nil { |
| + t.Fatal(err) |
| + } |
| + p, _ := h.Find("r", session, o.Peer) |
| + if !p.Admitted || p.Kind != KindGuest { |
| + t.Fatal("direct admission changed authority", p) |
| + } |
| + } |
| + extra, _ := h.Open("r", "extra", 200, "Extra", KindGuest) |
| + if err := h.Join(testCtx, "r", "extra", extra.Peer); err != ErrMeshFull { |
| + t.Fatal("capacity bypassed", err) |
| + } |
| + p, _ := h.Find("r", "extra", extra.Peer) |
| + if p.Admitted { |
| + t.Fatal("overflow admitted") |
| + } |
| +} |
| + |
| +func TestDisablingWaitingRoomAdmitsQueuedGuests(t *testing.T) { |
| + now := time.Now() |
| + h := testHub(&now) |
| + waiting := true |
| + h.cfg.WaitingRoom = func(context.Context, string) (bool, error) { return waiting, nil } |
| + o, _ := h.Open("r", "guest", 100, "Guest", KindGuest) |
| + if err := h.Join(testCtx, "r", "guest", o.Peer); err != nil { |
| + t.Fatal(err) |
| + } |
| + p, _ := h.Find("r", "guest", o.Peer) |
| + if p.Admitted || !p.Joined { |
| + t.Fatal("did not wait") |
| + } |
| + waiting = false |
| + if err := h.RefreshWaiting(testCtx, "r"); err != nil { |
| + t.Fatal(err) |
| + } |
| + p, _ = h.Find("r", "guest", o.Peer) |
| + if !p.Admitted || p.Kind != KindGuest { |
| + t.Fatal("retry not admitted as guest") |
| + } |
| +} |
diff --git a/internal/meet/migrations/0004_alter_persons.sql b/internal/meet/migrations/0004_alter_persons.sql| new file mode 100644 |
| index 0000000..e5e09d4 |
| --- /dev/null |
| +++ b/internal/meet/migrations/0004_alter_persons.sql |
| @@ -0,0 +1,11 @@ |
| +ALTER TABLE `persons` ADD `anonymous` numeric NOT NULL DEFAULT false; |
| + |
| +ALTER TABLE `meetings` ADD `require_signin` numeric NOT NULL DEFAULT false; |
| + |
| +ALTER TABLE `meetings` ADD `waiting_room` numeric NOT NULL DEFAULT false; |
| + |
| +CREATE TABLE `meeting_defaults` (`id` integer PRIMARY KEY AUTOINCREMENT,`require_signin` numeric NOT NULL DEFAULT false,`waiting_room` numeric NOT NULL DEFAULT false); |
| + |
| + |
| +-- Existing invitations retain their original sign-in and admission requirements. |
| +UPDATE meetings SET require_signin = true, waiting_room = true; |
diff --git a/internal/meet/migrations/schema.sql b/internal/meet/migrations/schema.sql| index 33efceb..9147b74 100644 |
| --- a/internal/meet/migrations/schema.sql |
| +++ b/internal/meet/migrations/schema.sql |
| @@ -5,13 +5,15 @@ CREATE TABLE `agents` (`id` integer PRIMARY KEY AUTOINCREMENT,`name` text NOT NU |
| CREATE TABLE `guest_grants` (`id` integer PRIMARY KEY AUTOINCREMENT,`meeting_id` integer NOT NULL,`person_id` integer NOT NULL,`granted_at` datetime,`expires_at` datetime,`admitted_at` datetime,`admitted_by` text); |
| +CREATE TABLE `meeting_defaults` (`id` integer PRIMARY KEY AUTOINCREMENT,`require_signin` numeric NOT NULL DEFAULT false,`waiting_room` numeric NOT NULL DEFAULT false); |
| + |
| CREATE TABLE `meeting_links` (`id` integer PRIMARY KEY AUTOINCREMENT,`meeting_id` integer NOT NULL,`token_hash` text NOT NULL,`created_by` text NOT NULL,`created_at` datetime,`revoked_at` datetime, `token_sealed` blob); |
| -CREATE TABLE `meetings` (`id` integer PRIMARY KEY AUTOINCREMENT,`sqid` text NOT NULL,`title_sealed` blob,`starts_at` datetime,`created_by` text NOT NULL,`created_at` datetime,`ended_at` datetime,`ended_by` text); |
| +CREATE TABLE `meetings` (`id` integer PRIMARY KEY AUTOINCREMENT,`sqid` text NOT NULL,`title_sealed` blob,`starts_at` datetime,`created_by` text NOT NULL,`created_at` datetime,`ended_at` datetime,`ended_by` text, `require_signin` numeric NOT NULL DEFAULT false, `waiting_room` numeric NOT NULL DEFAULT false); |
| CREATE TABLE `pending_joins` (`id` integer PRIMARY KEY AUTOINCREMENT,`meeting_id` integer NOT NULL,`link_id` integer NOT NULL,`address_fingerprint` text NOT NULL,`created_at` datetime,`expires_at` datetime); |
| -CREATE TABLE `persons` (`id` integer PRIMARY KEY AUTOINCREMENT,`email` text NOT NULL,`name` text NOT NULL,`created_at` datetime); |
| +CREATE TABLE `persons` (`id` integer PRIMARY KEY AUTOINCREMENT,`email` text NOT NULL,`name` text NOT NULL,`created_at` datetime, `anonymous` numeric NOT NULL DEFAULT false); |
| CREATE INDEX `idx_agents_operator_person_id` ON `agents`(`operator_person_id`); |
diff --git a/internal/meet/models.go b/internal/meet/models.go| index 45e41c9..5108d00 100644 |
| --- a/internal/meet/models.go |
| +++ b/internal/meet/models.go |
| @@ -13,11 +13,14 @@ var Models = []any{ |
| &GuestGrant{}, |
| &PendingJoin{}, |
| &Agent{}, |
| + &MeetingDefaults{}, |
| } |
| -// Person is a signed-in address. Standing (member, guest, none) is |
| -// never a column; it is computed per request (spec §2.3). |
| +// Person is a verified identity or an anonymous guest. Anonymous guests carry |
| +// an opaque guest: identifier in Email, never a claimed or verified address. |
| +// Standing (member, guest, none) is computed per request, never stored. |
| type Person struct { |
| + Anonymous bool `gorm:"not null;default:false"` |
| ID int64 |
| Email string `gorm:"uniqueIndex;not null"` |
| Name string `gorm:"not null"` |
| @@ -33,14 +36,16 @@ func (Person) TableName() string { return "persons" } |
| // Meeting is a room that may be entered. Its title is sealed; its |
| // times are plaintext because the list sorts on them. |
| type Meeting struct { |
| - ID int64 |
| - Sqid string `gorm:"uniqueIndex;not null"` |
| - TitleSealed []byte `gorm:"column:title_sealed"` |
| - StartsAt *time.Time |
| - CreatedBy string `gorm:"not null"` |
| - CreatedAt time.Time |
| - EndedAt *time.Time |
| - EndedBy string |
| + RequireSignin bool `gorm:"not null;default:false"` |
| + WaitingRoom bool `gorm:"not null;default:false"` |
| + ID int64 |
| + Sqid string `gorm:"uniqueIndex;not null"` |
| + TitleSealed []byte `gorm:"column:title_sealed"` |
| + StartsAt *time.Time |
| + CreatedBy string `gorm:"not null"` |
| + CreatedAt time.Time |
| + EndedAt *time.Time |
| + EndedBy string |
| } |
| // MeetingLink looks up invitations by hash. New tokens are also sealed so |
| @@ -93,3 +98,10 @@ type Agent struct { |
| CreatedAt time.Time |
| RevokedAt *time.Time |
| } |
| + |
| +// MeetingDefaults holds the team’s defaults for newly created meetings. |
| +type MeetingDefaults struct { |
| + ID int64 |
| + RequireSignin bool `gorm:"not null;default:false"` |
| + WaitingRoom bool `gorm:"not null;default:false"` |
| +} |
diff --git a/internal/meet/native_auth.go b/internal/meet/native_auth.go| index e20d9ca..1ef3d52 100644 |
| --- a/internal/meet/native_auth.go |
| +++ b/internal/meet/native_auth.go |
| @@ -208,12 +208,24 @@ func (a *app) nativeAuthorize(w http.ResponseWriter, r *http.Request) { |
| n.mu.Lock() |
| p := n.find(id) |
| valid := p != nil && p.Cookie == "" |
| + cancelHref := "/" |
| + if valid { |
| + cancelHref = p.Target |
| + if strings.HasPrefix(cancelHref, "/m/") { |
| + cancelHref += "/join" |
| + } |
| + } |
| n.mu.Unlock() |
| if !valid { |
| http.NotFound(w, r) |
| return |
| } |
| - render(w, "native", a.page(r, map[string]any{"Title": "Sign in to the Meet app", "Action": "/native/authorize?id=" + id, "Done": false, "Code": protocol.AuthenticationCode(id)})) |
| + person, _ := currentPerson(r) |
| + title := "Sign in to the Meet app" |
| + if person.Anonymous { |
| + title = "Continue to the Meet app" |
| + } |
| + render(w, "native", a.page(r, map[string]any{"CancelHref": cancelHref, "Guest": person.Anonymous, "Title": title, "Action": "/native/authorize?id=" + id, "Done": false, "Code": protocol.AuthenticationCode(id)})) |
| } |
| func (a *app) nativeApprove(w http.ResponseWriter, r *http.Request) { |
| nativeHeaders(w) |
| @@ -237,5 +249,10 @@ func (a *app) nativeApprove(w http.ResponseWriter, r *http.Request) { |
| func (a *app) nativeComplete(w http.ResponseWriter, r *http.Request) { |
| nativeHeaders(w) |
| - render(w, "native", a.page(r, map[string]any{"Title": "Meet is signed in", "Done": true})) |
| + person, _ := currentPerson(r) |
| + title := "Meet is signed in" |
| + if person.Anonymous { |
| + title = "Meet is ready" |
| + } |
| + render(w, "native", a.page(r, map[string]any{"Title": title, "Done": true})) |
| } |
diff --git a/internal/meet/native_auth_test.go b/internal/meet/native_auth_test.go| index be54ef6..c67ab40 100644 |
| --- a/internal/meet/native_auth_test.go |
| +++ b/internal/meet/native_auth_test.go |
| @@ -138,3 +138,63 @@ func TestNativeHandoffResumesAfterHomeAssertion(t *testing.T) { |
| t.Fatal("Home lost native continuation", got) |
| } |
| } |
| + |
| +func TestNativeHandoffPreservesAnonymousGuest(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + m, token, err := f.app.store.createMeeting(t.Context(), "Guest call", nil, "person:1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if w := f.do("POST", "/j/"+token+"/guest", "name=Visitor"); w.Code != 303 { |
| + t.Fatal(w.Code, w.Body.String()) |
| + } |
| + id, v := nativeBegin(t, f) |
| + if w := f.do("GET", "/native/authorize?id="+id, ""); w.Code != 200 || !strings.Contains(w.Body.String(), "Continue as guest") { |
| + t.Fatal(w.Code) |
| + } |
| + if w := f.do("POST", "/native/authorize?id="+id, ""); w.Code != 303 { |
| + t.Fatal(w.Code) |
| + } |
| + w := nativeJSON(f, "/native/exchange", map[string]string{"id": id, "verifier": v}) |
| + if w.Code != 200 { |
| + t.Fatal(w.Code, w.Body.String()) |
| + } |
| + var result map[string]string |
| + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.cookies = map[string]*http.Cookie{result["name"]: {Name: result["name"], Value: result["value"]}} |
| + if w := f.do("GET", "/m/"+m.Sqid+"/join", ""); w.Code != 200 { |
| + t.Fatal("native guest cannot join", w.Code) |
| + } |
| + if w := f.do("GET", "/settings", ""); w.Code != 404 { |
| + t.Fatal("native guest gained member authority", w.Code) |
| + } |
| +} |
| + |
| +func TestNativeGuestInvitationResumesAuthorizationWithoutSignin(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + m, token, err := f.app.store.createMeeting(t.Context(), "Native guest call", nil, "person:1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + verifier := nativeRandom() |
| + challenge := sha256.Sum256([]byte(verifier)) |
| + w := nativeJSON(f, "/native/request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(challenge[:]), "target": "/j/" + token}) |
| + var request map[string]string |
| + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &request) != nil { |
| + t.Fatal(w.Code) |
| + } |
| + if dest := homeRedirect(t, f.do("GET", "/native/start?id="+request["id"], "")); dest != "/j/"+token { |
| + t.Fatal(dest) |
| + } |
| + if w := f.do("GET", "/j/"+token, ""); w.Code != 200 { |
| + t.Fatal("guest invitation required sign-in", w.Code) |
| + } |
| + if dest := homeRedirect(t, f.do("POST", "/j/"+token+"/guest", "name=Visitor")); dest != "/m/"+m.Sqid+"/join" { |
| + t.Fatal(dest) |
| + } |
| + if dest := homeRedirect(t, f.do("GET", "/m/"+m.Sqid+"/join", "")); dest != "/native/authorize?id="+request["id"] { |
| + t.Fatal("guest lost native handoff", dest) |
| + } |
| +} |
diff --git a/internal/meet/render.go b/internal/meet/render.go| index d65efa8..c4eea3b 100644 |
| --- a/internal/meet/render.go |
| +++ b/internal/meet/render.go |
| @@ -44,7 +44,7 @@ func callAssets() fs.FS { |
| return files |
| } |
| -var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join", "room", "settings", "agent_once", "home", "native", "share", "credits"} |
| +var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join", "room", "settings", "agent_once", "home", "native", "share", "credits", "guest", "meeting_settings"} |
| var pages = map[string]*template.Template{} |
| @@ -108,7 +108,7 @@ type Viewer struct { |
| // a.isMember — the team as it stands right now (standing.go) — which |
| // a bare *http.Request cannot answer on its own. |
| func (a *app) page(r *http.Request, data map[string]any) map[string]any { |
| - if p, ok := currentPerson(r); ok { |
| + if p, ok := currentPerson(r); ok && !p.Anonymous { |
| data["Viewer"] = Viewer{Email: p.Email, Member: a.isMember(p)} |
| } |
| return data |
diff --git a/internal/meet/standing.go b/internal/meet/standing.go| index 6723ed7..b0d3188 100644 |
| --- a/internal/meet/standing.go |
| +++ b/internal/meet/standing.go |
| @@ -83,10 +83,13 @@ func (a *app) allowNow() allowlist { |
| // isMember is the lone-shape roster read: the allowlist. |
| func (a *app) isMember(p Person) bool { |
| l := a.allowNow() |
| - return l.addrs != nil && l.allows(p.Email) |
| + return !p.Anonymous && l.addrs != nil && l.allows(p.Email) |
| } |
| func (a *app) standingFor(ctx context.Context, p Person, m Meeting) (Standing, error) { |
| + if p.Anonymous && m.RequireSignin { |
| + return StandingNone, nil |
| + } |
| if a.isMember(p) { |
| return StandingMember, nil |
| } |
diff --git a/internal/meet/store.go b/internal/meet/store.go| index e1f0a1c..26f57e7 100644 |
| --- a/internal/meet/store.go |
| +++ b/internal/meet/store.go |
| @@ -115,6 +115,10 @@ func (s *store) createMeeting(ctx context.Context, title string, startsAt *time. |
| if err != nil { |
| return Meeting{}, "", err |
| } |
| + defaults, err := s.meetingDefaults(ctx) |
| + if err != nil { |
| + return Meeting{}, "", err |
| + } |
| var m Meeting |
| err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| for attempt := 0; ; attempt++ { |
| @@ -122,7 +126,7 @@ func (s *store) createMeeting(ctx context.Context, title string, startsAt *time. |
| if err != nil { |
| return err |
| } |
| - m = Meeting{Sqid: sqid, TitleSealed: sealed, StartsAt: startsAt, CreatedBy: actor, CreatedAt: s.now().UTC()} |
| + m = Meeting{RequireSignin: defaults.RequireSignin, WaitingRoom: defaults.WaitingRoom, Sqid: sqid, TitleSealed: sealed, StartsAt: startsAt, CreatedBy: actor, CreatedAt: s.now().UTC()} |
| res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&m) |
| if res.Error != nil { |
| return res.Error |
| @@ -639,3 +643,25 @@ func (s *store) homePersonByID(ctx context.Context, q interface { |
| err := q.QueryRowContext(ctx, "SELECT id, email, name FROM persons WHERE id = ?", id).Scan(&p.ID, &p.Email, &p.Name) |
| return p, err |
| } |
| + |
| +func (s *store) meetingDefaults(ctx context.Context) (MeetingDefaults, error) { |
| + var d MeetingDefaults |
| + err := s.g.WithContext(ctx).Where("id = ?", 1).Take(&d).Error |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + return MeetingDefaults{ID: 1}, nil |
| + } |
| + return d, err |
| +} |
| +func (s *store) saveMeetingDefaults(ctx context.Context, signin, waiting bool) error { |
| + return s.g.WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"require_signin", "waiting_room"})}).Create(&MeetingDefaults{ID: 1, RequireSignin: signin, WaitingRoom: waiting}).Error |
| +} |
| + |
| +// Meetings are team-owned, as are the other meeting mutations in this store. |
| +func (s *store) saveMeetingSettings(ctx context.Context, id int64, signin, waiting bool) error { |
| + return s.g.WithContext(ctx).Model(&Meeting{}).Where("id = ? AND ended_at IS NULL", id).Select("RequireSignin", "WaitingRoom").Updates(map[string]any{"RequireSignin": signin, "WaitingRoom": waiting}).Error |
| +} |
| +func (s *store) createGuest(ctx context.Context, name string) (Person, error) { |
| + p := Person{Email: "guest:" + nativeRandom(), Name: name, Anonymous: true, CreatedAt: s.now().UTC()} |
| + err := s.g.WithContext(ctx).Create(&p).Error |
| + return p, err |
| +} |
diff --git a/internal/meet/templates/guest.html b/internal/meet/templates/guest.html| new file mode 100644 |
| index 0000000..1fa99ae |
| --- /dev/null |
| +++ b/internal/meet/templates/guest.html |
| @@ -0,0 +1,11 @@ |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}} |
| +{{template "page-header" dict "Title" .Title}} |
| +<p><a href="{{.NativeURL}}" rst-btn>Join in the app</a></p> |
| +<p>If the app isn’t installed, you can join in your browser below.</p> |
| +<form rst-form method="post" action="/j/{{.Token}}/guest"> |
| +{{template "field" dict "ID" "name" "Name" "name" "Label" "Your name" "Required" true "Maxlength" "80" "Value" .Name "Error" .NameError}} |
| +<div rst-form-actions><button type="submit" rst-btn="primary lg">Continue in browser</button></div> |
| +</form> |
| +<p>No account needed. You can also <a href="/signin">sign in</a>.</p> |
| +{{end}} |
diff --git a/internal/meet/templates/index.html b/internal/meet/templates/index.html| index 85dfb4c..d6259d4 100644 |
| --- a/internal/meet/templates/index.html |
| +++ b/internal/meet/templates/index.html |
| @@ -22,7 +22,7 @@ |
| <h3><a href="/m/{{.Sqid}}/join">{{.Title}}</a>{{if .ByAgent}} {{icon "bot"}}{{template "badge" dict "Label" "Agent"}}{{end}}</h3> |
| {{if .ByAgent}}<p>by {{.CreatedByPrincipal}}</p>{{end}} |
| <p>{{if .Starts}}Starts {{.Starts}}{{else}}Starts now{{end}}{{if not .LinkPresent}} · no live link{{end}}</p> |
| - <p><a href="/meetings/{{.Sqid}}/links/replace">Replace the link</a> · <a href="/meetings/{{.Sqid}}/end">End</a></p> |
| + <p><a href="/meetings/{{.Sqid}}/settings">Settings</a> · <a href="/meetings/{{.Sqid}}/links/replace">Replace the link</a> · <a href="/meetings/{{.Sqid}}/end">End</a></p> |
| </article> |
| {{end}} |
| </div> |
diff --git a/internal/meet/templates/join.html b/internal/meet/templates/join.html| index 8575035..9fda346 100644 |
| --- a/internal/meet/templates/join.html |
| +++ b/internal/meet/templates/join.html |
| @@ -6,6 +6,8 @@ |
| {{if .IsMember}}{{template "back-nav" dict "Href" "/" "Label" "Meetings"}}{{end}} |
| <div class="meet-lobby-heading">{{template "page-header" dict "Title" .Title}}</div> |
| {{if .Starts}}<p>Starts {{.Starts}}.</p>{{end}} |
| +<p><a href="{{.NativeURL}}" rst-btn>Join in the app</a></p> |
| +<p>Or use your browser below. The app must be installed to open it.</p> |
| <p>You are {{.Name}}, joining as a {{.Standing}}.</p> |
| {{if .Floor}}<p rst-notice>This team's Meet has no relay yet. Calls usually connect only between people on the same network.</p>{{end}} |
| {{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false" estilo-call-actions><button type="submit" rst-btn aria-label="Share meeting link">Share link</button></form>{{end}} |
| @@ -31,11 +33,11 @@ |
| <div class="meet-controls" estilo-call-actions> |
| <button type="button" id="toggle-mute" rst-btn aria-label="Microphone" aria-pressed="false">{{icon "mic"}}Microphone<span id="toggle-mute-state" class="meet-toggle-state" aria-hidden="true">On</span></button> |
| <button type="button" id="toggle-camera" rst-btn aria-label="Camera" aria-pressed="false">{{icon "video"}}Camera<span id="toggle-camera-state" class="meet-toggle-state" aria-hidden="true">On</span></button> |
| - <button type="button" id="join" rst-btn="primary lg" disabled>Join the call</button> |
| + <button type="button" id="join" rst-btn="primary lg" disabled>Join in browser</button> |
| </div> |
| <p id="status" class="meet-status" role="status"></p> |
| </div> |
| </div> |
| </div> |
| -{{if .IsMember}}<p><a href="/meetings/{{.Sqid}}/end">End the meeting</a></p>{{end}} |
| +{{if .IsMember}}<p><a href="/meetings/{{.Sqid}}/settings">Meeting settings</a></p><p><a href="/meetings/{{.Sqid}}/end">End the meeting</a></p>{{end}} |
| {{end}} |
diff --git a/internal/meet/templates/meeting_settings.html b/internal/meet/templates/meeting_settings.html| new file mode 100644 |
| index 0000000..6dadd24 |
| --- /dev/null |
| +++ b/internal/meet/templates/meeting_settings.html |
| @@ -0,0 +1,12 @@ |
| +{{define "title"}}Meeting settings{{end}} |
| +{{define "content"}} |
| +{{template "back-nav" dict "Href" (printf "/m/%s/join" .Meeting.Sqid) "Label" "Meeting"}} |
| +{{template "page-header" dict "Title" "Meeting settings"}} |
| +<p>{{.Title}}</p> |
| +{{if .Saved}}<p rst-notice role="status">Meeting settings saved.</p>{{end}} |
| +<p>Changes apply when people join. People already admitted can stay in the call.</p> |
| +<form rst-form method="post" action="/meetings/{{.Meeting.Sqid}}/settings"> |
| +{{template "meeting-options" .Meeting}} |
| +<div rst-form-actions><button type="submit" rst-btn="primary lg">Save settings</button></div> |
| +</form> |
| +{{end}} |
diff --git a/internal/meet/templates/native.html b/internal/meet/templates/native.html| index 018627e..ea281e0 100644 |
| --- a/internal/meet/templates/native.html |
| +++ b/internal/meet/templates/native.html |
| @@ -6,8 +6,13 @@ |
| <p>Return to the Meet app to join your meeting. You can close this tab.</p> |
| {{else}} |
| <p>Check that this code matches the one in your Meet app: <strong class="rst-mono">{{.Code}}</strong></p> |
| +{{if .Guest}} |
| +<p>Continue only if you just opened the Meet app on your own device. This lets the app join this meeting as your guest identity.</p> |
| +{{template "confirm-form" dict "Action" .Action "Label" "Continue as guest" "CancelHref" .CancelHref "CancelLabel" "Cancel"}} |
| +{{else}} |
| <p>Continue only if you just started signing in from the Meet app on your own device. This gives that app access to Meet as the account shown above.</p> |
| {{template "confirm-form" dict "Action" .Action "Label" "Sign in to Meet app" "CancelHref" "/" "CancelLabel" "Cancel"}} |
| {{end}} |
| +{{end}} |
| </div> |
| {{end}} |
diff --git a/internal/meet/templates/partials/meeting-options.html b/internal/meet/templates/partials/meeting-options.html| new file mode 100644 |
| index 0000000..95b456e |
| --- /dev/null |
| +++ b/internal/meet/templates/partials/meeting-options.html |
| @@ -0,0 +1,6 @@ |
| +{{define "meeting-options"}} |
| +{{template "field-check" dict "Name" "require_signin" "Label" "Require sign-in to join" "Checked" .RequireSignin}} |
| +<p>Off: anyone with the invitation link can join using their name.</p> |
| +{{template "field-check" dict "Name" "waiting_room" "Label" "Enable waiting room" "Checked" .WaitingRoom}} |
| +<p>On: guests wait for a team member to admit them. Off: guests join directly.</p> |
| +{{end}} |
diff --git a/internal/meet/templates/settings.html b/internal/meet/templates/settings.html| index 91bd11c..e0a7cb2 100644 |
| --- a/internal/meet/templates/settings.html |
| +++ b/internal/meet/templates/settings.html |
| @@ -2,6 +2,15 @@ |
| {{define "content"}} |
| {{template "page-header" dict "Title" "Settings"}} |
| <section> |
| +<h2>Meeting defaults</h2> |
| +<p>Applies to new meetings created by anyone on this team. Existing meetings keep their own settings.</p> |
| +{{if .Saved}}<p rst-notice role="status">Meeting defaults saved.</p>{{end}} |
| +<form rst-form method="post" action="/settings/defaults"> |
| +{{template "meeting-options" .Defaults}} |
| +<div rst-form-actions><button type="submit" rst-btn="primary lg">Save defaults</button></div> |
| +</form> |
| +</section> |
| +<section> |
| <h2>Agents</h2> |
| {{if .Agents}} |
| <div rst-list> |
diff --git a/internal/meettest/browser_test.go b/internal/meettest/browser_test.go| index 03755e0..e7828ce 100644 |
| --- a/internal/meettest/browser_test.go |
| +++ b/internal/meettest/browser_test.go |
| @@ -80,6 +80,7 @@ const ( |
| // and is skipped from inside its own subtest with t.Skip carrying the |
| // exact failure — never deleted, and never quietly loosened. |
| var scenarios = []string{ |
| + "guestdirect", |
| "layout", |
| "devices", |
| "call", |
diff --git a/internal/meettest/chat_test.go b/internal/meettest/chat_test.go| index e4628be..e8bd2bd 100644 |
| --- a/internal/meettest/chat_test.go |
| +++ b/internal/meettest/chat_test.go |
| @@ -84,6 +84,7 @@ func TestChatReachesTheOtherAdmittedPeerWithTheSendersNameAndNotTheSender(t *tes |
| // stream, join — are inlined here instead of reusing that helper. |
| func TestChatNeverReachesAWaitingGuestAndAWaitingGuestCannotSend(t *testing.T) { |
| x, sqid, tok := memberWithMeeting(t) |
| + enableWaitingRoom(t, x, sqid) |
| a := x.browser(t) |
| a.signIn("paul@example.com") |
| sa := a.stream("/m/" + sqid + "/stream") |
diff --git a/internal/meettest/ice_test.go b/internal/meettest/ice_test.go| index 702c5d8..ace13a0 100644 |
| --- a/internal/meettest/ice_test.go |
| +++ b/internal/meettest/ice_test.go |
| @@ -41,6 +41,7 @@ type iceAnswer struct { |
| // isolation_test.go's openIsolationRoom also builds inline, lifted here |
| // for a single guest. |
| func guestJoinedAndWaiting(t *testing.T, x *harness, sqid, tok string) (*client, string) { |
| + enableWaitingRoom(t, x, sqid) |
| t.Helper() |
| g := x.browser(t) |
| g.get("/j/" + tok) |
| @@ -574,6 +575,7 @@ func TestIceHeaderClampsExtremeValues(t *testing.T) { |
| // store read stands between that stale hub state and a 200. |
| func TestIceRefusesAnEndedMeetingEvenForAStillAdmittedGuest(t *testing.T) { |
| x, sqid, tok := memberWithMeeting(t) |
| + enableWaitingRoom(t, x, sqid) |
| if err := x.ctl.SetRelay(relayContract(t)); err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/meettest/isolation_test.go b/internal/meettest/isolation_test.go| index c0f8726..3b14e88 100644 |
| --- a/internal/meettest/isolation_test.go |
| +++ b/internal/meettest/isolation_test.go |
| @@ -45,6 +45,7 @@ type isolationRoom struct { |
| func openIsolationRoom(t *testing.T) *isolationRoom { |
| t.Helper() |
| x, sqid, tok := memberWithMeeting(t) |
| + enableWaitingRoom(t, x, sqid) |
| m := x.browser(t) |
| m.signIn("paul@example.com") |
diff --git a/internal/meettest/join_test.go b/internal/meettest/join_test.go| index e988a1c..9b333dc 100644 |
| --- a/internal/meettest/join_test.go |
| +++ b/internal/meettest/join_test.go |
| @@ -56,6 +56,9 @@ func TestUnknownRevokedAndEndedLinksAreOne404(t *testing.T) { |
| func TestSignedOutOutsiderSignsInThroughTheLinkInTheSameBrowser(t *testing.T) { |
| x, sqid, tok := memberWithMeeting(t) |
| + member := x.browser(t) |
| + member.signIn("paul@example.com") |
| + member.post("/meetings/"+sqid+"/settings", url.Values{"require_signin": {"on"}}) |
| g := x.browser(t) |
| rec := g.get("/j/" + tok) |
| if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin" { |
diff --git a/internal/meettest/meeting_settings_test.go b/internal/meettest/meeting_settings_test.go| new file mode 100644 |
| index 0000000..02b6e84 |
| --- /dev/null |
| +++ b/internal/meettest/meeting_settings_test.go |
| @@ -0,0 +1,132 @@ |
| +package meettest |
| + |
| +import ( |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func enableWaitingRoom(t *testing.T, x *harness, sqid string) { |
| + t.Helper() |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + if r := c.post("/meetings/"+sqid+"/settings", url.Values{"waiting_room": {"on"}}); r.Code != 303 { |
| + t.Fatal(r.Code, r.Body.String()) |
| + } |
| +} |
| + |
| +func TestMeetingDefaultsAndIndependentOverrides(t *testing.T) { |
| + x := newHarness(t, "paul@example.com") |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + old, _ := createMeeting(t, c, "Original", "") |
| + if r := c.post("/settings/defaults", url.Values{"require_signin": {"on"}, "waiting_room": {"on"}}); r.Code != 303 { |
| + t.Fatal(r.Code) |
| + } |
| + next, token := createMeeting(t, c, "Restricted", "") |
| + if r := x.browser(t).get("/j/" + token); r.Code != 303 || r.Header().Get("Location") != "/signin" { |
| + t.Fatal("sign-in not required", r.Code) |
| + } |
| + for _, tc := range []struct { |
| + sqid string |
| + checked bool |
| + }{{old, false}, {next, true}} { |
| + r := c.get("/meetings/" + tc.sqid + "/settings") |
| + if r.Code != 200 || strings.Contains(r.Body.String(), " checked") != tc.checked { |
| + t.Fatal("settings not retained", r.Body.String()) |
| + } |
| + } |
| + if r := c.post("/meetings/"+next+"/settings", url.Values{}); r.Code != 303 { |
| + t.Fatal(r.Code) |
| + } |
| + if r := x.browser(t).get("/j/" + token); r.Code != 200 { |
| + t.Fatal("guest override not applied", r.Code) |
| + } |
| + if r := c.get("/settings"); !strings.Contains(r.Body.String(), " checked") { |
| + t.Fatal("override altered defaults") |
| + } |
| +} |
| + |
| +func TestAnonymousGuestJoinsWithoutMemberPowers(t *testing.T) { |
| + x, sqid, token := memberWithMeeting(t) |
| + g := x.browser(t) |
| + r := g.get("/j/" + token) |
| + if r.Code != 200 || !strings.Contains(r.Body.String(), "oficina-meet://join?") { |
| + t.Fatal("missing guest/app choice", r.Code) |
| + } |
| + if r := g.post("/j/"+token+"/guest", url.Values{"name": {" "}}); r.Code != 422 { |
| + t.Fatal("empty name accepted") |
| + } |
| + if r := g.post("/j/"+token+"/guest", url.Values{"name": {"Visitor"}}); r.Code != 303 { |
| + t.Fatal(r.Code, r.Body.String()) |
| + } |
| + if r := g.get("/m/" + sqid + "/join"); r.Code != 200 || !strings.Contains(r.Body.String(), "Visitor") { |
| + t.Fatal("guest holding", r.Code, r.Body.String()) |
| + } |
| + for _, path := range []string{"/settings", "/meetings/" + sqid + "/settings", "/"} { |
| + if r := g.get(path); r.Code != 404 { |
| + t.Fatal("guest can reach", path, r.Code) |
| + } |
| + } |
| + if r := g.post("/meetings/"+sqid+"/settings", url.Values{"waiting_room": {"on"}}); r.Code != 404 { |
| + t.Fatal("guest can change settings") |
| + } |
| + s := g.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + peer, _ := s.hello() |
| + if res, b := g.signal(sqid, peer, `{"type":"join"}`); res.StatusCode != http.StatusOK { |
| + t.Fatal(res.StatusCode, string(b)) |
| + } |
| + if _, data, ok := s.nextOf("roster", 2*time.Second); !ok || !strings.Contains(string(data), `"kind":"guest"`) { |
| + t.Fatal("guest not directly admitted", string(data)) |
| + } |
| +} |
| + |
| +func TestAnonymousEntryRefusesRestrictedAndRevokedInvitations(t *testing.T) { |
| + x, sqid, token := memberWithMeeting(t) |
| + m := x.browser(t) |
| + m.signIn("paul@example.com") |
| + g := x.browser(t) |
| + m.post("/meetings/"+sqid+"/settings", url.Values{"require_signin": {"on"}}) |
| + if r := g.post("/j/"+token+"/guest", url.Values{"name": {"Visitor"}}); r.Code != 303 || r.Header().Get("Location") != "/signin" { |
| + t.Fatal("restricted guest POST accepted") |
| + } |
| + m.post("/meetings/"+sqid+"/settings", url.Values{}) |
| + g.post("/j/"+token+"/guest", url.Values{"name": {"Visitor"}}) |
| + m.post("/meetings/"+sqid+"/settings", url.Values{"require_signin": {"on"}}) |
| + if r := g.get("/m/" + sqid + "/join"); r.Code != 404 { |
| + t.Fatal("guest bypassed new sign-in requirement") |
| + } |
| + m.post("/meetings/"+sqid+"/links", nil) |
| + if r := x.browser(t).post("/j/"+token+"/guest", url.Values{"name": {"Visitor"}}); r.Code != 404 { |
| + t.Fatal("revoked invitation accepted") |
| + } |
| +} |
| + |
| +func TestTurningOffWaitingRoomAdmitsQueuedAnonymousGuest(t *testing.T) { |
| + x, sqid, token := memberWithMeeting(t) |
| + enableWaitingRoom(t, x, sqid) |
| + g := x.browser(t) |
| + if r := g.post("/j/"+token+"/guest", url.Values{"name": {"Waiting visitor"}}); r.Code != 303 { |
| + t.Fatal(r.Code) |
| + } |
| + s := g.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + peer, _ := s.hello() |
| + if r, b := g.signal(sqid, peer, `{"type":"join"}`); r.StatusCode != 200 { |
| + t.Fatal(r.StatusCode, string(b)) |
| + } |
| + if r := g.getStream("/m/" + sqid + "/ice?peer=" + peer); r.Code != 404 { |
| + t.Fatal("waiting guest admitted prematurely", r.Code) |
| + } |
| + member := x.browser(t) |
| + member.signIn("paul@example.com") |
| + if r := member.post("/meetings/"+sqid+"/settings", url.Values{}); r.Code != 303 { |
| + t.Fatal(r.Code) |
| + } |
| + if _, _, ok := s.nextOf("roster", 2*time.Second); !ok { |
| + t.Fatal("queued guest not admitted after disabling waiting room") |
| + } |
| +} |
diff --git a/internal/meettest/signal_test.go b/internal/meettest/signal_test.go| index 96071e6..8235f7d 100644 |
| --- a/internal/meettest/signal_test.go |
| +++ b/internal/meettest/signal_test.go |
| @@ -449,6 +449,7 @@ func TestSignalStaleGenIsRefusedWith409(t *testing.T) { |
| // guest waits, a member in the same room sees knock. |
| func TestSignalGuestJoinWaitsAndKnocksTheRoom(t *testing.T) { |
| x, sqid, tok := memberWithMeeting(t) |
| + enableWaitingRoom(t, x, sqid) |
| member := x.browser(t) |
| member.signIn("paul@example.com") |
| ms := member.stream("/m/" + sqid + "/stream") |
| @@ -580,6 +581,7 @@ func TestSignallingLogsCarryNoContent(t *testing.T) { |
| c := x.browser(t) |
| c.signIn("paul@example.com") |
| sqid, tok := createMeeting(t, c, "Secret title", "") |
| + enableWaitingRoom(t, x, sqid) |
| s := c.stream("/m/" + sqid + "/stream") |
| defer s.close() |
| peer, _ := s.hello() |
diff --git a/native/apple/Sources/MeetKit/MeetLobby.swift b/native/apple/Sources/MeetKit/MeetLobby.swift| index d824e8c..e5c6794 100644 |
| --- a/native/apple/Sources/MeetKit/MeetLobby.swift |
| +++ b/native/apple/Sources/MeetKit/MeetLobby.swift |
| @@ -21,7 +21,7 @@ import SwiftUI |
| } |
| VStack(alignment: .leading, spacing: 20) { |
| if model.authenticating { |
| - ProgressView("Waiting for sign-in") |
| + ProgressView("Waiting for browser confirmation") |
| if !model.confirmationCode.isEmpty { |
| VStack(alignment: .leading, spacing: 10) { |
| Text(model.confirmationCode).font(.title.monospaced().bold()) |
| @@ -97,9 +97,9 @@ import SwiftUI |
| private var subheading: LocalizedStringKey { |
| if model.authenticating { |
| return |
| - "Open the emailed link in your browser, then confirm “Sign in to Meet app”. We’ll continue here automatically." |
| + "Continue in your browser, then confirm that the code matches. We’ll continue here automatically." |
| } |
| - if model.signedIn { return "You’re signed in. Join when you’re ready." } |
| + if model.signedIn { return "Join when you’re ready." } |
| return "A place for your team to come together." |
| } |
| } |
diff --git a/native/apple/Sources/MeetKit/MeetModel.swift b/native/apple/Sources/MeetKit/MeetModel.swift| index 86af864..85630fb 100644 |
| --- a/native/apple/Sources/MeetKit/MeetModel.swift |
| +++ b/native/apple/Sources/MeetKit/MeetModel.swift |
| @@ -25,6 +25,33 @@ import Foundation |
| private var cookies = "" |
| public init() {} |
| + public func openMeetingURL(_ url: URL) { |
| + guard !joining else { |
| + error = "Leave the current meeting before opening another." |
| + return |
| + } |
| + guard url.scheme == "oficina-meet", url.host == "join", |
| + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), |
| + let target = components.queryItems?.first(where: { $0.name == "url" })?.value, |
| + let meeting = URL(string: target), meeting.scheme == "https", |
| + meeting.host != nil, meeting.user == nil, meeting.password == nil, |
| + meeting.query == nil, meeting.fragment == nil |
| + else { |
| + error = "This meeting link is not valid." |
| + return |
| + } |
| + let path = meeting.path.split(separator: "/") |
| + guard path.count == 2, path[0] == "m" || path[0] == "j" else { |
| + error = "This meeting link is not valid." |
| + return |
| + } |
| + cancelSignIn() |
| + signedIn = false |
| + error = "" |
| + address = meeting.absoluteString |
| + // Let the person review the meeting address before starting authorization. |
| + } |
| + |
| public func connect() { |
| cancelSignIn() |
| authenticating = true |
diff --git a/native/apple/Sources/MeetMac/MeetMac.swift b/native/apple/Sources/MeetMac/MeetMac.swift| index 54d21c1..c0dd4d5 100644 |
| --- a/native/apple/Sources/MeetMac/MeetMac.swift |
| +++ b/native/apple/Sources/MeetMac/MeetMac.swift |
| @@ -10,6 +10,7 @@ import WebRTC |
| var body: some Scene { |
| Window("Meet", id: "main") { |
| ContentView(model: model).frame(minWidth: 760, minHeight: 540).onDisappear { model.leave() } |
| + .onOpenURL { model.openMeetingURL($0) } |
| } |
| .defaultSize(width: 1000, height: 760) |
| .commands { CommandGroup(replacing: .newItem) {} } |
diff --git a/native/apple/Tests/MeetKitTests/MeetingLinkTests.swift b/native/apple/Tests/MeetKitTests/MeetingLinkTests.swift| new file mode 100644 |
| index 0000000..84f2d8e |
| --- /dev/null |
| +++ b/native/apple/Tests/MeetKitTests/MeetingLinkTests.swift |
| @@ -0,0 +1,28 @@ |
| +import XCTest |
| +@testable import MeetKit |
| + |
| +final class MeetingLinkTests: XCTestCase { |
| + @MainActor func testInvitationOpensInLobbyWithoutJoiningOrStartingAuthentication() { |
| + let model = MeetModel() |
| + model.openMeetingURL(URL(string: "oficina-meet://join?url=https%3A%2F%2Fmeet.example.com%2Fj%2Finvitation")!) |
| + XCTAssertEqual(model.address, "https://meet.example.com/j/invitation") |
| + XCTAssertFalse(model.joining) |
| + XCTAssertFalse(model.authenticating) |
| + } |
| + |
| + @MainActor func testRejectsUntrustedURLShapesAndDoesNotInterruptCall() { |
| + let model = MeetModel() |
| + for target in ["file:///j/token", "https://user:secret@meet.example.com/j/token", "https://meet.example.com/settings"] { |
| + var link = URLComponents(string: "oficina-meet://join")! |
| + link.queryItems = [URLQueryItem(name: "url", value: target)] |
| + model.openMeetingURL(link.url!) |
| + XCTAssertEqual(model.address, "") |
| + XCTAssertFalse(model.error.isEmpty) |
| + } |
| + model.joining = true |
| + model.address = "current" |
| + model.openMeetingURL(URL(string: "oficina-meet://join?url=https%3A%2F%2Fmeet.example.com%2Fm%2Froom")!) |
| + XCTAssertEqual(model.address, "current") |
| + XCTAssertTrue(model.joining) |
| + } |
| +} |
diff --git a/native/ios/Sources/MeetPhone.swift b/native/ios/Sources/MeetPhone.swift| index adf6cdb..cdb55ec 100644 |
| --- a/native/ios/Sources/MeetPhone.swift |
| +++ b/native/ios/Sources/MeetPhone.swift |
| @@ -8,6 +8,7 @@ import WebRTC |
| var body: some Scene { |
| WindowGroup { |
| PhoneHome(model: model) |
| + .onOpenURL { model.openMeetingURL($0) } |
| .onChange(of: scenePhase) { _, phase in |
| // This milestone is foreground-only. Browser sign-in is kept alive; |
| // an active call leaves cleanly instead of silently sending audio. |
diff --git a/native/ios/project.yml b/native/ios/project.yml| index 9288c26..fcedfbf 100644 |
| --- a/native/ios/project.yml |
| +++ b/native/ios/project.yml |
| @@ -21,6 +21,12 @@ targets: |
| - Assets.xcassets |
| - path: ../LICENSE.lucide |
| buildPhase: resources |
| + info: |
| + path: ../build/ios-info.plist |
| + properties: |
| + CFBundleURLTypes: |
| + - CFBundleURLName: net.amadan.oficina.meet.join |
| + CFBundleURLSchemes: [oficina-meet] |
| dependencies: |
| - package: MeetNative |
| product: MeetKit |
diff --git a/native/scripts/bundle-mac.py b/native/scripts/bundle-mac.py| index 52d2e6f..79ba965 100644 |
| --- a/native/scripts/bundle-mac.py |
| +++ b/native/scripts/bundle-mac.py |
| @@ -27,6 +27,8 @@ for size in (16, 32, 128, 256, 512): |
| subprocess.run(['iconutil', '-c', 'icns', str(iconset), '-o', |
| str(bundle / 'Resources/MeetMac.icns')], check=True) |
| info = { |
| + 'CFBundleURLTypes': [{'CFBundleURLName': 'net.amadan.oficina.meet.join', |
| + 'CFBundleURLSchemes': ['oficina-meet']}], |
| 'CFBundleName': 'Meet', 'CFBundleDisplayName': 'Meet', |
| 'CFBundleIdentifier': 'net.amadan.oficina.meet', |
| 'CFBundleExecutable': 'MeetMac', 'CFBundlePackageType': 'APPL', |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index 376e8c1..2bcbbf5 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -978,8 +978,14 @@ function leakedCredential(credentials, lines) { |
| // browser: follow the emailed link signed out, ask for a sign-in link, |
| // and click the one that was actually mailed. |
| async function signInAsGuest(ctx, address = GUEST) { |
| + const member = await newContext('configure waiting room', { cookie: cookieA }); |
| + await member.request.post(origin + '/meetings/' + sqid + '/settings', { |
| + headers: { Origin: origin }, form: { waiting_room: 'on' }, |
| + }); |
| + await member.close(); |
| const page = await ctx.newPage(); |
| await page.goto(origin + '/j/' + token, { waitUntil: 'domcontentloaded' }); |
| + if (page.url().includes('/j/')) await page.getByRole('link', { name: 'sign in', exact: true }).click(); |
| check(page.url().startsWith(origin + '/signin'), |
| 'the link sends a signed-out outsider to sign in (at ' + where(page) + ')'); |
| @@ -1133,6 +1139,28 @@ function shareControl(page) { |
| // ── the scenarios ──────────────────────────────────────────────────── |
| const run = { |
| + async guestdirect() { |
| + const member = await newContext('settings owner', { cookie: cookieA, javaScriptEnabled: false }); |
| + const settings = await member.newPage(); |
| + await settings.goto(origin + '/settings'); |
| + check(!(await settings.getByLabel('Require sign-in to join').isChecked()), 'new meeting defaults permit guests'); |
| + check(!(await settings.getByLabel('Enable waiting room').isChecked()), 'waiting room defaults off'); |
| + await settings.screenshot({ path: '/tmp/meet-settings.png', fullPage: true }); |
| + await member.close(); |
| + const guest = await newContext('anonymous guest'); |
| + const page = await guest.newPage(); |
| + await page.goto(origin + '/j/' + token); |
| + check((await page.getByRole('link', { name: 'Join in the app' }).getAttribute('href')).startsWith('oficina-meet://join?'), 'invitation offers the native app'); |
| + await page.getByLabel('Your name').fill('Visitor'); |
| + await Promise.all([ |
| + page.waitForURL('**/m/' + sqid + '/join'), |
| + page.getByRole('button', { name: 'Continue in browser' }).click(), |
| + ]); |
| + check(await page.getByRole('link', { name: 'Join in the app' }).count() === 1, 'holding screen offers the native app'); |
| + await page.locator('#join').click(); |
| + check(await until(async () => (await state(page)).admitted, 20000), 'anonymous guest joins directly without sign-in or admission'); |
| + await guest.close(); |
| + }, |
| // The holding screen reaches real hardware: it enumerates devices, |
| // previews one, and lets the person in. |
| async devices() { |