oficina / meet Public

Merge branch 'feat/meet-access-settings' into main

paul@keymail.dev 100757ca4c3625561a64a0346e4f6a2b9956c4c3 parent 772c1e0parent 709e4c1
41 files changed, +724 −33
  • README.md +21 −0
  • internal/meet/app.go +11 −3
  • internal/meet/guest.go +78 −0
  • internal/meet/handlers_join.go +17 −0
  • internal/meet/handlers_room.go +5 −1
  • internal/meet/handlers_settings.go +45 −1
  • internal/meet/home.go +12 −0
  • internal/meet/home_router_test.go +1 −0
  • internal/meet/hub/admission.go +35 −2
  • internal/meet/hub/hub.go +5 −2
  • internal/meet/hub/waiting_room_test.go +59 −0
  • internal/meet/migrations/0004_alter_persons.sql +11 −0
  • internal/meet/migrations/schema.sql +4 −2
  • internal/meet/models.go +22 −10
  • internal/meet/native_auth.go +19 −2
  • internal/meet/native_auth_test.go +60 −0
  • internal/meet/render.go +2 −2
  • internal/meet/standing.go +4 −1
  • internal/meet/store.go +27 −1
  • internal/meet/templates/guest.html +11 −0
  • internal/meet/templates/index.html +1 −1
  • internal/meet/templates/join.html +4 −2
  • internal/meet/templates/meeting_settings.html +12 −0
  • internal/meet/templates/native.html +5 −0
  • internal/meet/templates/partials/meeting-options.html +6 −0
  • internal/meet/templates/settings.html +9 −0
  • internal/meettest/browser_test.go +1 −0
  • internal/meettest/chat_test.go +1 −0
  • internal/meettest/ice_test.go +2 −0
  • internal/meettest/isolation_test.go +1 −0
  • internal/meettest/join_test.go +3 −0
  • internal/meettest/meeting_settings_test.go +132 −0
  • internal/meettest/signal_test.go +2 −0
  • native/apple/Sources/MeetKit/MeetLobby.swift +3 −3
  • native/apple/Sources/MeetKit/MeetModel.swift +27 −0
  • native/apple/Sources/MeetMac/MeetMac.swift +1 −0
  • native/apple/Tests/MeetKitTests/MeetingLinkTests.swift +28 −0
  • native/ios/Sources/MeetPhone.swift +1 −0
  • native/ios/project.yml +6 −0
  • native/scripts/bundle-mac.py +2 −0
  • test/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() {