oficina / meet Public

Merge branch 'feat/knock-push' into main

claude agent, operated by paul@keymail.dev 3846ca4803001fb89e11429531ab661cf3d7f8c5 parent 320d950parent 2bbe9ad
22 files changed, +1312 −10
  • README.md +13 −2
  • callscore/meetings.go +49 −0
  • callscore/meetings_test.go +48 −0
  • internal/calls/apns/apns.go +230 −0
  • internal/calls/apns/apns_test.go +276 −0
  • internal/calls/app.go +23 −1
  • internal/calls/config.go +74 −0
  • internal/calls/hub/admission.go +13 −0
  • internal/calls/hub/hub.go +11 −0
  • internal/calls/knockpush.go +132 −0
  • internal/calls/knockpush_test.go +206 −0
  • internal/calls/migrations/0005_create_device_tokens.sql +6 −0
  • internal/calls/migrations/schema.sql +6 −0
  • internal/calls/models.go +30 −0
  • internal/calls/store.go +51 −0
  • native/README.md +14 −7
  • native/apple/Sources/CallsKit/Call.swift +4 −0
  • native/apple/Sources/CallsKit/CallsModel.swift +21 −0
  • native/apple/Sources/CallsKit/RemoteKnocks.swift +51 −0
  • native/apple/Sources/CallsMac/CallsMac.swift +20 −0
  • native/bridge/bridge.go +12 −0
  • native/ios/Sources/CallsPhone.swift +22 −0

CI failed — run details

