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>
13 files changed,
+722
−39
callscore/auth.go+23 −9callscore/auth_test.go+54 −0callscore/meetings.go+75 −0callscore/meetings_test.go+113 −0callscore/transport.go+41 −9internal/calls/app.go+1 −0internal/calls/native_auth.go+53 −0internal/calls/native_auth_test.go+131 −0native/README.md+16 −5native/apple/Sources/CallsKit/CallsLobby.swift+47 −9native/apple/Sources/CallsKit/CallsModel.swift+64 −0native/apple/Tests/CallsKitTests/StartMeetingTests.swift+71 −0native/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 { |