oficina / meet Public

Start a meeting from the Mac and iPhone apps

The apps could only join. Native sign-in is anchored to a meeting —
nativeTarget accepted /m/{sqid} and /j/{token} and nothing else — so
there was no way to sign in before a meeting existed, and the person with
no link had nowhere to begin. Creating one stayed on the web.

The target set gains "/", the meeting list. An address with no path now
means the instance itself: callscore signs in against it, the browser
lands on the list rather than a meeting, and the result carries no room,
which the app reads as "signed in, nowhere yet". A link still names its
meeting, because landing on the link is what consumes an invitation and
"/" consumes nothing. The set stays closed: a target is written by an
unauthenticated caller and later redirected to with the person's own
session, so anything open here is an open redirect with a credential
behind it.

POST /native/meetings/start is the app's half of the list's one-click
button, behind the same requireMember and through the same
store.createMeeting. The web answers that button with the link-once
page, which is a screen and not something Swift can read without
pretending to be a browser; this answers the sqid and the title. It does
not answer a link: a link is a credential, and the room's Share already
mints one when a member asks. Unlike /native/request and
/native/exchange it runs on ambient authority, so it sits behind both
locks — the JSON content type an HTML form cannot send, and the Origin
csrf.Protect reads.

The origin, room-shape and redirect rules came out of newTransport into
checkOrigin, validRoom and safeClient, so the one request this package
makes outside a transport obeys them too — in particular the rule about
never following a redirect while holding a session, which is how a
sign-in page comes back as its 303 instead of being decoded as JSON.

In the lobby, Start meeting is the primary act for the person who
arrived without a link, and Start another meeting sits beside Join once
there is a meeting in hand. The identifier is kept after a call ends, so
rejoining what you started is one button rather than a second meeting.
Both apps share CallsLobby, so this is iPhone as well as Mac.

The invitation test names oficina-meet, not oficina-calls: the custom
scheme was deliberately not renamed, for the reason the /m/ and /j/
routes were not — it is a registered handler, matched against links
already sent and apps already installed.

Gates: make ci, make native-test, swift test, make native-browser and
make browser. Five Go tests on the endpoint, five on StartMeeting, two on
the origin-only sign-in, five in Swift.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 69de6496e82af98994552a4b271f1369662cd760 parent 6ced127
13 files changed, +722 −39
  • callscore/auth.go +23 −9
  • callscore/auth_test.go +54 −0
  • callscore/meetings.go +75 −0
  • callscore/meetings_test.go +113 −0
  • callscore/transport.go +41 −9
  • internal/calls/app.go +1 −0
  • internal/calls/native_auth.go +53 −0
  • internal/calls/native_auth_test.go +131 −0
  • native/README.md +16 −5
  • native/apple/Sources/CallsKit/CallsLobby.swift +47 −9
  • native/apple/Sources/CallsKit/CallsModel.swift +64 −0
  • native/apple/Tests/CallsKitTests/StartMeetingTests.swift +71 −0
  • native/bridge/bridge.go +33 −7