diff --git a/README.md b/README.md
index 5bd3d35..8982b36 100644
--- a/README.md
+++ b/README.md
@@ -135,8 +135,19 @@ default rather than a refusal: `CALLS_ZONE` (an IANA zone, defaulting
to UTC when unset) and `CARLOS_RELAY` (empty means no relay, §5.5 —
only a *malformed* value refuses startup), delivered by `carlos relay
enable --app calls` rather than set by hand. Secrets —
-`CALLS_INSTANCE_KEY`, `CALLS_DEK` and `CALLS_SMTP_PASS` — go through
-`carlos secrets`; the rest go through `carlos env`. Mail reads
+`CALLS_INSTANCE_KEY`, `CALLS_DEK`, `CALLS_SMTP_PASS` and
+`CALLS_APNS_KEY` — go through `carlos secrets`; the rest go through
+`carlos env`.
+
+Apple push is a sixth group, and all of it or none of it:
+`CALLS_APNS_KEY` (the `.p8` as Apple issues it, PEM and multi-line),
+`CALLS_APNS_KEY_ID`, `CALLS_APNS_TEAM_ID`, `CALLS_APNS_TOPIC_IOS` and
+`CALLS_APNS_TOPIC_MACOS`, with `CALLS_APNS_SANDBOX` for a development
+build's tokens. Unset, the server sends no push and says so nowhere —
+a knock still reaches every member in the room over their own stream,
+which is where this app was before push existed. Partly set, startup
+refuses: half a push key is a mistake to catch at boot rather than in a
+goroutine on the first knock. Mail reads
`CALLS_SMTP_*` with `CARLOS_SMTP_*` as a per-variable fallback; the
platform supplies a sender too
(`CARLOS_SMTP_FROM`), which `CALLS_SMTP_FROM` can optionally override,
diff --git a/callscore/meetings.go b/callscore/meetings.go
index bac90e6..bfc537c 100644
--- a/callscore/meetings.go
+++ b/callscore/meetings.go
@@ -73,3 +73,52 @@ func StartMeeting(ctx context.Context, origin string, client *http.Client) (stri
}
return out.Sqid, nil
}
+
+// RegisterDevice tells the server where this device can be reached when
+// the app is not running.
+//
+// Best effort by design, and the caller treats a failure as nothing
+// happening: a device that could not register is a device that will not
+// be told about a knock, which is exactly where every device was before
+// push existed. It is never a reason to fail a join.
+func RegisterDevice(ctx context.Context, origin string, client *http.Client, token, platform string) error {
+ trimmed, err := checkOrigin(origin)
+ if err != nil {
+ return err
+ }
+ safe, err := safeClient(client)
+ if err != nil {
+ return err
+ }
+ if platform != "ios" && platform != "macos" {
+ return errors.New("unknown device platform")
+ }
+ body, err := json.Marshal(map[string]string{"token": token, "platform": platform})
+ if err != nil {
+ return errors.New("cannot build Calls request")
+ }
+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed+"/native/devices", bytes.NewReader(body))
+ if err != nil {
+ return errors.New("cannot build Calls request")
+ }
+ req.Header.Set("Accept", "application/json")
+ 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.StatusNoContent:
+ return nil
+ case res.StatusCode == http.StatusNotFound:
+ // An older server without the endpoint. Not an error worth
+ // showing anyone: it simply has no push.
+ return errors.New("this Calls server does not take device registrations")
+ default:
+ return errors.New("Calls refused this device registration")
+ }
+}
diff --git a/callscore/meetings_test.go b/callscore/meetings_test.go
index d96b42c..da67001 100644
--- a/callscore/meetings_test.go
+++ b/callscore/meetings_test.go
@@ -111,3 +111,51 @@ func TestStartMeetingNamesTheRefusalItCanDoSomethingAbout(t *testing.T) {
server.Close()
}
}
+
+func TestRegisterDeviceSendsTheTokenAndBothLocks(t *testing.T) {
+ var body, origin, contentType 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"))
+ var in map[string]string
+ json.NewDecoder(r.Body).Decode(&in)
+ body.Store(in)
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+ if err := RegisterDevice(t.Context(), server.URL, server.Client(), "aabbcc", "macos"); err != nil {
+ t.Fatal(err)
+ }
+ in := body.Load().(map[string]string)
+ if in["token"] != "aabbcc" || in["platform"] != "macos" {
+ t.Fatal("wrong body", in)
+ }
+ if origin.Load() != server.URL || contentType.Load() != "application/json" {
+ t.Fatal("missing a lock", origin.Load(), contentType.Load())
+ }
+}
+
+func TestRegisterDeviceRefusesAnUnknownPlatformWithoutCallingTheServer(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()
+ if err := RegisterDevice(t.Context(), server.URL, server.Client(), "aabb", "android"); err == nil {
+ t.Fatal("accepted an unknown platform")
+ }
+ if calls.Load() != 0 {
+ t.Fatal("called the server anyway")
+ }
+}
+
+func TestRegisterDeviceOnAnOlderServerIsAPlainRefusal(t *testing.T) {
+ // A server without the endpoint has no push. That is a fact about
+ // the server, not a failure worth showing anyone.
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ }))
+ defer server.Close()
+ err := RegisterDevice(t.Context(), server.URL, server.Client(), "aabb", "ios")
+ if err == nil || !strings.Contains(err.Error(), "does not take device registrations") {
+ t.Fatal("unhelpful error", err)
+ }
+}
diff --git a/internal/calls/apns/apns.go b/internal/calls/apns/apns.go
new file mode 100644
index 0000000..7e54ca8
--- /dev/null
+++ b/internal/calls/apns/apns.go
@@ -0,0 +1,230 @@
+// Package apns sends one kind of notification to Apple: someone is
+// waiting to be let into a meeting. It is deliberately small — no
+// badges, no collapse identifiers, no background pushes — because the
+// only thing this app has ever needed to say to a closed app is that
+// somebody is at the door.
+//
+// It is cgo-free, like the rest of the server. The provider token is
+// ES256 over the stdlib's crypto/ecdsa, and HTTP/2 comes from net/http
+// over TLS without help, which is the whole of what APNs requires.
+package apns
+
+import (
+ "bytes"
+ "context"
+ "crypto/ecdsa"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "sync"
+ "time"
+)
+
+// Hosts Apple serves. Production tokens are refused by the sandbox and
+// the other way round, with an error that names neither, so the choice
+// is configuration rather than something to guess at.
+const (
+ HostProduction = "https://api.push.apple.com"
+ HostSandbox = "https://api.sandbox.push.apple.com"
+)
+
+// providerTokenLife is how long one JWT is used for. Apple refuses a
+// token older than an hour and refuses a client that mints them more
+// often than every twenty minutes, so anything in between is correct and
+// fifty minutes leaves room for a slow clock on either side.
+const providerTokenLife = 50 * time.Minute
+
+// maxBody caps what is read from an error response. Apple's are a few
+// dozen bytes of JSON; anything larger is not Apple.
+const maxBody = 8 * 1024
+
+type Config struct {
+ KeyID string
+ TeamID string
+ Key *ecdsa.PrivateKey
+ // Topic per platform — the bundle identifier, which differs between
+ // the Mac and iPhone apps, so a token is useless without knowing
+ // which app issued it.
+ Topics map[string]string
+ Host string
+ // Now and HTTP exist for the tests, which run a fake Apple.
+ Now func() time.Time
+ HTTP *http.Client
+}
+
+type Client struct {
+ cfg Config
+ mu sync.Mutex
+ jwt string
+ made time.Time
+}
+
+// Verdict is what the caller must do about a token, which is the only
+// part of an APNs response worth carrying upwards.
+type Verdict int
+
+const (
+ // Delivered: Apple accepted it. It says nothing about the device.
+ Delivered Verdict = iota
+ // Gone: this token is dead and must be deleted. The app was removed,
+ // or the token was reissued to someone else. Keeping it means
+ // pushing at nobody for ever.
+ Gone
+ // Retry: transient, or ours to fix. The token stays.
+ Retry
+)
+
+func ParseKey(data []byte) (*ecdsa.PrivateKey, error) {
+ block, _ := pem.Decode(data)
+ if block == nil {
+ return nil, errors.New("apns: key is not PEM; expected the .p8 Apple issued")
+ }
+ parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+ if err != nil {
+ return nil, errors.New("apns: key is not a PKCS#8 private key")
+ }
+ key, ok := parsed.(*ecdsa.PrivateKey)
+ if !ok {
+ return nil, errors.New("apns: key is not an ECDSA key; APNs keys are P-256")
+ }
+ return key, nil
+}
+
+func New(cfg Config) (*Client, error) {
+ if cfg.KeyID == "" || cfg.TeamID == "" || cfg.Key == nil {
+ return nil, errors.New("apns: key id, team id and key are all required")
+ }
+ if len(cfg.Topics) == 0 {
+ return nil, errors.New("apns: at least one platform topic is required")
+ }
+ if cfg.Host == "" {
+ cfg.Host = HostProduction
+ }
+ if cfg.Now == nil {
+ cfg.Now = time.Now
+ }
+ if cfg.HTTP == nil {
+ // No Timeout here: the deadline rides on the request context, so
+ // one slow push cannot outlive the caller that asked for it.
+ cfg.HTTP = &http.Client{}
+ }
+ return &Client{cfg: cfg}, nil
+}
+
+// providerToken mints or reuses the ES256 JWT. Held under the mutex for
+// the whole mint: two goroutines racing here would each make one, and
+// Apple counts that against a rate limit measured in tokens per
+// connection rather than tokens per need.
+func (c *Client) providerToken() (string, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ now := c.cfg.Now()
+ if c.jwt != "" && now.Sub(c.made) < providerTokenLife {
+ return c.jwt, nil
+ }
+ header := encodeSegment(map[string]any{"alg": "ES256", "kid": c.cfg.KeyID, "typ": "JWT"})
+ claims := encodeSegment(map[string]any{"iss": c.cfg.TeamID, "iat": now.Unix()})
+ signing := header + "." + claims
+ digest := sha256.Sum256([]byte(signing))
+ r, s, err := ecdsa.Sign(rand.Reader, c.cfg.Key, digest[:])
+ if err != nil {
+ return "", errors.New("apns: cannot sign the provider token")
+ }
+ // JWS wants the raw r||s pair, each left-padded to the curve size —
+ // not the ASN.1 form ecdsa.SignASN1 would give, which Apple rejects
+ // with a message about the token being malformed and no more.
+ size := (c.cfg.Key.Curve.Params().BitSize + 7) / 8
+ signature := make([]byte, 2*size)
+ r.FillBytes(signature[:size])
+ s.FillBytes(signature[size:])
+ c.jwt = signing + "." + base64.RawURLEncoding.EncodeToString(signature)
+ c.made = now
+ return c.jwt, nil
+}
+
+func encodeSegment(value map[string]any) string {
+ b, _ := json.Marshal(value)
+ return base64.RawURLEncoding.EncodeToString(b)
+}
+
+// Push tells one device that someone is waiting. `name` is the waiting
+// person's own text, from the server's roster, so it is carried as a
+// JSON value and never built into one.
+func (c *Client) Push(ctx context.Context, token, platform, name string) (Verdict, error) {
+ topic, ok := c.cfg.Topics[platform]
+ if !ok {
+ return Retry, fmt.Errorf("apns: no topic configured for platform %q", platform)
+ }
+ jwt, err := c.providerToken()
+ if err != nil {
+ return Retry, err
+ }
+ body, err := json.Marshal(map[string]any{
+ "aps": map[string]any{
+ "alert": map[string]any{
+ "title": "Someone is waiting to join",
+ "body": name,
+ },
+ "sound": "default",
+ },
+ })
+ if err != nil {
+ return Retry, errors.New("apns: cannot build the payload")
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.Host+"/3/device/"+token, bytes.NewReader(body))
+ if err != nil {
+ return Retry, errors.New("apns: cannot build the request")
+ }
+ req.Header.Set("authorization", "bearer "+jwt)
+ req.Header.Set("apns-topic", topic)
+ req.Header.Set("apns-push-type", "alert")
+ // 10 is "deliver now", which is the only priority that makes sense
+ // for a person standing at a door.
+ req.Header.Set("apns-priority", "10")
+ req.Header.Set("content-type", "application/json")
+ res, err := c.cfg.HTTP.Do(req)
+ if err != nil {
+ return Retry, errors.New("apns: cannot reach Apple")
+ }
+ defer res.Body.Close()
+ if res.StatusCode == http.StatusOK {
+ return Delivered, nil
+ }
+ var out struct {
+ Reason string `json:"reason"`
+ }
+ raw, _ := io.ReadAll(io.LimitReader(res.Body, maxBody))
+ _ = json.Unmarshal(raw, &out)
+ return classify(res.StatusCode, out.Reason)
+}
+
+// classify turns a refusal into the one decision the caller has to make:
+// keep this token or delete it. Pure, so the table of Apple's reasons is
+// a thing a test can read rather than a thing a fake server has to act
+// out one status at a time.
+func classify(status int, reason string) (Verdict, error) {
+ switch reason {
+ case "BadDeviceToken", "Unregistered", "DeviceTokenNotForTopic":
+ // The token is wrong for this app or belongs to an app that is
+ // gone. No amount of retrying changes that.
+ return Gone, fmt.Errorf("apns: token rejected (%s)", reason)
+ case "ExpiredProviderToken", "InvalidProviderToken", "MissingProviderToken":
+ // Ours, not the device's. The token stays; the next push mints a
+ // new JWT.
+ return Retry, fmt.Errorf("apns: provider token refused (%s)", reason)
+ }
+ if status == http.StatusGone {
+ return Gone, errors.New("apns: token is no longer registered")
+ }
+ if reason == "" {
+ return Retry, fmt.Errorf("apns: refused with status %d", status)
+ }
+ return Retry, fmt.Errorf("apns: refused (%s)", reason)
+}
diff --git a/internal/calls/apns/apns_test.go b/internal/calls/apns/apns_test.go
new file mode 100644
index 0000000..9094156
--- /dev/null
+++ b/internal/calls/apns/apns_test.go
@@ -0,0 +1,276 @@
+package apns
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "encoding/pem"
+ "math/big"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func testKey(t *testing.T) (*ecdsa.PrivateKey, []byte) {
+ t.Helper()
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ der, err := x509.MarshalPKCS8PrivateKey(key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return key, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
+}
+
+func client(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Server) {
+ t.Helper()
+ key, _ := testKey(t)
+ server := httptest.NewServer(handler)
+ t.Cleanup(server.Close)
+ c, err := New(Config{
+ KeyID: "ABC123DEFG", TeamID: "TEAM123456", Key: key,
+ Topics: map[string]string{"ios": "works.oficina.calls", "macos": "works.oficina.calls.mac"},
+ Host: server.URL, HTTP: server.Client(),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return c, server
+}
+
+func TestParseKeyAcceptsAP8AndRefusesAnythingElse(t *testing.T) {
+ _, pemBytes := testKey(t)
+ if _, err := ParseKey(pemBytes); err != nil {
+ t.Fatal(err)
+ }
+ for name, input := range map[string]string{
+ "not pem": "hello",
+ "empty": "",
+ "pem but not a key": "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
+ } {
+ if _, err := ParseKey([]byte(input)); err == nil {
+ t.Errorf("accepted %s", name)
+ }
+ }
+}
+
+// The provider token is the part Apple rejects most opaquely — a wrong
+// signature encoding comes back as "InvalidProviderToken" and nothing
+// else — so it is verified here against the public key rather than
+// merely produced.
+func TestTheProviderTokenIsAValidES256JWTApplesWay(t *testing.T) {
+ var seen atomic.Value
+ c, _ := client(t, func(w http.ResponseWriter, r *http.Request) {
+ seen.Store(r.Header.Get("authorization"))
+ w.WriteHeader(http.StatusOK)
+ })
+ if _, err := c.Push(t.Context(), "abc", "ios", "Bea"); err != nil {
+ t.Fatal(err)
+ }
+ header, ok := seen.Load().(string)
+ if !ok || !strings.HasPrefix(header, "bearer ") {
+ t.Fatal("no bearer token", header)
+ }
+ parts := strings.Split(strings.TrimPrefix(header, "bearer "), ".")
+ if len(parts) != 3 {
+ t.Fatal("not three JWT segments")
+ }
+ var head map[string]any
+ raw, err := base64.RawURLEncoding.DecodeString(parts[0])
+ if err != nil || json.Unmarshal(raw, &head) != nil {
+ t.Fatal("header is not base64url JSON")
+ }
+ if head["alg"] != "ES256" || head["kid"] != "ABC123DEFG" {
+ t.Fatal("wrong header", head)
+ }
+ var claims map[string]any
+ raw, err = base64.RawURLEncoding.DecodeString(parts[1])
+ if err != nil || json.Unmarshal(raw, &claims) != nil {
+ t.Fatal("claims are not base64url JSON")
+ }
+ if claims["iss"] != "TEAM123456" {
+ t.Fatal("wrong issuer", claims)
+ }
+ // The signature must be the raw r||s pair, each padded to 32 bytes.
+ // ASN.1 — what SignASN1 produces — is the mistake this asserts against.
+ signature, err := base64.RawURLEncoding.DecodeString(parts[2])
+ if err != nil || len(signature) != 64 {
+ t.Fatalf("signature is %d bytes, want 64 raw r||s", len(signature))
+ }
+ digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
+ r := new(big.Int).SetBytes(signature[:32])
+ s := new(big.Int).SetBytes(signature[32:])
+ if !ecdsa.Verify(&c.cfg.Key.PublicKey, digest[:], r, s) {
+ t.Fatal("signature does not verify against the key that made it")
+ }
+}
+
+func TestOneTokenIsReusedAndThenReminted(t *testing.T) {
+ var tokens sync.Map
+ var count atomic.Int32
+ c, _ := client(t, func(w http.ResponseWriter, r *http.Request) {
+ tokens.Store(r.Header.Get("authorization"), true)
+ count.Add(1)
+ w.WriteHeader(http.StatusOK)
+ })
+ now := time.Now()
+ c.cfg.Now = func() time.Time { return now }
+ for range 3 {
+ if _, err := c.Push(t.Context(), "abc", "ios", "Bea"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ distinct := 0
+ tokens.Range(func(_, _ any) bool { distinct++; return true })
+ if distinct != 1 {
+ t.Fatalf("minted %d tokens for three pushes; Apple rate-limits that", distinct)
+ }
+ // Past the life, a new one. Apple refuses a token older than an hour.
+ now = now.Add(providerTokenLife + time.Minute)
+ if _, err := c.Push(t.Context(), "abc", "ios", "Bea"); err != nil {
+ t.Fatal(err)
+ }
+ distinct = 0
+ tokens.Range(func(_, _ any) bool { distinct++; return true })
+ if distinct != 2 {
+ t.Fatal("an expired provider token was reused")
+ }
+}
+
+func TestTheRequestCarriesWhatApplesRequires(t *testing.T) {
+ var path, topic, pushType, priority atomic.Value
+ var payload atomic.Value
+ c, _ := client(t, func(w http.ResponseWriter, r *http.Request) {
+ path.Store(r.URL.Path)
+ topic.Store(r.Header.Get("apns-topic"))
+ pushType.Store(r.Header.Get("apns-push-type"))
+ priority.Store(r.Header.Get("apns-priority"))
+ var body map[string]any
+ json.NewDecoder(r.Body).Decode(&body)
+ payload.Store(body)
+ w.WriteHeader(http.StatusOK)
+ })
+ if _, err := c.Push(t.Context(), "devicetoken123", "macos", "Bea O'Neill"); err != nil {
+ t.Fatal(err)
+ }
+ if path.Load() != "/3/device/devicetoken123" {
+ t.Fatal("wrong path", path.Load())
+ }
+ // The topic follows the platform: the two apps have different bundle
+ // identifiers, and a token sent under the wrong one is refused with
+ // DeviceTokenNotForTopic.
+ if topic.Load() != "works.oficina.calls.mac" {
+ t.Fatal("wrong topic for macos", topic.Load())
+ }
+ if pushType.Load() != "alert" || priority.Load() != "10" {
+ t.Fatal("wrong push type or priority", pushType.Load(), priority.Load())
+ }
+ body := payload.Load().(map[string]any)
+ aps := body["aps"].(map[string]any)
+ alert := aps["alert"].(map[string]any)
+ // The name is someone else's text and rides as a JSON value, never
+ // built into one.
+ if alert["body"] != "Bea O'Neill" {
+ t.Fatal("name not carried verbatim", alert["body"])
+ }
+}
+
+func TestAnUnknownPlatformIsRefusedWithoutCallingApple(t *testing.T) {
+ var called atomic.Int32
+ c, _ := client(t, func(w http.ResponseWriter, r *http.Request) { called.Add(1) })
+ verdict, err := c.Push(t.Context(), "abc", "android", "Bea")
+ if err == nil || verdict != Retry {
+ t.Fatal("accepted an unknown platform")
+ }
+ if called.Load() != 0 {
+ t.Fatal("called Apple for a platform with no topic")
+ }
+}
+
+// The table that decides whether a token lives or dies. Pure, so every
+// reason Apple documents can be covered without a fake acting each out.
+func TestApplesRefusalsAreSortedIntoKeepAndDelete(t *testing.T) {
+ for _, c := range []struct {
+ status int
+ reason string
+ want Verdict
+ }{
+ {410, "Unregistered", Gone},
+ {400, "BadDeviceToken", Gone},
+ {400, "DeviceTokenNotForTopic", Gone},
+ {410, "", Gone},
+ {403, "ExpiredProviderToken", Retry},
+ {403, "InvalidProviderToken", Retry},
+ {429, "TooManyRequests", Retry},
+ {503, "", Retry},
+ {500, "InternalServerError", Retry},
+ } {
+ got, err := classify(c.status, c.reason)
+ if got != c.want {
+ t.Errorf("status %d reason %q: got %v, want %v", c.status, c.reason, got, c.want)
+ }
+ if err == nil {
+ t.Errorf("status %d reason %q: refusal with no error", c.status, c.reason)
+ }
+ }
+}
+
+func TestApplesGoneVerdictReachesTheCaller(t *testing.T) {
+ c, _ := client(t, func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusGone)
+ w.Write([]byte(`{"reason":"Unregistered"}`))
+ })
+ verdict, err := c.Push(t.Context(), "dead", "ios", "Bea")
+ if verdict != Gone || err == nil {
+ t.Fatal("a dead token must be reported as Gone", verdict, err)
+ }
+}
+
+func TestAConfigMissingItsCredentialsIsRefusedAtConstruction(t *testing.T) {
+ key, _ := testKey(t)
+ full := Config{KeyID: "K", TeamID: "T", Key: key, Topics: map[string]string{"ios": "a"}}
+ if _, err := New(full); err != nil {
+ t.Fatal(err)
+ }
+ for name, broken := range map[string]Config{
+ "no key id": {TeamID: "T", Key: key, Topics: full.Topics},
+ "no team": {KeyID: "K", Key: key, Topics: full.Topics},
+ "no key": {KeyID: "K", TeamID: "T", Topics: full.Topics},
+ "no topics": {KeyID: "K", TeamID: "T", Key: key},
+ } {
+ if _, err := New(broken); err == nil {
+ t.Errorf("accepted a config with %s", name)
+ }
+ }
+}
+
+func TestUnreachableAppleIsRetryAndNotGone(t *testing.T) {
+ // The distinction that matters: a network that is down must never be
+ // read as "this device is gone", or an outage deletes every token.
+ key, _ := testKey(t)
+ c, err := New(Config{
+ KeyID: "K", TeamID: "T", Key: key,
+ Topics: map[string]string{"ios": "a"},
+ Host: "http://127.0.0.1:1",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
+ defer cancel()
+ verdict, err := c.Push(ctx, "abc", "ios", "Bea")
+ if verdict != Retry || err == nil {
+ t.Fatal("an unreachable Apple must be Retry", verdict, err)
+ }
+}
diff --git a/internal/calls/app.go b/internal/calls/app.go
index cf30892..c2f4718 100644
--- a/internal/calls/app.go
+++ b/internal/calls/app.go
@@ -18,6 +18,7 @@ import (
"amadan.net/rastrillo/rastrillo/db"
"amadan.net/rastrillo/rastrillo/migrate"
+ "amadan.net/oficina/calls/internal/calls/apns"
"amadan.net/oficina/calls/internal/calls/hub"
"amadan.net/oficina/calls/internal/calls/relay"
"amadan.net/oficina/calls/internal/calls/seal"
@@ -36,6 +37,12 @@ type app struct {
linkBudget *budget
outsiderBudget *budget
hub *hub.Hub
+ // push is the APNs sender, or nil where no key is configured — which
+ // is every test and every deployment that has not been given one.
+ // Nil is not a degraded mode: knocks still reach every member who is
+ // in the room, exactly as before. It is only the member who is not
+ // there who goes untold.
+ push knockPusher
// hubSalt keys the session cookie into the hub's session id, so the
// hub holds no session material it did not have to.
hubSalt string
@@ -369,8 +376,22 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err
}
// The hub reads standing back through the app, and never under its
// own lock: hubStanding is a database read.
+ // The APNs sender, where a key was configured. Built before the hub
+ // so that a.knocked has something to send with the first time it is
+ // called, which on a busy instance can be immediately.
+ if rc.apns != nil {
+ client, err := apns.New(*rc.apns)
+ if err != nil {
+ return nil, nil, fmt.Errorf("calls: Apple push: %w", err)
+ }
+ a.push = client
+ rc.Logger.Info("calls: Apple push configured", "sandbox", rc.APNsSandbox)
+ }
+ // The hub reads standing back through the app, and never under its
+ // own lock: hubStanding is a database read. Knocked is the same
+ // arrangement for the other direction.
a.hub = hub.New(hub.Config{
- Now: st.now, Logger: rc.Logger, StandingFor: a.hubStanding,
+ Now: st.now, Logger: rc.Logger, StandingFor: a.hubStanding, Knocked: a.knocked,
WaitingRoom: func(ctx context.Context, sqid string) (bool, error) {
m, _, err := st.meetingBySqid(ctx, sqid)
return m.WaitingRoom, err
@@ -473,6 +494,7 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err
}
r.Post("/meetings/start", a.startCall)
r.Post("/native/meetings/start", a.nativeStartMeeting)
+ r.Post("/native/devices", a.registerDevice)
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/config.go b/internal/calls/config.go
index 428fd63..5903f98 100644
--- a/internal/calls/config.go
+++ b/internal/calls/config.go
@@ -1,6 +1,7 @@
package calls
import (
+ "amadan.net/oficina/calls/internal/calls/apns"
"encoding/base64"
"fmt"
"log/slog"
@@ -38,6 +39,29 @@ type Config struct {
// AllowedAddresses is the team: the addresses that are members.
// CALLS_ALLOWED_ADDRESSES, comma-separated.
AllowedAddresses []string
+
+ // The Apple push key, for telling a member who is not in the room
+ // that somebody is waiting. All four are required together or none
+ // of them: a half-configured push is a boot-time mistake, not a
+ // runtime surprise on the first knock.
+ //
+ // APNsKey is the .p8 Apple issued, PEM as downloaded, set with
+ // `carlos secrets set` and never committed. CALLS_APNS_KEY.
+ APNsKey string
+ // CALLS_APNS_KEY_ID, the ten characters Apple names the key by.
+ APNsKeyID string
+ // CALLS_APNS_TEAM_ID, the Apple developer team.
+ APNsTeamID string
+ // CALLS_APNS_TOPIC_IOS and CALLS_APNS_TOPIC_MACOS: the two bundle
+ // identifiers. Separate because a token is issued per app, and one
+ // sent under the other app's topic is refused.
+ APNsTopicIOS string
+ APNsTopicMacOS string
+ // CALLS_APNS_SANDBOX sends to Apple's sandbox host, which is where a
+ // development build's tokens live. A production token refused by the
+ // sandbox says nothing useful about why, so this is configuration
+ // rather than something to detect.
+ APNsSandbox bool
// Mailer delivers magic links. Nil outside development is refused;
// nil in development logs the link instead of sending it.
Mailer mail.Sender
@@ -67,6 +91,7 @@ type resolved struct {
instanceKey string
dek []byte
mailer mail.Sender
+ apns *apns.Config
allow allowlist
relay []relay.Region
relayConfigured bool
@@ -134,6 +159,12 @@ func resolve(cfg Config) (resolved, error) {
}
out.allow = list
+ push, err := resolveAPNs(cfg)
+ if err != nil {
+ return out, err
+ }
+ out.apns = push
+
regions, err := relay.Parse(cfg.Relay)
switch {
case err == nil:
@@ -163,3 +194,46 @@ func resolve(cfg Config) (resolved, error) {
}
return out, nil
}
+
+// resolveAPNs reads the push key, or reads that there isn't one.
+//
+// All five settings are required together. A key with no topic, or a
+// topic with no key, is somebody halfway through configuring push, and
+// the moment to say so is boot — not the first knock, in a goroutine,
+// into a log nobody is reading.
+//
+// No key at all is not an error and not a degraded mode. Knocks still
+// reach every member who is in the room, over the streams they always
+// used. It is only the member who is elsewhere who goes untold, which
+// is exactly where this app was before any of this existed.
+func resolveAPNs(cfg Config) (*apns.Config, error) {
+ present := 0
+ for _, v := range []string{cfg.APNsKey, cfg.APNsKeyID, cfg.APNsTeamID, cfg.APNsTopicIOS, cfg.APNsTopicMacOS} {
+ if strings.TrimSpace(v) != "" {
+ present++
+ }
+ }
+ if present == 0 {
+ return nil, nil
+ }
+ if present < 5 {
+ return nil, fmt.Errorf("calls: Apple push is half-configured (%d of 5 settings present); set all of $CALLS_APNS_KEY, $CALLS_APNS_KEY_ID, $CALLS_APNS_TEAM_ID, $CALLS_APNS_TOPIC_IOS and $CALLS_APNS_TOPIC_MACOS, or none", present)
+ }
+ key, err := apns.ParseKey([]byte(cfg.APNsKey))
+ if err != nil {
+ // The error names the shape and never the bytes.
+ return nil, fmt.Errorf("calls: $CALLS_APNS_KEY is not usable: %w", err)
+ }
+ host := apns.HostProduction
+ if cfg.APNsSandbox {
+ host = apns.HostSandbox
+ }
+ return &apns.Config{
+ KeyID: strings.TrimSpace(cfg.APNsKeyID), TeamID: strings.TrimSpace(cfg.APNsTeamID), Key: key,
+ Topics: map[string]string{
+ "ios": strings.TrimSpace(cfg.APNsTopicIOS),
+ "macos": strings.TrimSpace(cfg.APNsTopicMacOS),
+ },
+ Host: host,
+ }, nil
+}
diff --git a/internal/calls/hub/admission.go b/internal/calls/hub/admission.go
index 3a97e62..f736614 100644
--- a/internal/calls/hub/admission.go
+++ b/internal/calls/hub/admission.go
@@ -260,6 +260,19 @@ func (h *Hub) Join(ctx context.Context, sqid, session string, peer PeerID) error
p.Joined = true
p.JoinedAt = h.cfg.Now()
h.broadcastLocked(r, Message{Type: TypeKnock, From: p.Peer, Name: p.Name}, admittedMembers)
+ // Who is already looking, gathered here because it needs the lock and
+ // must not be re-read later: by the time the notification is sent the
+ // room may have changed, and telling someone who *was* present is a
+ // smaller fault than holding the mutex across a network call.
+ if h.cfg.Knocked != nil {
+ watching := make([]int64, 0, len(r.peers))
+ for _, other := range r.peers {
+ if other.Admitted && other.Kind == KindMember {
+ watching = append(watching, other.PersonID)
+ }
+ }
+ go h.cfg.Knocked(r.sqid, watching, p.Name)
+ }
return nil
}
diff --git a/internal/calls/hub/hub.go b/internal/calls/hub/hub.go
index 22c579c..0ef0aed 100644
--- a/internal/calls/hub/hub.go
+++ b/internal/calls/hub/hub.go
@@ -72,6 +72,17 @@ type Config struct {
// 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)
+ // Knocked says that someone has begun waiting. It exists because the
+ // admission queue lives only here, in memory, and reaches members
+ // over their own streams — so a member who is not in the room has
+ // never had any way to learn that somebody is at the door.
+ //
+ // Called on a goroutine of its own, never under the lock: the app's
+ // implementation reads the database and talks to Apple, and the hub's
+ // mutex is held across every peer in every room. Nil is no
+ // notification, which is every test and every deployment without a
+ // push key.
+ Knocked func(room string, personIDs []int64, name string)
}
// Participation is the hub's record that a peer is in a room. It is the
diff --git a/internal/calls/knockpush.go b/internal/calls/knockpush.go
new file mode 100644
index 0000000..84c4277
--- /dev/null
+++ b/internal/calls/knockpush.go
@@ -0,0 +1,132 @@
+package calls
+
+import (
+ "context"
+ "encoding/hex"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "amadan.net/oficina/calls/internal/calls/apns"
+)
+
+// maxDeviceToken is Apple's token length with room to spare: 32 bytes
+// today, 100 allowed since the format is not promised to stay put.
+const maxDeviceToken = 200
+
+// knockPushDeadline bounds one round of notifications. A knock is only
+// interesting for as long as somebody is still standing at the door.
+const knockPushDeadline = 20 * time.Second
+
+// registerDevice is POST /native/devices: the app saying where it can be
+// reached when it is not running.
+//
+// Behind the same two locks as the other native write — the JSON content
+// type an HTML form cannot send, and the Origin csrf.Protect reads —
+// because like that one it runs on the session the app is holding rather
+// than on no authority at all.
+func (a *app) registerDevice(w http.ResponseWriter, r *http.Request) {
+ nativeHeaders(w)
+ if r.Header.Get("Content-Type") != "application/json" {
+ http.Error(w, "expected JSON", http.StatusUnsupportedMediaType)
+ return
+ }
+ var input struct {
+ Token string `json:"token"`
+ Platform string `json:"platform"`
+ }
+ if json.NewDecoder(io.LimitReader(r.Body, maxAPIBody)).Decode(&input) != nil {
+ http.Error(w, "invalid request", http.StatusBadRequest)
+ return
+ }
+ // Apple's tokens are hex. Insisting on that here means nothing
+ // arbitrary is ever stored, and a token is never logged: it is an
+ // address for a person's device and does not belong in a log line.
+ token := strings.ToLower(strings.TrimSpace(input.Token))
+ if token == "" || len(token) > maxDeviceToken {
+ http.Error(w, "invalid request", http.StatusBadRequest)
+ return
+ }
+ if _, err := hex.DecodeString(token); err != nil {
+ http.Error(w, "invalid request", http.StatusBadRequest)
+ return
+ }
+ if input.Platform != "ios" && input.Platform != "macos" {
+ http.Error(w, "invalid request", http.StatusBadRequest)
+ return
+ }
+ p, ok := currentPerson(r)
+ if !ok {
+ http.Error(w, "sign in again", http.StatusUnauthorized)
+ return
+ }
+ if err := a.store.saveDeviceToken(r.Context(), p.ID, token, input.Platform); err != nil {
+ a.logger.Error("calls: register device", "err", err)
+ http.Error(w, "cannot register this device", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// knocked is the hub's Knocked callback: somebody has begun waiting, and
+// the members who are not in the room have no other way to find out.
+//
+// `watching` is who was admitted at the moment of the knock. They are
+// skipped, because the queue is already on their screen and a push would
+// be a second telling of something they can see. That check is the whole
+// reason the hub gathers the list under its own lock and passes it in —
+// asking again here would race the room.
+//
+// Everything is best effort and nothing is retried. A knock is worth
+// saying once, quickly; a person who has stopped waiting is not worth
+// telling anyone about a minute later.
+func (a *app) knocked(room string, watching []int64, name string) {
+ if a.push == nil {
+ return
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), knockPushDeadline)
+ defer cancel()
+ members, err := a.store.memberPersonIDs(ctx, a.allowNow())
+ if err != nil {
+ a.logger.Error("calls: knock push members", "err", err, "room", room)
+ return
+ }
+ looking := make(map[int64]bool, len(watching))
+ for _, id := range watching {
+ looking[id] = true
+ }
+ absent := members[:0:0]
+ for _, id := range members {
+ if !looking[id] {
+ absent = append(absent, id)
+ }
+ }
+ tokens, err := a.store.deviceTokensFor(ctx, absent)
+ if err != nil {
+ a.logger.Error("calls: knock push tokens", "err", err, "room", room)
+ return
+ }
+ for _, device := range tokens {
+ verdict, err := a.push.Push(ctx, device.Token, device.Platform, name)
+ switch verdict {
+ case apns.Gone:
+ // Apple says this device will never be reachable again. The
+ // row is wrong and deleting it is the only hygiene this
+ // table gets. The token is not logged.
+ if err := a.store.forgetDeviceToken(ctx, device.Token); err != nil {
+ a.logger.Error("calls: forget device token", "err", err)
+ }
+ case apns.Retry:
+ a.logger.Warn("calls: knock push refused", "err", err, "room", room, "platform", device.Platform)
+ }
+ }
+}
+
+// knockPusher is the narrow part of apns.Client this app uses, named as
+// an interface so a test can watch what would have been sent to Apple
+// without an Apple to send it to.
+type knockPusher interface {
+ Push(ctx context.Context, token, platform, name string) (apns.Verdict, error)
+}
diff --git a/internal/calls/knockpush_test.go b/internal/calls/knockpush_test.go
new file mode 100644
index 0000000..06bcf42
--- /dev/null
+++ b/internal/calls/knockpush_test.go
@@ -0,0 +1,206 @@
+package calls
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+
+ "amadan.net/oficina/calls/internal/calls/apns"
+)
+
+// recordingPusher stands in for Apple. The verdict is per token so one
+// test can have a live device and a dead one at the same time.
+type recordingPusher struct {
+ mu sync.Mutex
+ sent []string
+ verdicts map[string]apns.Verdict
+}
+
+func (p *recordingPusher) Push(ctx context.Context, token, platform, name string) (apns.Verdict, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.sent = append(p.sent, token+"|"+platform+"|"+name)
+ if v, ok := p.verdicts[token]; ok {
+ if v == apns.Gone {
+ return v, context.Canceled
+ }
+ return v, nil
+ }
+ return apns.Delivered, nil
+}
+
+func (p *recordingPusher) all() []string {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return append([]string(nil), p.sent...)
+}
+
+func deviceJSON(f *homeRouterFixture, body string) *httptest.ResponseRecorder {
+ r := httptest.NewRequest("POST", homeTestOrigin+"/native/devices", 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 TestRegisteringADeviceStoresItAndIsIdempotent(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ if w := deviceJSON(f, `{"token":"AABBCC0011","platform":"macos"}`); w.Code != http.StatusNoContent {
+ t.Fatal("refused", w.Code, w.Body.String())
+ }
+ var count int
+ if err := f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count); err != nil {
+ t.Fatal(err)
+ }
+ if count != 1 {
+ t.Fatal("expected one row", count)
+ }
+ // The app re-registers on every launch. That must move last_seen,
+ // not accumulate rows.
+ if w := deviceJSON(f, `{"token":"aabbcc0011","platform":"macos"}`); w.Code != http.StatusNoContent {
+ t.Fatal("re-registration refused", w.Code)
+ }
+ if err := f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count); err != nil {
+ t.Fatal(err)
+ }
+ if count != 1 {
+ t.Fatal("re-registration made a second row", count)
+ }
+ var stored string
+ f.app.db.Writer().QueryRow("SELECT token FROM device_tokens").Scan(&stored)
+ if stored != "aabbcc0011" {
+ t.Fatal("token not normalised to lower case", stored)
+ }
+}
+
+func TestADeviceRegistrationMustBeHexAndAKnownPlatform(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ for name, body := range map[string]string{
+ "not hex": `{"token":"zzzz","platform":"ios"}`,
+ "empty token": `{"token":"","platform":"ios"}`,
+ "no platform": `{"token":"aabb"}`,
+ "bad platform": `{"token":"aabb","platform":"android"}`,
+ "too long": `{"token":"` + strings.Repeat("ab", 200) + `","platform":"ios"}`,
+ "not json": `hello`,
+ } {
+ if w := deviceJSON(f, body); w.Code == http.StatusNoContent {
+ t.Errorf("accepted %s", name)
+ }
+ }
+ var count int
+ f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count)
+ if count != 0 {
+ t.Fatal("a refused registration still stored something", count)
+ }
+}
+
+func TestADeviceRegistrationNeedsBothLocksAndASession(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ // A form content type is the cross-site shape an HTML form can send.
+ form := httptest.NewRequest("POST", homeTestOrigin+"/native/devices", 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 is csrf.Protect's refusal, before the handler.
+ bare := httptest.NewRequest("POST", homeTestOrigin+"/native/devices", strings.NewReader(`{"token":"aabb","platform":"ios"}`))
+ 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 == http.StatusNoContent {
+ t.Fatal("origin-less registration accepted")
+ }
+ // Signed out entirely.
+ g := newHomeRouterFixture(t)
+ if w := deviceJSON(g, `{"token":"aabb","platform":"ios"}`); w.Code == http.StatusNoContent {
+ t.Fatal("signed-out registration accepted")
+ }
+}
+
+// The decision this whole feature turns on: who is told.
+func TestAKnockSkipsMembersWhoAreAlreadyLooking(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ if w := deviceJSON(f, `{"token":"aabbcc","platform":"macos"}`); w.Code != http.StatusNoContent {
+ t.Fatal(w.Code)
+ }
+ var personID int64
+ if err := f.app.db.Writer().QueryRow("SELECT person_id FROM device_tokens").Scan(&personID); err != nil {
+ t.Fatal(err)
+ }
+ pusher := &recordingPusher{}
+ f.app.push = pusher
+
+ // In the room: the queue is on their screen already, and a push
+ // would be a second telling of something they can see.
+ f.app.knocked("room1", []int64{personID}, "Bea")
+ if got := pusher.all(); len(got) != 0 {
+ t.Fatal("pushed to a member who was watching", got)
+ }
+
+ // Not in the room: this is the case the feature exists for, and the
+ // case that was impossible before it.
+ f.app.knocked("room1", nil, "Bea")
+ got := pusher.all()
+ if len(got) != 1 || got[0] != "aabbcc|macos|Bea" {
+ t.Fatal("absent member was not told", got)
+ }
+}
+
+func TestAKnockDeletesATokenAppleSaysIsGone(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ deviceJSON(f, `{"token":"deadbeef","platform":"ios"}`)
+ f.app.push = &recordingPusher{verdicts: map[string]apns.Verdict{"deadbeef": apns.Gone}}
+ f.app.knocked("room1", nil, "Bea")
+ var count int
+ f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count)
+ if count != 0 {
+ t.Fatal("a token Apple reported unregistered was kept", count)
+ }
+}
+
+func TestAKnockKeepsATokenWhenAppleIsMerelyUnreachable(t *testing.T) {
+ // The distinction that matters: an outage must never be read as "the
+ // device is gone", or one bad afternoon empties the table.
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ deviceJSON(f, `{"token":"abcdef","platform":"ios"}`)
+ f.app.push = &recordingPusher{verdicts: map[string]apns.Verdict{"abcdef": apns.Retry}}
+ f.app.knocked("room1", nil, "Bea")
+ var count int
+ f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count)
+ if count != 1 {
+ t.Fatal("a retryable refusal deleted the token", count)
+ }
+}
+
+func TestNoPushKeyIsQuietRatherThanBroken(t *testing.T) {
+ // Nil push is every deployment without a key, and it must be a
+ // no-op: knocks still reach everyone in the room over their streams,
+ // exactly as before any of this existed.
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ deviceJSON(f, `{"token":"aabb","platform":"ios"}`)
+ f.app.push = nil
+ f.app.knocked("room1", nil, "Bea")
+}
diff --git a/internal/calls/migrations/0005_create_device_tokens.sql b/internal/calls/migrations/0005_create_device_tokens.sql
new file mode 100644
index 0000000..9a055ae
--- /dev/null
+++ b/internal/calls/migrations/0005_create_device_tokens.sql
@@ -0,0 +1,6 @@
+CREATE TABLE `device_tokens` (`id` integer PRIMARY KEY AUTOINCREMENT,`person_id` integer NOT NULL,`token` text NOT NULL,`platform` text NOT NULL,`created_at` datetime,`last_seen` datetime);
+
+CREATE UNIQUE INDEX `idx_device_tokens_token` ON `device_tokens`(`token`);
+
+CREATE INDEX `idx_device_tokens_person_id` ON `device_tokens`(`person_id`);
+
diff --git a/internal/calls/migrations/schema.sql b/internal/calls/migrations/schema.sql
index 9147b74..e5030d4 100644
--- a/internal/calls/migrations/schema.sql
+++ b/internal/calls/migrations/schema.sql
@@ -3,6 +3,8 @@
CREATE TABLE `agents` (`id` integer PRIMARY KEY AUTOINCREMENT,`name` text NOT NULL,`operator_person_id` integer NOT NULL,`token_hash` text NOT NULL,`created_at` datetime,`revoked_at` datetime);
+CREATE TABLE `device_tokens` (`id` integer PRIMARY KEY AUTOINCREMENT,`person_id` integer NOT NULL,`token` text NOT NULL,`platform` text NOT NULL,`created_at` datetime,`last_seen` datetime);
+
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);
@@ -19,6 +21,10 @@ CREATE INDEX `idx_agents_operator_person_id` ON `agents`(`operator_person_id`);
CREATE UNIQUE INDEX `idx_agents_token_hash` ON `agents`(`token_hash`);
+CREATE INDEX `idx_device_tokens_person_id` ON `device_tokens`(`person_id`);
+
+CREATE UNIQUE INDEX `idx_device_tokens_token` ON `device_tokens`(`token`);
+
CREATE UNIQUE INDEX `idx_grant_meeting_person` ON `guest_grants`(`meeting_id`,`person_id`);
CREATE INDEX `idx_guest_grants_expires_at` ON `guest_grants`(`expires_at`);
diff --git a/internal/calls/models.go b/internal/calls/models.go
index 6a50f82..6dbf30c 100644
--- a/internal/calls/models.go
+++ b/internal/calls/models.go
@@ -14,6 +14,7 @@ var Models = []any{
&PendingJoin{},
&Agent{},
&MeetingDefaults{},
+ &DeviceToken{},
}
// Person is a verified identity or an anonymous guest. Anonymous guests carry
@@ -60,6 +61,35 @@ type MeetingLink struct {
RevokedAt *time.Time
}
+// DeviceToken is one Apple device a person can be reached on when the
+// app is not running. It is the only part of the knock notification that
+// has to be stored: the queue itself lives in the hub's memory and dies
+// with the process, which is exactly why a person outside the room could
+// never be told about it.
+//
+// The token is stored in plaintext, unlike a meeting title. It is not a
+// secret about a person — it is an address Apple issues, scoped to one
+// app on one device, useless without the push key, and rotated by the
+// system whenever it likes. Sealing it would cost the sweep the ability
+// to delete by token when Apple reports one unregistered, which is the
+// one hygiene operation this table needs.
+//
+// Unique on the token rather than on (person, token): Apple reissues a
+// token to whoever installs the app next, so the same string arriving
+// for a different person means the device changed hands and the old row
+// is wrong, not that there are two.
+type DeviceToken struct {
+ ID int64
+ PersonID int64 `gorm:"index;not null"`
+ Token string `gorm:"uniqueIndex;not null"`
+ // "ios" or "macos". Apple keeps one token space per app per device,
+ // but the two apps have different bundle identifiers and therefore
+ // different APNs topics, so a token is useless without knowing which.
+ Platform string `gorm:"not null"`
+ CreatedAt time.Time
+ LastSeen time.Time
+}
+
// GuestGrant is a non-member's entry authority for one meeting. One
// row per person per meeting. AdmittedAt and AdmittedBy are an audit of
// the first admission (slice 2) and confer nothing.
diff --git a/internal/calls/store.go b/internal/calls/store.go
index 8a9732d..6f69cad 100644
--- a/internal/calls/store.go
+++ b/internal/calls/store.go
@@ -665,3 +665,54 @@ func (s *store) createGuest(ctx context.Context, name string) (Person, error) {
err := s.g.WithContext(ctx).Create(&p).Error
return p, err
}
+
+// saveDeviceToken records where a person can be reached when the app is
+// not running, and is an upsert on the token rather than on the person:
+// Apple reissues a token to whoever installs the app next, so the same
+// string arriving under a different person means the device changed
+// hands and the old row is wrong. LastSeen moves on every registration,
+// which is what lets a token that has gone quiet be swept later.
+func (s *store) saveDeviceToken(ctx context.Context, personID int64, token, platform string) error {
+ now := s.now().UTC()
+ return s.g.WithContext(ctx).Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "token"}},
+ DoUpdates: clause.AssignmentColumns([]string{"person_id", "platform", "last_seen"}),
+ }).Create(&DeviceToken{
+ PersonID: personID, Token: token, Platform: platform, CreatedAt: now, LastSeen: now,
+ }).Error
+}
+
+// deviceTokensFor answers every device a set of people can be reached
+// on. Plural because one person has a Mac and a phone, and a knock is
+// worth saying on both — whichever they are near.
+func (s *store) deviceTokensFor(ctx context.Context, personIDs []int64) ([]DeviceToken, error) {
+ if len(personIDs) == 0 {
+ return nil, nil
+ }
+ var tokens []DeviceToken
+ err := s.g.WithContext(ctx).Where("person_id IN ?", personIDs).Find(&tokens).Error
+ return tokens, err
+}
+
+// forgetDeviceToken deletes one token, which is the only correct answer
+// to Apple reporting it unregistered. Deleting by token and not by
+// person: the device is gone, the person is not.
+func (s *store) forgetDeviceToken(ctx context.Context, token string) error {
+ return s.g.WithContext(ctx).Where("token = ?", token).Delete(&DeviceToken{}).Error
+}
+
+// memberPersonIDs is everyone who may admit: the people whose address is
+// on the team allowlist. A knock is theirs to answer, and nobody else's.
+func (s *store) memberPersonIDs(ctx context.Context, allowed allowlist) ([]int64, error) {
+ var persons []Person
+ if err := s.g.WithContext(ctx).Where("email NOT LIKE ?", "guest:%").Find(&persons).Error; err != nil {
+ return nil, err
+ }
+ ids := make([]int64, 0, len(persons))
+ for _, p := range persons {
+ if allowed.allows(p.Email) {
+ ids = append(ids, p.ID)
+ }
+ }
+ return ids, nil
+}
diff --git a/native/README.md b/native/README.md
index d442e03..5f6a204 100644
--- a/native/README.md
+++ b/native/README.md
@@ -71,13 +71,20 @@ not frontmost. The admission queue is already on screen for a member who is
looking at the call; this is for the member who is in another Space,
another app, or has the phone in a pocket.
-These are **local** notifications, and that is the whole of what is
-possible here today: there is no APNs anywhere in this suite — no push
-key, no device token registration, no sender — so a member whose app is
-*closed* is not reached. That case needs server-side push and a decision
-about whether a host who is not in the meeting can be notified at all,
-since the waiting queue lives only in the hub's memory and reaches members
-over SSE.
+A knock also reaches a member whose app is **closed**, where the server
+has an Apple push key. The app registers its device token at join
+(`POST /native/devices`); the hub calls back when someone begins waiting;
+the server pushes to every member who was *not* admitted in that room at
+that moment, because the ones who were can already see the queue.
+
+Without a key the server sends nothing and says nothing, which is where
+this app was before push existed: knocks still reach everyone in the room
+over their own streams. It is configured with `CALLS_APNS_KEY` (the .p8
+as Apple issues it, via `carlos secrets set`), `CALLS_APNS_KEY_ID`,
+`CALLS_APNS_TEAM_ID`, `CALLS_APNS_TOPIC_IOS`, `CALLS_APNS_TOPIC_MACOS`
+and optionally `CALLS_APNS_SANDBOX`. All five of the first are required
+together — a half-configured push is refused at boot rather than
+discovered on the first knock.
Someone who arrived while the queue was visible is never announced later,
so tabbing away does not produce a pile of notifications about people who
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift
index 55909c3..9235300 100644
--- a/native/apple/Sources/CallsKit/Call.swift
+++ b/native/apple/Sources/CallsKit/Call.swift
@@ -101,6 +101,10 @@ private final class Events: NSObject, BridgeListenerProtocol {
currentCamera = media.currentCamera
audio.refresh()
KnockNotice.requestPermission()
+ // Asking Apple at join, once there is a session to register the
+ // answer against. Apple replies on its own schedule, at the app
+ // delegate, and may never reply at all.
+ RemoteKnocks.register()
try client.start()
}
public func close() {
diff --git a/native/apple/Sources/CallsKit/CallsModel.swift b/native/apple/Sources/CallsKit/CallsModel.swift
index 555ba08..c1fb867 100644
--- a/native/apple/Sources/CallsKit/CallsModel.swift
+++ b/native/apple/Sources/CallsKit/CallsModel.swift
@@ -197,6 +197,27 @@ import Foundation
}
}
}
+ /// Hands the server an Apple device token, so a knock reaches this
+ /// device when the app is closed. Called from the app delegate when
+ /// Apple answers, which may be long after the join that asked, and
+ /// may never happen at all.
+ ///
+ /// Silent on failure on purpose: nobody asked for this and there is
+ /// nothing they could do about it. The consequence of failing is no
+ /// push, which is what they had before push existed.
+ public func registerDevice(token: Data) {
+ guard signedIn, !origin.isEmpty, !cookies.isEmpty else { return }
+ let origin = self.origin, cookies = self.cookies
+ let hex = RemoteKnocks.hex(token)
+ let platform = RemoteKnocks.platform
+ Task.detached {
+ // gomobile renders a func returning only error as BOOL plus an
+ // NSErrorPointer. Both are dropped: see above.
+ var error: NSError?
+ BridgeRegisterDevice(origin, cookies, hex, platform, &error)
+ }
+ }
+
public func join() {
callEpoch = UUID()
let operation = callEpoch
diff --git a/native/apple/Sources/CallsKit/RemoteKnocks.swift b/native/apple/Sources/CallsKit/RemoteKnocks.swift
new file mode 100644
index 0000000..030e551
--- /dev/null
+++ b/native/apple/Sources/CallsKit/RemoteKnocks.swift
@@ -0,0 +1,51 @@
+import Foundation
+
+#if os(iOS)
+ import UIKit
+#else
+ import AppKit
+#endif
+
+/// Registering this device with Apple, and then with Calls, so that a
+/// knock can reach it when the app is closed.
+///
+/// The local half — KnockWatch — only reaches an app that is running.
+/// This is the other half, and it is the only one that can help the
+/// person whose Mac is asleep or whose phone is in a pocket with Calls
+/// not open.
+///
+/// Everything here is best effort and nothing is ever shown to anyone. A
+/// device that could not register is a device that will not be told
+/// about a knock, which is exactly where every device was before this
+/// existed. It is never a reason to fail a join, and never a sheet.
+@MainActor public enum RemoteKnocks {
+ /// The platform name the server stores alongside the token. The two
+ /// apps have different bundle identifiers and therefore different APNs
+ /// topics, so a token is useless without it.
+ public static var platform: String {
+ #if os(iOS)
+ return "ios"
+ #else
+ return "macos"
+ #endif
+ }
+
+ /// Asks Apple for a device token. The answer arrives at the app
+ /// delegate, asynchronously and possibly never — without the push
+ /// entitlement, or on a build Apple will not issue one for, the
+ /// failure callback is what comes back instead.
+ public static func register() {
+ #if os(iOS)
+ UIApplication.shared.registerForRemoteNotifications()
+ #else
+ NSApplication.shared.registerForRemoteNotifications()
+ #endif
+ }
+
+ /// Apple's token is opaque bytes; the server stores it as lower-case
+ /// hex, which is the form every APNs provider uses and the form the
+ /// registration endpoint validates.
+ public static func hex(_ token: Data) -> String {
+ token.map { String(format: "%02x", $0) }.joined()
+ }
+}
diff --git a/native/apple/Sources/CallsMac/CallsMac.swift b/native/apple/Sources/CallsMac/CallsMac.swift
index c059dfa..1f4daf9 100644
--- a/native/apple/Sources/CallsMac/CallsMac.swift
+++ b/native/apple/Sources/CallsMac/CallsMac.swift
@@ -17,6 +17,7 @@ import WebRTC
Window("Calls", id: "main") {
ContentView(model: model).frame(minWidth: 760, minHeight: 540).onDisappear { model.leave() }
.onOpenURL { model.openMeetingURL($0) }
+ .onAppear { CallsAppDelegate.model = model }
}
.defaultSize(width: 1000, height: 760)
.commands {
@@ -27,6 +28,25 @@ import WebRTC
}
final class CallsAppDelegate: NSObject, NSApplicationDelegate {
+ /// Set by the app so Apple's answer can reach the session that asked.
+ /// Weak-ish by convention rather than by keyword: the model outlives
+ /// the delegate for the whole life of the process.
+ @MainActor static var model: CallsModel?
+
+ func application(
+ _ application: NSApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data
+ ) {
+ Task { @MainActor in CallsAppDelegate.model?.registerDevice(token: token) }
+ }
+
+ func application(
+ _ application: NSApplication, didFailToRegisterForRemoteNotificationsWithError error: Error
+ ) {
+ // Expected on any build without the push entitlement, which is every
+ // ad-hoc signed local build. Silent: the person did not ask for this
+ // and the consequence is simply no push.
+ }
+
func applicationDidFinishLaunching(_ notification: Notification) {
if let url = Bundle.main.url(forResource: "MacIcon", withExtension: "png")
?? Bundle.module.url(forResource: "MacIcon", withExtension: "png")
diff --git a/native/bridge/bridge.go b/native/bridge/bridge.go
index 0d30843..8784a66 100644
--- a/native/bridge/bridge.go
+++ b/native/bridge/bridge.go
@@ -103,3 +103,15 @@ func (c *Client) Send(typ, to string, generation int64, payload string) error {
}
func (c *Client) Leave() { c.room.Leave() }
func (c *Client) Close() { c.room.Close() }
+
+// RegisterDevice hands the server an Apple device token so a knock can
+// reach this device when the app is closed. Best effort: the caller
+// ignores the error beyond logging, because a device that did not
+// register is simply one that will not be pushed to.
+func RegisterDevice(origin, cookiesJSON, token, platform string) error {
+ jar, err := sessionJar(origin, cookiesJSON)
+ if err != nil {
+ return err
+ }
+ return callscore.RegisterDevice(context.Background(), origin, &http.Client{Jar: jar}, token, platform)
+}
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift
index d094e07..6bad8fd 100644
--- a/native/ios/Sources/CallsPhone.swift
+++ b/native/ios/Sources/CallsPhone.swift
@@ -2,13 +2,35 @@ import CallsKit
import SwiftUI
import WebRTC
+/// The delegate exists for one reason: Apple hands a device token to
+/// the application delegate and nowhere else, so a pure SwiftUI app has
+/// no way to receive one.
+final class CallsPhoneDelegate: NSObject, UIApplicationDelegate {
+ @MainActor static var model: CallsModel?
+
+ func application(
+ _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data
+ ) {
+ Task { @MainActor in CallsPhoneDelegate.model?.registerDevice(token: token) }
+ }
+
+ func application(
+ _ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error
+ ) {
+ // Expected in the simulator and on any build without the push
+ // entitlement. Silent, for the same reason as on the Mac.
+ }
+}
+
@main struct CallsPhoneApp: App {
+ @UIApplicationDelegateAdaptor(CallsPhoneDelegate.self) private var appDelegate
@StateObject private var model = CallsModel()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
PhoneHome(model: model)
.onOpenURL { model.openMeetingURL($0) }
+ .onAppear { CallsPhoneDelegate.model = model }
.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.