diff --git a/callscore/auth.go b/callscore/auth.go
index 15e4847..ca3e589 100644
--- a/callscore/auth.go
+++ b/callscore/auth.go
@@ -40,14 +40,22 @@ func BeginAuthentication(ctx context.Context, address string) (*Authentication,
if err != nil || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" {
return nil, errors.New("enter a full meeting or invitation link")
}
- parts := strings.Split(strings.Trim(u.Path, "/"), "/")
- if len(parts) == 3 && parts[0] == "m" && parts[2] == "join" {
- parts = parts[:2]
- }
- if len(parts) != 2 || (parts[0] != "m" && parts[0] != "j") || len(parts[1]) == 0 || len(parts[1]) > 128 || strings.IndexFunc(parts[1], func(r rune) bool {
- return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-')
- }) >= 0 {
- return nil, errors.New("enter a full meeting or invitation link")
+ // An address with no path at all is the instance itself, and the
+ // target is the meeting list: the person signing in has no link
+ // because the meeting they are about to be in does not exist yet.
+ // Everything downstream of here treats an empty Room as that case.
+ target := "/"
+ if trimmed := strings.Trim(u.Path, "/"); trimmed != "" {
+ parts := strings.Split(trimmed, "/")
+ if len(parts) == 3 && parts[0] == "m" && parts[2] == "join" {
+ parts = parts[:2]
+ }
+ if len(parts) != 2 || (parts[0] != "m" && parts[0] != "j") || len(parts[1]) == 0 || len(parts[1]) > 128 || strings.IndexFunc(parts[1], func(r rune) bool {
+ return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-')
+ }) >= 0 {
+ return nil, errors.New("enter a full meeting or invitation link")
+ }
+ target = "/" + strings.Join(parts, "/")
}
origin := u.Scheme + "://" + u.Host
transport, err := newTransport(origin, "auth", &http.Client{})
@@ -60,7 +68,7 @@ func BeginAuthentication(ctx context.Context, address string) (*Authentication,
cancel()
return nil, errors.New("cannot start sign-in")
}
- a := &Authentication{origin: origin, verifier: base64.RawURLEncoding.EncodeToString(raw), target: "/" + strings.Join(parts, "/"), client: transport.http, ctx: ctx, cancel: cancel}
+ a := &Authentication{origin: origin, verifier: base64.RawURLEncoding.EncodeToString(raw), target: target, client: transport.http, ctx: ctx, cancel: cancel}
hash := sha256.Sum256([]byte(a.verifier))
res, err := a.post("request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(hash[:]), "target": a.target})
if err != nil {
@@ -141,6 +149,12 @@ func (a *Authentication) Wait() (*AuthenticationResult, error) {
if res.StatusCode != 200 || err != nil || out.Name != expected || !authToken(out.Value) || out.Target != a.target {
return nil, errors.New("sign-in expired or was refused; try again")
}
+ // The meeting list signs in without a meeting: there is no room
+ // to name, no invitation to follow and nothing to validate. The
+ // host reads the empty Room as "signed in, nowhere yet".
+ if a.target == "/" {
+ return &AuthenticationResult{Origin: a.origin, Cookies: []SessionCookie{{Name: out.Name, Value: out.Value}}}, nil
+ }
room := strings.TrimPrefix(a.target, "/m/")
if strings.HasPrefix(a.target, "/j/") {
req, _ := http.NewRequestWithContext(a.ctx, "GET", a.origin+a.target, nil)
diff --git a/callscore/auth_test.go b/callscore/auth_test.go
index d53b018..634e33e 100644
--- a/callscore/auth_test.go
+++ b/callscore/auth_test.go
@@ -75,3 +75,57 @@ func TestAuthenticationRejectsUnsafeAddress(t *testing.T) {
}
}
}
+
+func TestAuthenticationWithoutAPathTargetsTheMeetingList(t *testing.T) {
+ // The person who has no link, because the meeting they are about to be
+ // in does not exist yet. The target is the list, and the result carries
+ // no room: the host reads that as "signed in, nowhere yet".
+ var target atomic.Value
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var in map[string]string
+ json.NewDecoder(r.Body).Decode(&in)
+ switch r.URL.Path {
+ case "/native/request":
+ target.Store(in["target"])
+ json.NewEncoder(w).Encode(map[string]string{"id": strings.Repeat("A", 43)})
+ case "/native/exchange":
+ json.NewEncoder(w).Encode(map[string]string{
+ // 43 base64url zeros: the only 43-character run that both
+ // decodes to thirty-two bytes and re-encodes to itself,
+ // which is what authToken insists on.
+ "name": "rastrillo_session", "value": strings.Repeat("A", 43), "target": "/",
+ })
+ }
+ }))
+ defer server.Close()
+ for _, address := range []string{server.URL, server.URL + "/"} {
+ a, err := BeginAuthentication(t.Context(), address)
+ if err != nil {
+ t.Fatal(address, err)
+ }
+ if target.Load() != "/" {
+ t.Fatal("wrong target", target.Load())
+ }
+ result, err := a.Wait()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Room != "" {
+ t.Fatal("a list sign-in named a meeting", result.Room)
+ }
+ if len(result.Cookies) != 1 {
+ t.Fatal("no session")
+ }
+ }
+}
+
+func TestAuthenticationStillRejectsAPathThatIsNotAMeeting(t *testing.T) {
+ // Widening the empty path to mean "the instance" must not widen
+ // anything else: a path that is present and wrong is still an error,
+ // not a quiet fallback to the list.
+ for _, address := range []string{"https://example.test/settings", "https://example.test/m", "https://example.test/m/a/b", "https://example.test/x/abc"} {
+ if _, err := BeginAuthentication(t.Context(), address); err == nil {
+ t.Errorf("accepted %q", address)
+ }
+ }
+}
diff --git a/callscore/meetings.go b/callscore/meetings.go
new file mode 100644
index 0000000..bac90e6
--- /dev/null
+++ b/callscore/meetings.go
@@ -0,0 +1,75 @@
+package callscore
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "time"
+)
+
+// StartMeeting creates a meeting on the instance this client is signed
+// in to and answers its identifier. It is the app's half of the meeting
+// list's one-click button, and it exists because that button's web
+// answer is the link-once *page* — a screen, and not something a Swift
+// app can read without pretending to be a browser.
+//
+// The identifier is all it answers. A link is a credential, and the
+// room already has a share flow that mints one when a member asks for
+// it (Client.ShareLink), so nothing that merely starts a meeting has to
+// hold one, log one, or decide when to forget it.
+//
+// The session is the caller's: an ordinary browser session carried in
+// the client's cookie jar, exactly as Room uses. Nothing here mints or
+// accepts an agent bearer token.
+func StartMeeting(ctx context.Context, origin string, client *http.Client) (string, error) {
+ trimmed, err := checkOrigin(origin)
+ if err != nil {
+ return "", err
+ }
+ safe, err := safeClient(client)
+ if err != nil {
+ return "", err
+ }
+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed+"/native/meetings/start", bytes.NewReader([]byte("{}")))
+ if err != nil {
+ return "", errors.New("cannot build Calls request")
+ }
+ req.Header.Set("Accept", "application/json")
+ // Both locks the server asks for: the content type an HTML form
+ // cannot send, and the origin csrf.Protect reads. A POST carrying
+ // neither is refused before the handler sees it.
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Origin", trimmed)
+ res, err := safe.Do(req)
+ if err != nil {
+ return "", errors.New("cannot reach Calls")
+ }
+ defer res.Body.Close()
+ switch {
+ case res.StatusCode == http.StatusOK:
+ case res.StatusCode == http.StatusUnauthorized, res.StatusCode == http.StatusSeeOther:
+ // The session went while the app was holding it. Redirects are
+ // never followed here, so a sign-in page arrives as its 303 and
+ // is read as one rather than decoded as JSON.
+ return "", errors.New("sign in to Calls again")
+ case res.StatusCode == http.StatusForbidden, res.StatusCode == http.StatusNotFound:
+ // requireMember's refusal. A guest may join a meeting and may
+ // not start one, and saying which would tell an outsider
+ // something about the team.
+ return "", errors.New("this account cannot start meetings on that Calls server")
+ default:
+ return "", errors.New("cannot start a meeting just now")
+ }
+ var out struct {
+ Sqid string `json:"sqid"`
+ }
+ if json.NewDecoder(io.LimitReader(res.Body, maxResponse)).Decode(&out) != nil || !validRoom(out.Sqid) {
+ return "", errors.New("Calls answered with a meeting this app cannot open")
+ }
+ return out.Sqid, nil
+}
diff --git a/callscore/meetings_test.go b/callscore/meetings_test.go
new file mode 100644
index 0000000..d96b42c
--- /dev/null
+++ b/callscore/meetings_test.go
@@ -0,0 +1,113 @@
+package callscore
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+)
+
+func TestStartMeetingSendsBothLocksAndAnswersTheSqid(t *testing.T) {
+ var origin, contentType, method, path atomic.Value
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ origin.Store(r.Header.Get("Origin"))
+ contentType.Store(r.Header.Get("Content-Type"))
+ method.Store(r.Method)
+ path.Store(r.URL.Path)
+ json.NewEncoder(w).Encode(map[string]string{"sqid": "abc123", "title": "Call, 17 Sep 11:23"})
+ }))
+ defer server.Close()
+ sqid, err := StartMeeting(t.Context(), server.URL, server.Client())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if sqid != "abc123" {
+ t.Fatal("wrong meeting", sqid)
+ }
+ if method.Load() != "POST" || path.Load() != "/native/meetings/start" {
+ t.Fatal("wrong request", method.Load(), path.Load())
+ }
+ // csrf.Protect refuses a POST that carries neither, and it is worth a
+ // test because the failure is a 403 on a live server and nothing a
+ // unit test of the handler would ever see.
+ if origin.Load() != server.URL {
+ t.Fatal("origin not sent", origin.Load())
+ }
+ if contentType.Load() != "application/json" {
+ t.Fatal("content type not sent", contentType.Load())
+ }
+}
+
+func TestStartMeetingRefusesAnAnswerItCannotOpen(t *testing.T) {
+ for _, body := range []string{`{"sqid":""}`, `{"sqid":"../admin"}`, `{"sqid":"has space"}`, `{"sqid":"` + strings.Repeat("a", 33) + `"}`, `not json`, `{}`} {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(body))
+ }))
+ if _, err := StartMeeting(t.Context(), server.URL, server.Client()); err == nil {
+ t.Errorf("accepted %q", body)
+ }
+ server.Close()
+ }
+}
+
+func TestStartMeetingNeverFollowsARedirectWithTheSession(t *testing.T) {
+ // A session that went comes back as the sign-in page's redirect. Follow
+ // it and the app carries a credential to wherever it points, then
+ // decodes a page as JSON.
+ var calls atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls.Add(1)
+ http.Redirect(w, r, "/signin", http.StatusSeeOther)
+ }))
+ defer server.Close()
+ _, err := StartMeeting(t.Context(), server.URL, server.Client())
+ if err == nil {
+ t.Fatal("redirect accepted")
+ }
+ if !strings.Contains(err.Error(), "sign in") {
+ t.Fatal("unhelpful error", err)
+ }
+ if calls.Load() != 1 {
+ t.Fatal("followed the redirect", calls.Load())
+ }
+}
+
+func TestStartMeetingRefusesAnUnsafeOriginBeforeSendingAnything(t *testing.T) {
+ var calls atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) }))
+ defer server.Close()
+ for _, origin := range []string{"http://example.test", "https://alice:secret@example.test", "https://example.test?x=1", "https://example.test/m/abc", "", "not a url"} {
+ if _, err := StartMeeting(t.Context(), origin, server.Client()); err == nil {
+ t.Errorf("accepted %q", origin)
+ }
+ }
+ if calls.Load() != 0 {
+ t.Fatal("a refused origin still reached a server", calls.Load())
+ }
+ if _, err := StartMeeting(t.Context(), server.URL, nil); err == nil {
+ t.Fatal("accepted a client with no session")
+ }
+}
+
+func TestStartMeetingNamesTheRefusalItCanDoSomethingAbout(t *testing.T) {
+ for _, c := range []struct {
+ status int
+ want string
+ }{
+ {http.StatusUnauthorized, "sign in"},
+ {http.StatusForbidden, "cannot start meetings"},
+ {http.StatusNotFound, "cannot start meetings"},
+ {http.StatusInternalServerError, "just now"},
+ } {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "no", c.status)
+ }))
+ _, err := StartMeeting(t.Context(), server.URL, server.Client())
+ if err == nil || !strings.Contains(err.Error(), c.want) {
+ t.Errorf("status %d gave %v, wanted something about %q", c.status, err, c.want)
+ }
+ server.Close()
+ }
+}
diff --git a/callscore/transport.go b/callscore/transport.go
index f7f830a..2c4ebd2 100644
--- a/callscore/transport.go
+++ b/callscore/transport.go
@@ -39,22 +39,39 @@ type transport struct {
http *http.Client
}
-func newTransport(origin, room string, client *http.Client) (*transport, error) {
+// checkOrigin is the one place an origin is judged, so a request made
+// outside a transport — StartMeeting is the only one — cannot reach a
+// server this package would otherwise refuse. It answers the origin
+// without its trailing slash, which is also the value the Origin header
+// has to carry for csrf.Protect to recognise it.
+func checkOrigin(origin string) (string, error) {
u, err := url.Parse(origin)
if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") {
- return nil, errors.New("enter a Calls server origin")
+ return "", errors.New("enter a Calls server origin")
}
if u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "127.0.0.1" || u.Hostname() == "localhost" || u.Hostname() == "::1")) {
- return nil, errors.New("Calls requires HTTPS (HTTP is allowed only on loopback)")
- }
- if room == "" || len(room) > 32 || strings.IndexFunc(room, func(r rune) bool { return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') }) >= 0 {
- return nil, errors.New("invalid meeting identifier")
+ return "", errors.New("Calls requires HTTPS (HTTP is allowed only on loopback)")
}
+ return strings.TrimSuffix(origin, "/"), nil
+}
+
+// validRoom is the meeting identifier's shape, checked both on the way
+// in from a person and on the way back from the server: a sqid this app
+// cannot open is a bad answer, not a meeting.
+func validRoom(room string) bool {
+ return room != "" && len(room) <= 32 && strings.IndexFunc(room, func(r rune) bool {
+ return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9')
+ }) < 0
+}
+
+// safeClient wraps the caller's client for every request this package
+// makes. Never follow a redirect with a credential or accidentally decode a
+// sign-in page as protocol JSON. The caller's transport and cookie jar are
+// retained.
+func safeClient(client *http.Client) (*http.Client, error) {
if client == nil {
return nil, errors.New("an authenticated HTTP client is required")
}
- // Never follow a redirect with a credential or accidentally decode a sign-in
- // page as protocol JSON. The caller's transport and cookie jar are retained.
safe := *client
if safe.Transport == nil {
transport := http.DefaultTransport.(*http.Transport).Clone()
@@ -63,7 +80,22 @@ func newTransport(origin, room string, client *http.Client) (*transport, error)
}
safe.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
safe.Timeout = 0 // streams live for five minutes; individual calls have deadlines
- return &transport{strings.TrimSuffix(origin, "/"), room, &safe}, nil
+ return &safe, nil
+}
+
+func newTransport(origin, room string, client *http.Client) (*transport, error) {
+ trimmed, err := checkOrigin(origin)
+ if err != nil {
+ return nil, err
+ }
+ if !validRoom(room) {
+ return nil, errors.New("invalid meeting identifier")
+ }
+ safe, err := safeClient(client)
+ if err != nil {
+ return nil, err
+ }
+ return &transport{trimmed, room, safe}, nil
}
func (t *transport) request(ctx context.Context, method, endpoint string, q url.Values, body []byte) (*http.Response, error) {
diff --git a/internal/calls/app.go b/internal/calls/app.go
index c0fe7f2..cf30892 100644
--- a/internal/calls/app.go
+++ b/internal/calls/app.go
@@ -472,6 +472,7 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err
r.Post("/account/home/confirm", a.home.routes.ConfirmPost)
}
r.Post("/meetings/start", a.startCall)
+ r.Post("/native/meetings/start", a.nativeStartMeeting)
r.Post("/meetings", a.createMeeting)
r.Post("/m/{sqid}/share", a.shareMeeting)
r.Get("/meetings/{sqid}/links/replace", a.confirmReplaceLink)
diff --git a/internal/calls/native_auth.go b/internal/calls/native_auth.go
index 03b76c7..12c11c6 100644
--- a/internal/calls/native_auth.go
+++ b/internal/calls/native_auth.go
@@ -50,7 +50,24 @@ func nativeToken(s string) bool {
b, e := base64.RawURLEncoding.DecodeString(s)
return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s
}
+
+// nativeTarget is where the app may ask the browser to land, and the
+// set is closed on purpose: a target is written into a pending request
+// by an unauthenticated caller and later redirected to with the
+// person's own session, so anything open here is an open redirect with
+// an ambient credential behind it.
+//
+// Two shapes are one meeting — /m/{sqid} and /j/{token} — and the third
+// is "/", the meeting list. "/" is how the app signs in to an instance
+// rather than to a meeting: the person who has no link yet, because the
+// meeting they are about to be in does not exist until they start it.
+// An app that does hold a link still names it, because landing on the
+// link is what consumes an invitation (spec §3.2) and "/" consumes
+// nothing.
func nativeTarget(s string) bool {
+ if s == "/" {
+ return true
+ }
if !strings.HasPrefix(s, "/") {
return false
}
@@ -256,3 +273,39 @@ func (a *app) nativeComplete(w http.ResponseWriter, r *http.Request) {
}
render(w, "native", a.page(r, map[string]any{"Title": title, "Done": true}))
}
+
+// nativeStartMeeting is POST /native/meetings/start: the app's half of
+// the list's one-click button. The web answers that button with the
+// link-once page, which is a screen and not something a Swift app can
+// read; this answers the one fact the app needs — the meeting's sqid —
+// and leaves the link to the share flow the room already has.
+//
+// It is the same store.createMeeting every other meeting goes through,
+// under the same requireMember as POST /meetings/start, so nothing
+// about who may start a meeting is decided twice.
+//
+// Ambient authority, unlike /native/request and /native/exchange: this
+// runs on the session the app is holding. That is what csrf.Protect
+// above is for — callscore sends Origin on every POST — and the JSON
+// content type is the second lock, because an HTML form cannot send
+// one and a cross-origin fetch that tries is stopped at the preflight.
+func (a *app) nativeStartMeeting(w http.ResponseWriter, r *http.Request) {
+ nativeHeaders(w)
+ if r.Header.Get("Content-Type") != "application/json" {
+ http.Error(w, "expected JSON", http.StatusUnsupportedMediaType)
+ return
+ }
+ p, _ := currentPerson(r)
+ title := callTitle(a.store.now(), a.cfg.zone)
+ m, _, err := a.store.createMeeting(r.Context(), title, nil, actorFor(p))
+ if err != nil {
+ a.logger.Error("calls: native start meeting", "err", err)
+ http.Error(w, "cannot start a meeting", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ // The title is the server's, not the app's: callTitle writes it in
+ // the instance zone, and an app in another one would otherwise name
+ // the meeting an hour it is not.
+ json.NewEncoder(w).Encode(map[string]string{"sqid": m.Sqid, "title": title})
+}
diff --git a/internal/calls/native_auth_test.go b/internal/calls/native_auth_test.go
index e5bdef1..ebd70e5 100644
--- a/internal/calls/native_auth_test.go
+++ b/internal/calls/native_auth_test.go
@@ -198,3 +198,134 @@ func TestNativeGuestInvitationResumesAuthorizationWithoutSignin(t *testing.T) {
t.Fatal("guest lost native handoff", dest)
}
}
+
+// nativeMemberJSON is nativeJSON with the session and the origin: the
+// start endpoint runs on ambient authority, unlike /native/request and
+// /native/exchange, so both of the locks it sits behind have to be in
+// the request for the handler to be reached at all.
+func nativeMemberJSON(f *homeRouterFixture, path, body string) *httptest.ResponseRecorder {
+ r := httptest.NewRequest("POST", homeTestOrigin+path, strings.NewReader(body))
+ r.Header.Set("Content-Type", "application/json")
+ r.Header.Set("Origin", homeTestOrigin)
+ for _, c := range f.cookies {
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ f.handler.ServeHTTP(w, r)
+ return w
+}
+
+func TestNativeStartMeetingAnswersASqidAMemberCanOpen(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ w := nativeMemberJSON(f, "/native/meetings/start", "{}")
+ if w.Code != 200 {
+ t.Fatal("start refused", w.Code, w.Body.String())
+ }
+ if got := w.Header().Get("Cache-Control"); got != "no-store" {
+ t.Fatal("meeting identifier is cacheable", got)
+ }
+ var out map[string]string
+ if json.Unmarshal(w.Body.Bytes(), &out) != nil || !validSqid(out["sqid"]) {
+ t.Fatal("no usable sqid", w.Body.String())
+ }
+ if out["title"] == "" {
+ t.Fatal("no title")
+ }
+ // The sqid is only worth answering if it opens: the app's next act
+ // is to join it, on the same session.
+ if got := f.do("GET", "/m/"+out["sqid"]+"/join", ""); got.Code != 200 {
+ t.Fatal("started meeting will not open", got.Code)
+ }
+}
+
+func TestNativeStartMeetingRefusesWithoutBothLocks(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ // A form content type is the cross-site shape: an HTML form can send
+ // it, and nothing else about the request would stop it.
+ form := httptest.NewRequest("POST", homeTestOrigin+"/native/meetings/start", strings.NewReader(""))
+ form.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ form.Header.Set("Origin", homeTestOrigin)
+ for _, c := range f.cookies {
+ form.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ f.handler.ServeHTTP(w, form)
+ if w.Code != http.StatusUnsupportedMediaType {
+ t.Fatal("form post accepted", w.Code)
+ }
+ // And no origin at all is csrf.Protect's refusal, before the handler.
+ bare := httptest.NewRequest("POST", homeTestOrigin+"/native/meetings/start", strings.NewReader("{}"))
+ bare.Header.Set("Content-Type", "application/json")
+ for _, c := range f.cookies {
+ bare.AddCookie(c)
+ }
+ w = httptest.NewRecorder()
+ f.handler.ServeHTTP(w, bare)
+ if w.Code == 200 {
+ t.Fatal("origin-less post accepted")
+ }
+ var count int
+ if err := f.app.db.Writer().QueryRow("SELECT count(*) FROM meetings").Scan(&count); err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Fatal("a refused request still created a meeting", count)
+ }
+}
+
+func TestNativeStartMeetingRefusesASignedOutApp(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ if w := nativeMemberJSON(f, "/native/meetings/start", "{}"); w.Code == 200 {
+ t.Fatal("signed-out start accepted")
+ }
+}
+
+func TestNativeTargetAcceptsTheMeetingListAndNothingElseUnpathed(t *testing.T) {
+ // "/" is the app signing in to an instance rather than to a meeting:
+ // the person who has no link because the meeting does not exist yet.
+ if !nativeTarget("/") {
+ t.Fatal("the meeting list is not a target")
+ }
+ for _, target := range []string{"", "//", "/?next=evil", "/#evil", "/welcome", "/settings", "/signin"} {
+ if nativeTarget(target) {
+ t.Errorf("unsafe or unintended target %q", target)
+ }
+ }
+}
+
+func TestNativeHandoffWithoutAMeetingLandsOnTheList(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ v := nativeRandom()
+ h := sha256.Sum256([]byte(v))
+ w := nativeJSON(f, "/native/request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(h[:]), "target": "/"})
+ var out map[string]string
+ if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &out) != nil {
+ t.Fatal("request refused", w.Code)
+ }
+ id := out["id"]
+ // No /join is appended to the list the way it is to a meeting.
+ if got := homeRedirect(t, f.do("GET", "/native/start?id="+id, "")); got != "/" {
+ t.Fatal("did not land on the list", got)
+ }
+ // And the resume middleware carries an already-signed-in person
+ // straight to the consent screen from "/", which is the whole point
+ // of the list being a target at all.
+ if got := homeRedirect(t, f.do("GET", "/", "")); got != "/native/authorize?id="+id {
+ t.Fatal("no continuation from the list", got)
+ }
+ if page := f.do("GET", "/native/authorize?id="+id, ""); page.Code != 200 {
+ t.Fatal("no consent screen", page.Code)
+ }
+ homeRedirect(t, f.do("POST", "/native/authorize?id="+id, ""))
+ ex := nativeJSON(f, "/native/exchange", map[string]string{"id": id, "verifier": v})
+ if ex.Code != 200 {
+ t.Fatal("exchange refused", ex.Code, ex.Body.String())
+ }
+ var session map[string]string
+ if json.Unmarshal(ex.Body.Bytes(), &session) != nil || session["target"] != "/" {
+ t.Fatal("target not carried back", ex.Body.String())
+ }
+}
diff --git a/native/README.md b/native/README.md
index 1f4e9fa..8770e08 100644
--- a/native/README.md
+++ b/native/README.md
@@ -11,11 +11,22 @@ make native-mac
open native/build/Calls.app
```
-Paste a meeting or invitation URL. Calls opens your default browser for the
-existing Home/Calls sign-in. Open the emailed link normally, compare the code in
-the browser with the app, and confirm **Sign in to Calls app**. The app continues
-automatically; choose **Join meeting** and allow camera/microphone access.
-Creating and ending meetings, and replacing invitations, stay on the web for this milestone.
+Paste a meeting or invitation URL — or, to start a meeting of your own, your
+instance address alone (`https://calls.oficina.works`). Calls opens your default
+browser for the existing Home/Calls sign-in. Open the emailed link normally,
+compare the code in the browser with the app, and confirm **Sign in to Calls
+app**. The app continues automatically; choose **Join meeting**, or **Start
+meeting** when you signed in without a link, and allow camera/microphone access.
+
+A link signs you in to that one meeting; an address signs you in to the
+instance, which is what **Start meeting** needs. Starting goes through
+`POST /native/meetings/start` — the same `store.createMeeting`, behind the same
+`requireMember`, as the web list's one-click button — and answers only the
+meeting's identifier. The invitation is not part of the answer: a link is a
+credential, and the room's own **Share** is where one is minted.
+
+Scheduling a meeting for later, ending one, and replacing invitations stay on
+the web for this milestone.
Members can use **Share** in the call to retrieve the current invitation, copy
it, or open the system sharing menu. This needs the server's
diff --git a/native/apple/Sources/CallsKit/CallsLobby.swift b/native/apple/Sources/CallsKit/CallsLobby.swift
index 182a696..0448f5d 100644
--- a/native/apple/Sources/CallsKit/CallsLobby.swift
+++ b/native/apple/Sources/CallsKit/CallsLobby.swift
@@ -38,12 +38,25 @@ import SwiftUI
}
Button("Cancel", action: model.cancelSignIn).buttonStyle(.bordered)
} else if model.signedIn {
- Label("Meeting \(model.room)", systemImage: "video")
- .font(.headline).textSelection(.enabled)
+ if model.room.isEmpty {
+ Label("Signed in to \(model.host)", systemImage: "person.crop.circle.badge.checkmark")
+ .font(.headline).textSelection(.enabled)
+ } else {
+ Label("Meeting \(model.room)", systemImage: "video")
+ .font(.headline).textSelection(.enabled)
+ }
Label("Your camera and microphone will be requested when you join.", systemImage: "mic")
.font(.callout).foregroundStyle(.secondary)
- Button("Join meeting", systemImage: "video.fill", action: model.join)
- .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction)
+ // The meeting in hand comes first when there is one. Starting
+ // is the primary act only for the person who arrived without
+ // a link, which is the whole case it was added for.
+ if !model.room.isEmpty {
+ Button("Join meeting", systemImage: "video.fill", action: model.join)
+ .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction)
+ .accessibilityIdentifier("joinMeeting")
+ }
+ if model.canStart { startButton }
+ if model.starting { ProgressView("Starting a meeting") }
Button("Choose another meeting") { model.signedIn = false }
.buttonStyle(.bordered)
#if os(iOS)
@@ -53,7 +66,7 @@ import SwiftUI
} else {
VStack(alignment: .leading, spacing: 10) {
Text("Meeting link").font(.headline)
- TextField("Meeting or invitation link", text: $model.address)
+ TextField("Meeting link, or your Calls address", text: $model.address)
.textFieldStyle(.roundedBorder)
.accessibilityLabel("Meeting link").accessibilityIdentifier("meetingLink")
#if os(iOS)
@@ -64,8 +77,10 @@ import SwiftUI
Button("Continue", systemImage: "arrow.right", action: model.connect)
.buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction)
.disabled(model.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
- Text("Use the meeting or invitation link shared with you.")
- .font(.callout).foregroundStyle(.secondary)
+ Text(
+ "Use the meeting or invitation link shared with you — or your own Calls address, to start a meeting of your own."
+ )
+ .font(.callout).foregroundStyle(.secondary)
}
if !model.error.isEmpty {
Label(model.error, systemImage: "exclamationmark.circle.fill")
@@ -88,9 +103,30 @@ import SwiftUI
.tint(CallsAppearance.accent)
}
+ // Lifted out of the body because the type checker will not carry this
+ // many conditionals inside a ViewBuilder: the whole expression times
+ // out rather than failing on one line, which reads as a compiler
+ // problem and is a shape problem.
+ @ViewBuilder private var startButton: some View {
+ // Two branches rather than a ternary per modifier: buttonStyle's
+ // return type differs between the two, so the choice has to be made
+ // where whole views can differ and not inside one.
+ if model.room.isEmpty {
+ start("Start meeting").buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction)
+ } else {
+ start("Start another meeting").buttonStyle(.bordered)
+ }
+ }
+
+ private func start(_ title: LocalizedStringKey) -> some View {
+ Button(title, systemImage: "video.badge.plus", action: model.startMeeting)
+ .accessibilityIdentifier("startMeeting")
+ .disabled(model.starting)
+ }
+
private var heading: LocalizedStringKey {
if model.authenticating { return "One more step." }
- if model.signedIn { return "Ready to join" }
+ if model.signedIn { return model.room.isEmpty ? "Ready when you are" : "Ready to join" }
return "Meet face to face."
}
@@ -99,7 +135,9 @@ import SwiftUI
return
"Continue in your browser, then confirm that the code matches. We’ll continue here automatically."
}
- if model.signedIn { return "Join when you’re ready." }
+ if model.signedIn {
+ return model.room.isEmpty ? "Start a meeting and share the link from inside it." : "Join when you’re ready."
+ }
return "A place for your team to come together."
}
}
diff --git a/native/apple/Sources/CallsKit/CallsModel.swift b/native/apple/Sources/CallsKit/CallsModel.swift
index e5a7a62..4bf2100 100644
--- a/native/apple/Sources/CallsKit/CallsModel.swift
+++ b/native/apple/Sources/CallsKit/CallsModel.swift
@@ -19,12 +19,28 @@ import Foundation
private var callEpoch = UUID()
@Published public var signedIn = false
@Published public var joining = false
+ /// A meeting is starting on the server; there is nothing to join yet.
+ @Published public var starting = false
+ /// This session may create meetings — it signed in to the instance
+ /// rather than to one meeting, and the server let it. Someone who
+ /// followed a link signs in to that meeting and nothing else, so the
+ /// button is theirs only once they have signed in without one.
+ @Published public var canStart = false
+ /// Empty while signed in with no meeting: the state a person is in
+ /// between starting one and finishing the last.
@Published public var room = ""
@Published public var origin = ""
public let call = Call()
private var cookies = ""
public init() {}
+ /// The instance's host, for a line that says where this app is signed
+ /// in. The scheme is dropped: it is https for everyone but a developer
+ /// on loopback, and naming it would only make the line longer.
+ public var host: String {
+ URLComponents(string: origin)?.host ?? origin
+ }
+
public func openMeetingURL(_ url: URL) {
guard !joining else {
error = "Leave the current meeting before opening another."
@@ -47,6 +63,8 @@ import Foundation
}
cancelSignIn()
signedIn = false
+ canStart = false
+ room = ""
error = ""
address = meeting.absoluteString
// Let the person review the meeting address before starting authorization.
@@ -96,6 +114,10 @@ import Foundation
cookies = String(data: try JSONEncoder().encode(response.cookies), encoding: .utf8)!
origin = response.origin
room = response.room
+ // No meeting came back, so the address was the instance itself
+ // and this is the person who has no link because the meeting
+ // does not exist yet. That is exactly who may start one.
+ canStart = response.room.isEmpty
signedIn = true
authenticating = false
signin = nil
@@ -119,6 +141,7 @@ import Foundation
}
public func cancelSignIn() {
authEpoch = UUID()
+ starting = false
authentication?.cancel()
authentication = nil
authenticating = false
@@ -134,6 +157,46 @@ import Foundation
let value: String
}
}
+ /// Creates a meeting on the signed-in instance and joins it. The
+ /// server decides the title, in its own zone, so a Mac in another one
+ /// cannot name a meeting an hour it is not.
+ ///
+ /// The identifier is kept after the call ends rather than cleared, so
+ /// leaving and rejoining the meeting you started is one button and not
+ /// a second meeting. Starting again simply replaces it.
+ public func startMeeting() {
+ guard !starting, !joining else { return }
+ guard canStart else {
+ error = "Sign in with your Calls address to start a meeting."
+ return
+ }
+ starting = true
+ error = ""
+ let origin = self.origin, cookies = self.cookies
+ let operation = callEpoch
+ Task {
+ do {
+ let sqid = try await Task.detached {
+ var error: NSError?
+ let sqid = BridgeStartMeeting(origin, cookies, &error)
+ if let error { throw error }
+ return sqid
+ }.value
+ // A leave or a second start while the server was answering: the
+ // meeting exists and is simply not the one this app is on. It is
+ // left alone rather than ended, because ending someone else's
+ // newer meeting is the worse mistake.
+ guard operation == callEpoch else { return }
+ starting = false
+ room = sqid
+ join()
+ } catch {
+ guard operation == callEpoch else { return }
+ self.error = error.localizedDescription
+ starting = false
+ }
+ }
+ }
public func join() {
callEpoch = UUID()
let operation = callEpoch
@@ -152,6 +215,7 @@ import Foundation
}
public func leave() {
callEpoch = UUID()
+ starting = false
let operation = callEpoch
cancelSignIn()
Task {
diff --git a/native/apple/Tests/CallsKitTests/StartMeetingTests.swift b/native/apple/Tests/CallsKitTests/StartMeetingTests.swift
new file mode 100644
index 0000000..eef7c51
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/StartMeetingTests.swift
@@ -0,0 +1,71 @@
+import XCTest
+
+@testable import CallsKit
+
+/// The guard rails around starting a meeting. The request itself is Go's
+/// (callscore.StartMeeting, gated server-side), so what is worth holding
+/// here is the state the button is allowed to be in at all.
+final class StartMeetingTests: XCTestCase {
+ @MainActor func testStartingNeedsAnInstanceSignInAndSaysSoOtherwise() {
+ let model = CallsModel()
+ // Signed in by following a link: that session is for one meeting, and
+ // the server would refuse the request. Refusing here means the app
+ // does not spend a round trip learning that.
+ model.signedIn = true
+ model.room = "abc123"
+ model.canStart = false
+ model.startMeeting()
+ XCTAssertFalse(model.starting)
+ XCTAssertFalse(model.error.isEmpty)
+ XCTAssertEqual(model.room, "abc123", "a refused start must not disturb the meeting in hand")
+ }
+
+ @MainActor func testStartingIsRefusedDuringACallRatherThanReplacingIt() {
+ let model = CallsModel()
+ model.signedIn = true
+ model.canStart = true
+ model.joining = true
+ model.error = ""
+ model.startMeeting()
+ XCTAssertFalse(model.starting)
+ // Silent, unlike the case above: this is a button that should not have
+ // been reachable, not a person being told something they can act on.
+ XCTAssertTrue(model.error.isEmpty)
+ }
+
+ @MainActor func testLeavingAndCancellingBothPutTheStartingFlagDown() {
+ // `starting` gates the button. Left up by a leave or a cancel it would
+ // be a button nobody can press again for the rest of the session.
+ let model = CallsModel()
+ model.starting = true
+ model.cancelSignIn()
+ XCTAssertFalse(model.starting)
+ model.starting = true
+ model.leave()
+ XCTAssertFalse(model.starting)
+ }
+
+ @MainActor func testAnInvitationClearsWhatTheLastSignInDecided() {
+ let model = CallsModel()
+ model.signedIn = true
+ model.canStart = true
+ model.room = "abc123"
+ // oficina-meet, not oficina-calls: the custom scheme was deliberately
+ // not renamed, for the same reason the /m/ and /j/ routes were not —
+ // it is a registered handler, and links already sent and apps already
+ // installed are matched against it.
+ model.openMeetingURL(
+ URL(string: "oficina-meet://join?url=https%3A%2F%2Fcalls.example.com%2Fj%2Finvitation")!)
+ XCTAssertFalse(model.canStart, "a link is a sign-in to one meeting, not to the instance")
+ XCTAssertEqual(model.room, "")
+ XCTAssertFalse(model.signedIn)
+ }
+
+ @MainActor func testHostIsTheInstanceWithoutItsScheme() {
+ let model = CallsModel()
+ model.origin = "https://calls.oficina.works"
+ XCTAssertEqual(model.host, "calls.oficina.works")
+ model.origin = "http://127.0.0.1:8080"
+ XCTAssertEqual(model.host, "127.0.0.1")
+ }
+}
diff --git a/native/bridge/bridge.go b/native/bridge/bridge.go
index e89ccfa..0d30843 100644
--- a/native/bridge/bridge.go
+++ b/native/bridge/bridge.go
@@ -3,6 +3,7 @@
package bridge
import (
+ "context"
"encoding/base64"
"encoding/json"
"errors"
@@ -19,13 +20,13 @@ type Listener interface{ OnEvent(event string) }
type Client struct{ room *callscore.Room }
-// NewClient uses the ordinary session returned by the browser sign-in handoff.
-// This call does not mint, exchange, or accept an agent bearer token.
-// Cookies are scoped to the exact supplied origin and kept in memory only.
-func NewClient(origin, room, cookiesJSON string, listener Listener) (*Client, error) {
- if listener == nil {
- return nil, errors.New("a listener is required")
- }
+// sessionJar turns the cookies the sign-in handoff returned into the jar
+// every authenticated call in this package is made on. It is shared by
+// NewClient and StartMeeting so the rules about what counts as a session
+// — one cookie, the framework's name, thirty-two decoded bytes — are
+// written once. Cookies are scoped to the exact supplied origin and kept
+// in memory only.
+func sessionJar(origin, cookiesJSON string) (*cookiejar.Jar, error) {
u, err := url.Parse(origin)
if err != nil {
return nil, errors.New("invalid Calls origin")
@@ -56,6 +57,31 @@ func NewClient(origin, room, cookiesJSON string, listener Listener) (*Client, er
if !session {
return nil, errors.New("sign in to Calls first")
}
+ return jar, nil
+}
+
+// StartMeeting creates a meeting on an instance the app is signed in to
+// and answers its identifier, for the app that has no link because the
+// meeting does not exist yet. The caller joins it exactly as it joins a
+// meeting someone sent: NewClient with the same origin and cookies.
+func StartMeeting(origin, cookiesJSON string) (string, error) {
+ jar, err := sessionJar(origin, cookiesJSON)
+ if err != nil {
+ return "", err
+ }
+ return callscore.StartMeeting(context.Background(), origin, &http.Client{Jar: jar})
+}
+
+// NewClient uses the ordinary session returned by the browser sign-in handoff.
+// This call does not mint, exchange, or accept an agent bearer token.
+func NewClient(origin, room, cookiesJSON string, listener Listener) (*Client, error) {
+ if listener == nil {
+ return nil, errors.New("a listener is required")
+ }
+ jar, err := sessionJar(origin, cookiesJSON)
+ if err != nil {
+ return nil, err
+ }
r, err := callscore.New(origin, room, &http.Client{Jar: jar}, func(event callscore.Event) {
b, err := json.Marshal(event)
if err == nil {