Add private Apple activity notifications with durable delivery
20 files changed,
+711
−8
apple/Infinity/Infinity.entitlements+12 −1apple/Infinity/InfinityApp.swift+7 −1apple/Infinity/NativeNotifications.swift+137 −0apple/Infinity/OchoRootView.swift+3 −1apple/InfinityMac/InfinityMac.entitlements+18 −1apple/project.yml+6 −1cmd/infinity-home/main.go+12 −1docs/infinity/push.md+15 −0internal/apns/apns.go+12 −1internal/apns/apns_test.go+24 −0internal/infinity/app.go+1 −0internal/infinity/inbox.go+5 −0internal/infinity/migrations/0002_push.sql+6 −0internal/infinity/outbox.go+4 −1internal/infinity/push.go+109 −0internal/infinity/push_test.go+88 −0internal/infinityhome/home.go+2 −0internal/infinityhome/migrations/0004_push.sql+7 −0internal/infinityhome/push.go+150 −0internal/infinityhome/push_test.go+93 −0
Intent
diff --git a/apple/Infinity/Infinity.entitlements b/apple/Infinity/Infinity.entitlements| index f7f17c0..0d8b4ee 100644 |
| --- a/apple/Infinity/Infinity.entitlements |
| +++ b/apple/Infinity/Infinity.entitlements |
| @@ -1 +1,12 @@ |
| -<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>com.apple.developer.associated-domains</key><array><string>webcredentials:ocho.chat</string></array></dict></plist> |
| +<?xml version="1.0" encoding="UTF-8"?> |
| +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| +<plist version="1.0"> |
| +<dict> |
| + <key>aps-environment</key> |
| + <string>$(OCHO_APNS_ENVIRONMENT)</string> |
| + <key>com.apple.developer.associated-domains</key> |
| + <array> |
| + <string>webcredentials:ocho.chat</string> |
| + </array> |
| +</dict> |
| +</plist> |
diff --git a/apple/Infinity/InfinityApp.swift b/apple/Infinity/InfinityApp.swift| index 7c755c4..8d06c0e 100644 |
| --- a/apple/Infinity/InfinityApp.swift |
| +++ b/apple/Infinity/InfinityApp.swift |
| @@ -1,13 +1,19 @@ |
| // π€ Fully native Ocho: SwiftUI, CryptoKit, URLSession. No WebViews. |
| import SwiftUI |
| @main struct InfinityApp: App { |
| + #if os(iOS) |
| + @UIApplicationDelegateAdaptor(OchoAppDelegate.self) private var appDelegate |
| + #else |
| + @NSApplicationDelegateAdaptor(OchoAppDelegate.self) private var appDelegate |
| + #endif |
| @StateObject private var account = AccountStore() |
| var body: some Scene { |
| WindowGroup("Ocho") { |
| OchoRootView().environmentObject(account) |
| .tint(Color(red: 0.40, green: 0.29, blue: 0.82)) |
| .onOpenURL { account.open($0) } |
| - .task { await account.poll() } |
| + .task { await NativeNotifications.shared.configure(account); await account.poll() } |
| + .onChange(of: account.identity?.origin) { _, _ in Task { await NativeNotifications.shared.synchronize() } } |
| } |
| #if os(macOS) |
| .defaultSize(width: 1050, height: 760) |
diff --git a/apple/Infinity/NativeNotifications.swift b/apple/Infinity/NativeNotifications.swift| new file mode 100644 |
| index 0000000..ced7593 |
| --- /dev/null |
| +++ b/apple/Infinity/NativeNotifications.swift |
| @@ -0,0 +1,137 @@ |
| +// π€ Native APNs permission, token rotation and content-blind account routing. |
| +import SwiftUI |
| +import UserNotifications |
| +import OchoCore |
| +#if os(iOS) |
| +import UIKit |
| +#else |
| +import AppKit |
| +#endif |
| + |
| +@MainActor final class NativeNotifications: NSObject, ObservableObject, UNUserNotificationCenterDelegate { |
| + static let shared = NativeNotifications() |
| + @Published var enabled = UserDefaults.standard.bool(forKey: "ocho.notifications.enabled") |
| + @Published var status = "Notifications are off on this device." |
| + @Published var busy = false |
| + private weak var account: AccountStore? |
| + private var token: String? |
| + private var registered = "" |
| + private var retryNeeded = false |
| + func configure(_ account: AccountStore) async { |
| + self.account = account |
| + UNUserNotificationCenter.current().delegate = self |
| + let settings = await UNUserNotificationCenter.current().notificationSettings() |
| + if enabled && (settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional) { register() } |
| + else if enabled { await disable() } |
| + } |
| + func enable() async { |
| + do { |
| + guard try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) else { |
| + status = "Allow Ocho notifications in system Settings to receive alerts."; return |
| + } |
| + enabled = true; UserDefaults.standard.set(true, forKey: "ocho.notifications.enabled") |
| + status = "Registering this deviceβ¦"; register() |
| + await synchronize() |
| + } catch { status = "Could not enable notifications. Please try again." } |
| + } |
| + private func register() { |
| + #if os(iOS) |
| + UIApplication.shared.registerForRemoteNotifications() |
| + #else |
| + NSApplication.shared.registerForRemoteNotifications() |
| + #endif |
| + } |
| + func received(_ data: Data) { |
| + token = data.map { String(format: "%02x", $0) }.joined() |
| + Task { await synchronize() } |
| + } |
| + func failed() { status = "Apple registration failed. Try enabling notifications again." } |
| + private func secret(_ id: Identity) throws -> String { |
| + let key = "push-route:" + id.origin |
| + if let data = try KeychainIdentity.read(account: key), let value = String(data: data, encoding: .utf8) { return value } |
| + let value = Crypto.random(); try KeychainIdentity.write(Data(value.utf8), account: key); return value |
| + } |
| + // The embedded profile is signed as part of the app. Store-distributed apps |
| + // may omit it and always use the production APNs gateway. |
| + private var sandbox: Bool { |
| + let paths = ["embedded.mobileprovision", "Contents/embedded.provisionprofile"] |
| + for path in paths { |
| + guard let data = try? Data(contentsOf: Bundle.main.bundleURL.appendingPathComponent(path)), |
| + let start = data.range(of: Data("<plist".utf8)), let end = data.range(of: Data("</plist>".utf8), in: start.lowerBound..<data.endIndex), |
| + let value = try? PropertyListSerialization.propertyList(from: data[start.lowerBound..<end.upperBound], format: nil) as? [String: Any], |
| + let entitlements = value["Entitlements"] as? [String: Any] else { continue } |
| + return (entitlements["aps-environment"] as? String ?? entitlements["com.apple.developer.aps-environment"] as? String) == "development" |
| + } |
| + return false |
| + } |
| + private struct Reply: Decodable { let ok: Bool } |
| + private func post(_ origin: String, _ path: String, id: Identity, body: [String: Any]) async throws { |
| + let _: Reply = try await API.request(origin, path, auth: id.auth, method: "POST", body: JSONSerialization.data(withJSONObject: body), as: Reply.self) |
| + } |
| + func synchronize() async { |
| + guard enabled, let id = account?.identity, let token else { return } |
| + if busy { retryNeeded = true; return } |
| + busy = true |
| + defer { busy = false; if retryNeeded { retryNeeded = false; Task { await synchronize() } } } |
| + do { |
| + let secret = try secret(id) |
| + let stamp = id.origin + token |
| + guard stamp != registered else { return } |
| + guard let topic = Bundle.main.bundleIdentifier, ["chat.ocho.app", "chat.ocho.app.mac"].contains(topic) else { throw OchoError.invalid("Use the Team Tito Ocho build for notifications.") } |
| + try await post(AccountStore.home, "/api/push/register", id: id, body: ["origin": id.origin, "secret": secret, "token": token, "topic": topic, "sandbox": sandbox, "enabled": true]) |
| + try await post(id.origin, "/api/push", id: id, body: ["secret": secret, "enabled": true]) |
| + registered = stamp; status = "Notifications are on. Message contents stay private." |
| + } catch { status = "Could not finish notification setup. Tap Retry. " + error.localizedDescription } |
| + } |
| + func retry() async { registered = ""; register(); await synchronize() } |
| + func disable() async { |
| + guard !busy else { return }; busy = true; defer { busy = false } |
| + do { |
| + if let id = account?.identity { |
| + let secret = try secret(id) |
| + try await post(id.origin, "/api/push", id: id, body: ["secret": secret, "enabled": false]) |
| + try await post(AccountStore.home, "/api/push/register", id: id, body: ["origin": id.origin, "secret": secret, "enabled": false]) |
| + } |
| + enabled = false; registered = ""; UserDefaults.standard.set(false, forKey: "ocho.notifications.enabled") |
| + status = "Notifications are off on this device." |
| + #if os(iOS) |
| + UIApplication.shared.unregisterForRemoteNotifications() |
| + #else |
| + NSApplication.shared.unregisterForRemoteNotifications() |
| + #endif |
| + } catch { status = "Could not disable notifications yet. Please retry while connected." } |
| + } |
| + nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { |
| + completionHandler([]) // The open native feed already displays new activity. |
| + } |
| + nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { |
| + Task { @MainActor in try? await self.account?.refresh(); completionHandler() } |
| + } |
| +} |
| + |
| +#if os(iOS) |
| +final class OchoAppDelegate: NSObject, UIApplicationDelegate { |
| + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { Task { @MainActor in NativeNotifications.shared.received(deviceToken) } } |
| + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { Task { @MainActor in NativeNotifications.shared.failed() } } |
| +} |
| +#else |
| +final class OchoAppDelegate: NSObject, NSApplicationDelegate { |
| + func application(_ application: NSApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { Task { @MainActor in NativeNotifications.shared.received(deviceToken) } } |
| + func application(_ application: NSApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { Task { @MainActor in NativeNotifications.shared.failed() } } |
| +} |
| +#endif |
| + |
| +struct NotificationsSheet: View { |
| + @ObservedObject private var notifications = NativeNotifications.shared |
| + @Environment(\.dismiss) private var dismiss |
| + var body: some View { |
| + NavigationStack { Form { |
| + Text("Get an alert when thereβs new activity, even when Ocho is closed. Notifications never show message contents.") |
| + Text(notifications.status).foregroundStyle(.secondary) |
| + if notifications.enabled { |
| + Button("Retry") { Task { await notifications.retry() } } |
| + Button("Turn off notifications") { Task { await notifications.disable() } } |
| + } else { Button("Enable notifications") { Task { await notifications.enable() } } } |
| + }.disabled(notifications.busy).navigationTitle("Notifications").toolbar { ToolbarItem { Button("Done") { dismiss() } } } }.frame(minWidth: 320, minHeight: 260) |
| + } |
| +} |
diff --git a/apple/Infinity/OchoRootView.swift b/apple/Infinity/OchoRootView.swift| index 1e545fc..f75c5e4 100644 |
| --- a/apple/Infinity/OchoRootView.swift |
| +++ b/apple/Infinity/OchoRootView.swift |
| @@ -6,7 +6,7 @@ struct OchoRootView: View { |
| @State private var sheet: Sheet? |
| @State private var shareURL: URL? |
| @State private var search = "" |
| - enum Sheet: String, Identifiable { case connect, group, recovery; var id: String { rawValue } } |
| + enum Sheet: String, Identifiable { case connect, group, recovery, notifications; var id: String { rawValue } } |
| var body: some View { |
| Group { |
| if account.identity == nil { WelcomeView() } |
| @@ -41,6 +41,7 @@ struct OchoRootView: View { |
| Button("Connect with a link", systemImage: "link") { sheet = .connect } |
| Button("New group", systemImage: "person.3") { sheet = .group } |
| Divider() |
| + Button("Notifications", systemImage: "bell") { sheet = .notifications } |
| Button("Save recovery file", systemImage: "key") { sheet = .recovery } |
| } label: { Image(systemName: "square.and.pencil") }.disabled(account.busy).accessibilityLabel("New conversation") } |
| } |
| @@ -61,6 +62,7 @@ struct OchoRootView: View { |
| case .connect: ConnectSheet() |
| case .group: GroupSheet() |
| case .recovery: RecoverySheet() |
| + case .notifications: NotificationsSheet() |
| } |
| } |
| .sheet(isPresented: Binding(get: { shareURL != nil }, set: { if !$0 { shareURL = nil } })) { |
diff --git a/apple/InfinityMac/InfinityMac.entitlements b/apple/InfinityMac/InfinityMac.entitlements| index 4bc1832..e546ba9 100644 |
| --- a/apple/InfinityMac/InfinityMac.entitlements |
| +++ b/apple/InfinityMac/InfinityMac.entitlements |
| @@ -1 +1,18 @@ |
| -<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>com.apple.security.app-sandbox</key><true/><key>com.apple.security.network.client</key><true/><key>com.apple.security.files.user-selected.read-write</key><true/><key>com.apple.developer.associated-domains</key><array><string>webcredentials:ocho.chat</string></array></dict></plist> |
| +<?xml version="1.0" encoding="UTF-8"?> |
| +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| +<plist version="1.0"> |
| +<dict> |
| + <key>com.apple.developer.aps-environment</key> |
| + <string>$(OCHO_APNS_ENVIRONMENT)</string> |
| + <key>com.apple.developer.associated-domains</key> |
| + <array> |
| + <string>webcredentials:ocho.chat</string> |
| + </array> |
| + <key>com.apple.security.app-sandbox</key> |
| + <true/> |
| + <key>com.apple.security.files.user-selected.read-write</key> |
| + <true/> |
| + <key>com.apple.security.network.client</key> |
| + <true/> |
| +</dict> |
| +</plist> |
diff --git a/apple/project.yml b/apple/project.yml| index b58deef..067be9b 100644 |
| --- a/apple/project.yml |
| +++ b/apple/project.yml |
| @@ -8,12 +8,17 @@ settings: |
| base: |
| SWIFT_VERSION: '5.0' |
| MARKETING_VERSION: '0.2.0' |
| - CURRENT_PROJECT_VERSION: '4' |
| + CURRENT_PROJECT_VERSION: '5' |
| GENERATE_INFOPLIST_FILE: YES |
| ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon |
| CODE_SIGN_STYLE: Automatic |
| DEVELOPMENT_TEAM: 9U4A84C4WK |
| INFOPLIST_KEY_CFBundleDisplayName: Ocho |
| + configs: |
| + Debug: |
| + OCHO_APNS_ENVIRONMENT: development |
| + Release: |
| + OCHO_APNS_ENVIRONMENT: production |
| packages: |
| OchoCore: |
| path: OchoCore |
diff --git a/cmd/infinity-home/main.go b/cmd/infinity-home/main.go| index 4c4eb09..52cedaf 100644 |
| --- a/cmd/infinity-home/main.go |
| +++ b/cmd/infinity-home/main.go |
| @@ -5,6 +5,7 @@ import ( |
| "amadan.net/rastrillo/rastrillo" |
| "amadan.net/rastrillo/rastrillo/db" |
| "encoding/base64" |
| + "lchat/internal/apns" |
| "lchat/internal/infinitydiag" |
| "lchat/internal/infinityhome" |
| "log/slog" |
| @@ -30,7 +31,17 @@ func run() error { |
| } |
| defer d.Close() |
| key, _ := base64.RawURLEncoding.DecodeString(os.Getenv("INFINITY_CLAIM_KEY")) |
| - h, e := infinityhome.New(d, infinityhome.Config{AndroidCertSHA256: os.Getenv("INFINITY_ANDROID_CERT_SHA256"), Origin: os.Getenv("INFINITY_ORIGIN"), Console: "https://console.carloku.com", Account: "bdy", App: "infinity", Domain: "bdy.oncarlos.com", Channel: os.Getenv("INFINITY_CHANNEL"), Token: os.Getenv("INFINITY_PROVISION_TOKEN"), ClaimKey: key}) |
| + push := map[string]infinityhome.PushSender{} |
| + if raw := os.Getenv("INFINITY_APNS_KEY"); raw != "" { |
| + for _, topic := range []string{"chat.ocho.app", "chat.ocho.app.mac"} { |
| + sender, err := apns.New(raw, os.Getenv("INFINITY_APNS_KEY_ID"), "9U4A84C4WK", topic) |
| + if err != nil { |
| + return err |
| + } |
| + push[topic] = sender |
| + } |
| + } |
| + h, e := infinityhome.New(d, infinityhome.Config{Push: push, AndroidCertSHA256: os.Getenv("INFINITY_ANDROID_CERT_SHA256"), Origin: os.Getenv("INFINITY_ORIGIN"), Console: "https://console.carloku.com", Account: "bdy", App: "infinity", Domain: "bdy.oncarlos.com", Channel: os.Getenv("INFINITY_CHANNEL"), Token: os.Getenv("INFINITY_PROVISION_TOKEN"), ClaimKey: key}) |
| if e != nil { |
| return e |
| } |
diff --git a/docs/infinity/push.md b/docs/infinity/push.md| new file mode 100644 |
| index 0000000..b77e3be |
| --- /dev/null |
| +++ b/docs/infinity/push.md |
| @@ -0,0 +1,15 @@ |
| +# π€ Ocho Apple push β 2026-09-07 |
| + |
| +Paul requested push notifications and authorized the Team Tito APNs key in 1Password item `jlbqeh2gzltde3psytmccpqoae`. Scope: native iOS/macOS Ocho account activity. Android FCM and browser Web Push require their own provider setup and are not implied by an Apple key. |
| + |
| +Use the inherited APNs provider client. The key lives only in sealed configuration on infinity-home. Owners authenticate against their known personal instance before registering an Apple token, topic and sandbox/production environment at the front door. A per-install random capability binds that one destination; each personal instance stores only its own notification capabilities, never the Apple key. The relay accepts only its fixed generic activity payload, not arbitrary titles, URLs or content. The topic allowlist is chat.ocho.app and chat.ocho.app.mac. |
| + |
| +Native UI explicitly requests system permission, registers the current token at launch when permission exists, and offers disable/retry controls. Registration is idempotent. The native device keeps its per-account capability in Keychain. Home registration must succeed before the personal route is enabled. Disabling removes the personal route first, then invalidates the front-door capability. APNs token rotation updates the existing route. Sandbox/production comes from the signed entitlement, not the Debug build flag. |
| + |
| +A fresh accepted inbox envelope increments durable pending generation for each enabled route in the same transaction. Duplicates produce no new job. Jobs coalesce, have a 24-hour TTL, retry with bounded exponential backoff, and use the existing Carlos scheduled delivery task while the app is asleep. A successful send clears only the generation it actually sent. Revoked/invalid routes are removed. Per-route relay pacing prevents notification floods. APNs acceptance is not proof of device delivery; failures never discard encrypted inbox messages. |
| + |
| +The notification contains only fixed Ocho branding/activity text and sound. No sender, conversation name, account URL, encrypted envelope or usable private key is sent to Apple. Taps open the native chat list and refresh/decrypt through the existing Go engine. Because event types are encrypted, alerts cover invitations and group changes as well as messages. Metadata exception: the relay stores account host, device token, topic, environment, capability hash and send timing; personal instances store routing capabilities and pending job timing. This is not anonymity or guaranteed exactly-once notification delivery. |
| + |
| +Validate owner isolation, topic/token input, duplicate suppression, retry/generation safety, route revocation and generic APNs payloads with stub servers. Build/sign both native apps with push entitlements and verify actual provisioning. Deploy home before personal and verify response bodies. Real device notification permission/token registration and APNs delivery require a device account and user permission; do not fabricate this evidence or send messages to real contacts for testing. |
| + |
| +Implementation checkpoint: personal/home/APNs tests pass under the race detector; Chromium/WebKit chat regressions and the actual SwiftUI chat UI test pass. Both Team Tito builds sign with development APNs entitlements. The key is sealed only on infinity-home; sandbox/production provider probes for both bundle IDs return the expected invalid-token response. No real device delivery has been claimed. Deployment and a user-enabled device token remain to be verified. |
diff --git a/internal/apns/apns.go b/internal/apns/apns.go| index 217dd1a..f2148e4 100644 |
| --- a/internal/apns/apns.go |
| +++ b/internal/apns/apns.go |
| @@ -24,6 +24,7 @@ import ( |
| "fmt" |
| "io" |
| "net/http" |
| + "strconv" |
| "strings" |
| "sync" |
| "time" |
| @@ -206,7 +207,7 @@ func (c *Client) SendAlert(deviceToken string, sandbox bool, title, body string) |
| return c.post(deviceToken, sandbox, BuildAlertPayload(title, body)) |
| } |
| -func (c *Client) post(deviceToken string, sandbox bool, payload []byte) error { |
| +func (c *Client) post(deviceToken string, sandbox bool, payload []byte, expires ...int64) error { |
| if !ValidDeviceToken(deviceToken) { |
| return errors.New("apns: invalid device token") // never ErrGone β not a prune signal |
| } |
| @@ -226,6 +227,10 @@ func (c *Client) post(deviceToken string, sandbox bool, payload []byte) error { |
| req.Header.Set("apns-topic", c.topic) |
| req.Header.Set("apns-push-type", "alert") |
| req.Header.Set("apns-priority", "10") |
| + if len(expires) > 0 { |
| + req.Header.Set("apns-expiration", strconv.FormatInt(expires[0], 10)) |
| + req.Header.Set("apns-collapse-id", "ocho-activity") |
| + } |
| resp, err := c.http.Do(req) |
| if err != nil { |
| return err |
| @@ -240,3 +245,9 @@ func (c *Client) post(deviceToken string, sandbox bool, payload []byte) error { |
| } |
| return fmt.Errorf("apns: status %d body=%q", resp.StatusCode, body) |
| } |
| + |
| +// SendNotice sends a generic notice without conversation metadata or NSE fields. |
| +func (c *Client) SendNotice(token string, sandbox bool, title, body string) error { |
| + payload, _ := json.Marshal(map[string]any{"aps": map[string]any{"alert": map[string]string{"title": title, "body": body}, "sound": "default"}}) |
| + return c.post(token, sandbox, payload, time.Now().Add(time.Hour).Unix()) |
| +} |
diff --git a/internal/apns/apns_test.go b/internal/apns/apns_test.go| index de8d011..598f610 100644 |
| --- a/internal/apns/apns_test.go |
| +++ b/internal/apns/apns_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "crypto/elliptic" |
| "crypto/rand" |
| "crypto/x509" |
| + "encoding/json" |
| "encoding/pem" |
| "errors" |
| "net/http" |
| @@ -135,3 +136,26 @@ func TestBuildAlertPayloadStable(t *testing.T) { |
| t.Errorf("alert envelope drift:\n got: %s\nwant: %s", got, want) |
| } |
| } |
| + |
| +func TestGenericNoticeHasNoConversationPayload(t *testing.T) { |
| + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + var payload map[string]any |
| + if json.NewDecoder(r.Body).Decode(&payload) != nil { |
| + t.Fatal("bad JSON") |
| + } |
| + if len(payload) != 1 || payload["aps"] == nil { |
| + t.Error("unexpected metadata") |
| + } |
| + aps := payload["aps"].(map[string]any) |
| + if len(aps) != 2 || aps["sound"] != "default" || aps["mutable-content"] != nil { |
| + t.Error("not a fixed generic notification") |
| + } |
| + if r.Header.Get("apns-topic") != "com.example.app" || r.Header.Get("authorization") == "" { |
| + t.Error("missing APNs authentication") |
| + } |
| + })) |
| + defer stub.Close() |
| + if e := newTestClient(t, stub.URL).SendNotice(strings.Repeat("ab", 32), true, "Ocho", "New activity in Ocho."); e != nil { |
| + t.Fatal(e) |
| + } |
| +} |
diff --git a/internal/infinity/app.go b/internal/infinity/app.go| index 6616257..c324ebf 100644 |
| --- a/internal/infinity/app.go |
| +++ b/internal/infinity/app.go |
| @@ -71,6 +71,7 @@ func (a *App) Handler() *http.ServeMux { |
| m.Handle("POST /api/capabilities", a.owner(a.capability)) |
| m.Handle("DELETE /api/capabilities/{hash}", a.owner(a.revoke)) |
| m.Handle("POST /api/send", a.owner(a.send)) |
| + m.Handle("POST /api/push", a.owner(a.registerPush)) |
| m.Handle("POST /api/retry", a.owner(a.retry)) |
| m.Handle("GET /api/delivery", a.owner(a.delivery)) |
| m.HandleFunc("POST /tasks/delivery", a.tick) |
diff --git a/internal/infinity/inbox.go b/internal/infinity/inbox.go| index d308df5..4c1eb1d 100644 |
| --- a/internal/infinity/inbox.go |
| +++ b/internal/infinity/inbox.go |
| @@ -69,10 +69,15 @@ func (a *App) receive(w http.ResponseWriter, r *http.Request) { |
| dbError(w, e) |
| return |
| } |
| + if _, e = tx.ExecContext(r.Context(), "UPDATE push_routes SET generation=generation+1,pending=1,attempts=0,next_attempt=?,queued=?", now(), now()); e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| if e = tx.Commit(); e != nil { |
| dbError(w, e) |
| return |
| } |
| + a.schedule(r.Context()) |
| reply(w, 201, map[string]bool{"ok": true}) |
| } |
| func (a *App) events(w http.ResponseWriter, r *http.Request) { |
diff --git a/internal/infinity/migrations/0002_push.sql b/internal/infinity/migrations/0002_push.sql| new file mode 100644 |
| index 0000000..591178e |
| --- /dev/null |
| +++ b/internal/infinity/migrations/0002_push.sql |
| @@ -0,0 +1,6 @@ |
| +-- π€ One durable coalesced job per installation; never contains message plaintext. |
| +CREATE TABLE push_routes ( |
| + secret TEXT PRIMARY KEY, generation INTEGER NOT NULL DEFAULT 0, |
| + pending INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0, |
| + next_attempt INTEGER NOT NULL DEFAULT 0, queued INTEGER NOT NULL DEFAULT 0 |
| +); |
diff --git a/internal/infinity/outbox.go b/internal/infinity/outbox.go| index ae78800..8db2671 100644 |
| --- a/internal/infinity/outbox.go |
| +++ b/internal/infinity/outbox.go |
| @@ -62,6 +62,7 @@ func (a *App) send(w http.ResponseWriter, r *http.Request) { |
| } |
| // Work stays in the request, so the activator cannot sleep halfway through. |
| a.flush(r.Context()) |
| + a.flushPush(r.Context()) |
| a.schedule(r.Context()) |
| reply(w, 202, map[string]bool{"queued": true}) |
| } |
| @@ -118,6 +119,7 @@ func (a *App) flush(ctx context.Context) { |
| } |
| func (a *App) retry(w http.ResponseWriter, r *http.Request) { |
| a.flush(r.Context()) |
| + a.flushPush(r.Context()) |
| a.schedule(r.Context()) |
| reply(w, 200, map[string]bool{"ok": true}) |
| } |
| @@ -127,12 +129,13 @@ func (a *App) tick(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| a.flush(r.Context()) |
| + a.flushPush(r.Context()) |
| a.schedule(r.Context()) |
| reply(w, 200, map[string]bool{"ok": true}) |
| } |
| func (a *App) NextDue() time.Time { |
| var n int64 |
| - if a.db.QueryRow("SELECT coalesce(min(next_attempt),-1) FROM outbox WHERE delivered=0").Scan(&n) != nil || n < 0 { |
| + if a.db.QueryRow("SELECT coalesce(min(next_attempt),-1) FROM (SELECT next_attempt FROM outbox WHERE delivered=0 UNION ALL SELECT next_attempt FROM push_routes WHERE pending=1)").Scan(&n) != nil || n < 0 { |
| return time.Time{} |
| } |
| if n == 0 { |
diff --git a/internal/infinity/push.go b/internal/infinity/push.go| new file mode 100644 |
| index 0000000..deb6186 |
| --- /dev/null |
| +++ b/internal/infinity/push.go |
| @@ -0,0 +1,109 @@ |
| +// π€ Personal instances hold per-device wake capabilities, never the Apple key. |
| +package infinity |
| + |
| +import ( |
| + "context" |
| + "io" |
| + "net/http" |
| + "strings" |
| +) |
| + |
| +func (a *App) registerPush(w http.ResponseWriter, r *http.Request) { |
| + var p struct { |
| + Secret string `json:"secret"` |
| + Enabled bool `json:"enabled"` |
| + } |
| + if !decode(w, r, &p) { |
| + return |
| + } |
| + if !validSecret(p.Secret) { |
| + fail(w, 400, "Invalid notification capability.") |
| + return |
| + } |
| + if !p.Enabled { |
| + if _, e := a.db.ExecContext(r.Context(), "DELETE FROM push_routes WHERE secret=?", p.Secret); e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + } else { |
| + res, e := a.db.ExecContext(r.Context(), "INSERT OR IGNORE INTO push_routes(secret) SELECT ? WHERE (SELECT count(*) FROM push_routes)<16", p.Secret) |
| + if e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + n, _ := res.RowsAffected() |
| + if n == 0 { |
| + var found string |
| + if a.db.QueryRowContext(r.Context(), "SELECT secret FROM push_routes WHERE secret=?", p.Secret).Scan(&found) != nil { |
| + fail(w, 409, "Too many notification devices.") |
| + return |
| + } |
| + } |
| + } |
| + reply(w, 200, map[string]bool{"ok": true}) |
| +} |
| +func (a *App) flushPush(ctx context.Context) { |
| + a.db.ExecContext(ctx, "UPDATE push_routes SET pending=0 WHERE pending=1 AND queued<?", now()-86400) |
| + rows, e := a.db.QueryContext(ctx, "SELECT secret,generation,attempts FROM push_routes WHERE pending=1 AND next_attempt<=? LIMIT 4", now()) |
| + if e != nil { |
| + return |
| + } |
| + type job struct { |
| + secret string |
| + generation int64 |
| + attempts int |
| + } |
| + var jobs []job |
| + for rows.Next() { |
| + var j job |
| + if rows.Scan(&j.secret, &j.generation, &j.attempts) != nil { |
| + rows.Close() |
| + return |
| + } |
| + jobs = append(jobs, j) |
| + } |
| + rows.Close() |
| + home := a.cfg.HomeOrigin |
| + if home == "" { |
| + home = "https://ocho.chat" |
| + } |
| + for _, j := range jobs { |
| + if ctx.Err() != nil { |
| + return |
| + } |
| + // Claim before network I/O to prevent concurrent ticks duplicating delivery. |
| + res, e := a.db.ExecContext(ctx, "UPDATE push_routes SET next_attempt=? WHERE secret=? AND generation=? AND pending=1 AND next_attempt<=?", now()+90, j.secret, j.generation, now()) |
| + if e != nil { |
| + continue |
| + } |
| + n, _ := res.RowsAffected() |
| + if n != 1 { |
| + continue |
| + } |
| + req, e := http.NewRequestWithContext(ctx, "POST", strings.TrimRight(home, "/")+"/api/push/wake", nil) |
| + if e != nil { |
| + continue |
| + } |
| + req.Header.Set("Authorization", "Bearer "+j.secret) |
| + response, e := a.client.Do(req) |
| + status := 0 |
| + if e == nil { |
| + status = response.StatusCode |
| + io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) |
| + response.Body.Close() |
| + } |
| + switch status { |
| + case 200: |
| + a.db.ExecContext(ctx, "UPDATE push_routes SET pending=0,attempts=0 WHERE secret=? AND generation=?", j.secret, j.generation) |
| + case 403, 410: |
| + a.db.ExecContext(ctx, "DELETE FROM push_routes WHERE secret=? AND generation=?", j.secret, j.generation) |
| + default: |
| + shift := j.attempts |
| + if shift > 6 { |
| + shift = 6 |
| + } |
| + delay := int64(30 << shift) |
| + a.db.ExecContext(ctx, "UPDATE push_routes SET attempts=attempts+1,next_attempt=? WHERE secret=? AND generation=?", now()+delay, j.secret, j.generation) |
| + } |
| + } |
| +} |
diff --git a/internal/infinity/push_test.go b/internal/infinity/push_test.go| new file mode 100644 |
| index 0000000..7102fa7 |
| --- /dev/null |
| +++ b/internal/infinity/push_test.go |
| @@ -0,0 +1,88 @@ |
| +// π€ Durable notifications cannot alter inbox delivery or owner isolation. |
| +package infinity |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/http/httptest" |
| + "sync/atomic" |
| + "testing" |
| +) |
| + |
| +func TestPushQueue(t *testing.T) { |
| + f := newFixture(t) |
| + peer := newFixture(t) |
| + cap := secret() |
| + f.call(t, "POST", "/api/push", "wrong", map[string]any{"secret": cap, "enabled": true}, 401) |
| + f.call(t, "POST", "/api/push", f.auth, map[string]any{"secret": cap, "enabled": true}, 200) |
| + var calls atomic.Int32 |
| + var status atomic.Int32 |
| + status.Store(503) |
| + relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/api/push/wake" || r.Header.Get("Authorization") != "Bearer "+cap { |
| + t.Error("incorrect relay routing") |
| + } |
| + calls.Add(1) |
| + w.WriteHeader(int(status.Load())) |
| + })) |
| + defer relay.Close() |
| + f.a.cfg.HomeOrigin = relay.URL |
| + env := signedEnvelope(t, peer, f) |
| + f.call(t, "POST", "/api/inbox", f.cap, env, 201) |
| + f.call(t, "POST", "/api/inbox", f.cap, env, 200) |
| + var gen, pending, attempts int |
| + f.a.db.QueryRow("SELECT generation,pending FROM push_routes").Scan(&gen, &pending) |
| + if gen != 1 || pending != 1 || f.a.NextDue().IsZero() { |
| + t.Fatal("fresh inbox did not durably queue once") |
| + } |
| + f.a.flushPush(context.Background()) |
| + f.a.db.QueryRow("SELECT pending,attempts FROM push_routes").Scan(&pending, &attempts) |
| + if pending != 1 || attempts != 1 || calls.Load() != 1 { |
| + t.Fatal("transient error lost the job") |
| + } |
| + f.a.flushPush(context.Background()) |
| + if calls.Load() != 1 { |
| + t.Fatal("backoff ignored") |
| + } |
| + status.Store(200) |
| + f.a.db.Exec("UPDATE push_routes SET next_attempt=0") |
| + f.a.flushPush(context.Background()) |
| + f.a.db.QueryRow("SELECT pending FROM push_routes").Scan(&pending) |
| + if pending != 0 || calls.Load() != 2 { |
| + t.Fatal("success not acknowledged") |
| + } |
| + f.call(t, "POST", "/api/inbox", f.cap, env, 200) |
| + f.a.flushPush(context.Background()) |
| + if calls.Load() != 2 { |
| + t.Fatal("duplicate notified") |
| + } |
| + f.call(t, "POST", "/api/push", f.auth, map[string]any{"secret": cap, "enabled": false}, 200) |
| + var n int |
| + f.a.db.QueryRow("SELECT count(*) FROM push_routes").Scan(&n) |
| + if n != 0 { |
| + t.Fatal("disable did not remove route") |
| + } |
| +} |
| +func TestPushNewGenerationDuringDelivery(t *testing.T) { |
| + f := newFixture(t) |
| + cap := secret() |
| + f.a.db.Exec("INSERT INTO push_routes(secret,pending,generation,queued) VALUES(?,1,1,?)", cap, now()) |
| + relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + f.a.db.Exec("UPDATE push_routes SET generation=2,pending=1,next_attempt=0") |
| + w.WriteHeader(200) |
| + })) |
| + defer relay.Close() |
| + f.a.cfg.HomeOrigin = relay.URL |
| + f.a.flushPush(context.Background()) |
| + var gen, pending int |
| + f.a.db.QueryRow("SELECT generation,pending FROM push_routes").Scan(&gen, &pending) |
| + if gen != 2 || pending != 1 { |
| + t.Fatal("in-flight acknowledgement dropped newer activity") |
| + } |
| + f.a.db.Exec("UPDATE push_routes SET queued=?", now()-86401) |
| + f.a.flushPush(context.Background()) |
| + f.a.db.QueryRow("SELECT pending FROM push_routes").Scan(&pending) |
| + if pending != 0 { |
| + t.Fatal("expired job retained") |
| + } |
| +} |
diff --git a/internal/infinityhome/home.go b/internal/infinityhome/home.go| index 7f09766..2423808 100644 |
| --- a/internal/infinityhome/home.go |
| +++ b/internal/infinityhome/home.go |
| @@ -31,6 +31,7 @@ import ( |
| var schema embed.FS |
| type Config struct { |
| + Push map[string]PushSender |
| AndroidCertSHA256 string |
| Origin, Console, Account, App, Domain, Channel, Token string |
| ClaimKey ed25519.PrivateKey |
| @@ -74,6 +75,7 @@ func New(d *db.DB, cfg Config) (*Home, error) { |
| func (h *Home) Handler() *http.ServeMux { |
| m := infinity.WebHandler() |
| h.passkeyRoutes(m) |
| + h.pushRoutes(m) |
| m.HandleFunc("GET /.well-known/assetlinks.json", h.androidAssociation) |
| m.HandleFunc("GET /.well-known/apple-app-site-association", appleAssociation) |
| for _, method := range []string{"GET", "POST", "PUT"} { |
diff --git a/internal/infinityhome/migrations/0004_push.sql b/internal/infinityhome/migrations/0004_push.sql| new file mode 100644 |
| index 0000000..8a4d8e8 |
| --- /dev/null |
| +++ b/internal/infinityhome/migrations/0004_push.sql |
| @@ -0,0 +1,7 @@ |
| +-- π€ Content-blind, owner-authorized Apple notification routes. |
| +CREATE TABLE ocho_push_routes ( |
| + secret_hash TEXT PRIMARY KEY, host TEXT NOT NULL, token TEXT NOT NULL, |
| + topic TEXT NOT NULL, sandbox INTEGER NOT NULL, expires INTEGER NOT NULL, |
| + last_sent INTEGER NOT NULL DEFAULT 0 |
| +); |
| +CREATE INDEX ocho_push_host ON ocho_push_routes(host); |
diff --git a/internal/infinityhome/push.go b/internal/infinityhome/push.go| new file mode 100644 |
| index 0000000..6ca142e |
| --- /dev/null |
| +++ b/internal/infinityhome/push.go |
| @@ -0,0 +1,150 @@ |
| +// π€ Only the front door holds Apple signing capability; payloads contain no user content. |
| +package infinityhome |
| + |
| +import ( |
| + "amadan.net/rastrillo/rastrillo/csrf" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/json" |
| + "errors" |
| + "lchat/internal/apns" |
| + "net/http" |
| + "strings" |
| + "time" |
| +) |
| + |
| +type PushSender interface { |
| + SendNotice(string, bool, string, string) error |
| +} |
| + |
| +func pushHash(secret string) string { sum := sha256.Sum256([]byte(secret)); return encoded(sum[:]) } |
| +func validPushSecret(secret string) bool { |
| + b, e := base64.RawURLEncoding.DecodeString(secret) |
| + return e == nil && len(b) == 32 |
| +} |
| +func (h *Home) pushRoutes(m *http.ServeMux) { |
| + m.Handle("POST /api/push/register", csrf.Protect(h.cfg.Origin)(http.HandlerFunc(h.registerPush))) |
| + m.HandleFunc("POST /api/push/wake", h.wakePush) |
| +} |
| +func (h *Home) registerPush(w http.ResponseWriter, r *http.Request) { |
| + var p struct { |
| + Origin string `json:"origin"` |
| + Secret string `json:"secret"` |
| + Token string `json:"token"` |
| + Topic string `json:"topic"` |
| + Sandbox bool `json:"sandbox"` |
| + Enabled bool `json:"enabled"` |
| + } |
| + r.Body = http.MaxBytesReader(w, r.Body, 4096) |
| + decoder := json.NewDecoder(r.Body) |
| + decoder.DisallowUnknownFields() |
| + if decoder.Decode(&p) != nil || !validPushSecret(p.Secret) { |
| + failure(w, 400, "Invalid notification registration.") |
| + return |
| + } |
| + host, ok := h.ownedHost(r, p.Origin) |
| + if !ok { |
| + failure(w, 403, "Open your account before changing notifications.") |
| + return |
| + } |
| + if !p.Enabled { |
| + if _, e := h.db.ExecContext(r.Context(), "DELETE FROM ocho_push_routes WHERE secret_hash=? AND host=?", pushHash(p.Secret), host); e != nil { |
| + failure(w, 503, "Could not disable notifications.") |
| + return |
| + } |
| + write(w, 200, map[string]bool{"ok": true}) |
| + return |
| + } |
| + if (p.Topic != "chat.ocho.app" && p.Topic != "chat.ocho.app.mac") || !apns.ValidDeviceToken(p.Token) || len(p.Token) < 32 || len(p.Token)%2 != 0 { |
| + failure(w, 400, "Invalid Apple notification destination.") |
| + return |
| + } |
| + if h.cfg.Push[p.Topic] == nil { |
| + failure(w, 503, "Apple notifications are not configured yet.") |
| + return |
| + } |
| + tx, e := h.db.BeginTx(r.Context(), nil) |
| + if e != nil { |
| + failure(w, 503, "Could not save notifications.") |
| + return |
| + } |
| + defer tx.Rollback() |
| + var count int |
| + if e = tx.QueryRowContext(r.Context(), "SELECT count(*) FROM ocho_push_routes WHERE host=? AND expires>? AND secret_hash<>?", host, time.Now().Unix(), pushHash(p.Secret)).Scan(&count); e != nil || count >= 16 { |
| + failure(w, 409, "This account has too many notification devices.") |
| + return |
| + } |
| + _, e = tx.ExecContext(r.Context(), "DELETE FROM ocho_push_routes WHERE expires<?", time.Now().Unix()) |
| + if e != nil { |
| + failure(w, 503, "Could not save notifications.") |
| + return |
| + } |
| + result, e := tx.ExecContext(r.Context(), `INSERT INTO ocho_push_routes(secret_hash,host,token,topic,sandbox,expires) VALUES(?,?,?,?,?,?) ON CONFLICT(secret_hash) DO UPDATE SET token=excluded.token,topic=excluded.topic,sandbox=excluded.sandbox,expires=excluded.expires WHERE host=excluded.host`, pushHash(p.Secret), host, strings.ToLower(p.Token), p.Topic, p.Sandbox, time.Now().Add(180*24*time.Hour).Unix()) |
| + if e != nil { |
| + failure(w, 503, "Could not save notifications.") |
| + return |
| + } |
| + n, _ := result.RowsAffected() |
| + if n != 1 { |
| + failure(w, 403, "Notification route belongs to another account.") |
| + return |
| + } |
| + if tx.Commit() != nil { |
| + failure(w, 503, "Could not save notifications.") |
| + return |
| + } |
| + write(w, 200, map[string]bool{"ok": true}) |
| +} |
| +func (h *Home) wakePush(w http.ResponseWriter, r *http.Request) { |
| + secret := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") |
| + if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") || !validPushSecret(secret) { |
| + failure(w, 403, "Invalid notification capability.") |
| + return |
| + } |
| + var token, topic string |
| + var sandbox bool |
| + var last int64 |
| + e := h.db.QueryRowContext(r.Context(), "SELECT token,topic,sandbox,last_sent FROM ocho_push_routes WHERE secret_hash=? AND expires>?", pushHash(secret), time.Now().Unix()).Scan(&token, &topic, &sandbox, &last) |
| + if e != nil { |
| + failure(w, 410, "Notification route expired.") |
| + return |
| + } |
| + sender := h.cfg.Push[topic] |
| + if sender == nil { |
| + failure(w, 503, "Notifications temporarily unavailable.") |
| + return |
| + } |
| + now := time.Now().Unix() |
| + res, e := h.db.ExecContext(r.Context(), "UPDATE ocho_push_routes SET last_sent=? WHERE secret_hash=? AND last_sent=? AND last_sent<=?", now, pushHash(secret), last, now-30) |
| + if e != nil { |
| + failure(w, 503, "Notifications temporarily unavailable.") |
| + return |
| + } |
| + n, _ := res.RowsAffected() |
| + if n != 1 { |
| + w.Header().Set("Retry-After", "30") |
| + failure(w, 429, "Notification queued. Retry shortly.") |
| + return |
| + } |
| + e = sender.SendNotice(token, sandbox, "Ocho", "New activity in Ocho. Open the app to catch up.") |
| + if errors.Is(e, apns.ErrGone) { |
| + // A response for an old token must not prune a concurrently refreshed route. |
| + result, err := h.db.ExecContext(r.Context(), "DELETE FROM ocho_push_routes WHERE secret_hash=? AND token=? AND topic=? AND sandbox=?", pushHash(secret), token, topic, sandbox) |
| + if err != nil { |
| + failure(w, 503, "Could not update notification route.") |
| + return |
| + } |
| + removed, _ := result.RowsAffected() |
| + if removed == 0 { |
| + failure(w, 503, "Notification destination changed. Retry.") |
| + return |
| + } |
| + failure(w, 410, "Notification destination unavailable.") |
| + return |
| + } |
| + if e != nil { |
| + failure(w, 503, "Apple notifications temporarily unavailable.") |
| + return |
| + } |
| + write(w, 200, map[string]bool{"ok": true}) |
| +} |
diff --git a/internal/infinityhome/push_test.go b/internal/infinityhome/push_test.go| new file mode 100644 |
| index 0000000..a512238 |
| --- /dev/null |
| +++ b/internal/infinityhome/push_test.go |
| @@ -0,0 +1,93 @@ |
| +// π€ The relay validates ownership and capabilities before fixed-content Apple sends. |
| +package infinityhome |
| + |
| +import ( |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "bytes" |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "encoding/json" |
| + "io" |
| + "lchat/internal/apns" |
| + "log/slog" |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +type noticeStub struct { |
| + calls int |
| + err error |
| +} |
| + |
| +func (s *noticeStub) SendNotice(token string, sandbox bool, title, body string) error { |
| + s.calls++ |
| + if title != "Ocho" || body != "New activity in Ocho. Open the app to catch up." || !sandbox || token != strings.Repeat("ab", 32) { |
| + return io.ErrUnexpectedEOF |
| + } |
| + return s.err |
| +} |
| +func TestPushOwnershipAndRevocation(t *testing.T) { |
| + _, key, _ := ed25519.GenerateKey(rand.Reader) |
| + d, e := db.Open(filepath.Join(t.TempDir(), "push.db"), slog.New(slog.NewTextHandler(io.Discard, nil))) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer d.Close() |
| + host := "p-0123456789abcdef01234567.bdy.oncarlos.com" |
| + sender := ¬iceStub{} |
| + h, e := New(d, Config{Push: map[string]PushSender{"chat.ocho.app": sender}, Origin: "https://ocho.chat", Console: "https://console.example", Account: "bdy", App: "infinity", Domain: "bdy.oncarlos.com", Token: "scoped", ClaimKey: key, Client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { |
| + status := 403 |
| + if r.URL.Host == host && r.Header.Get("Authorization") == "Bearer owner" { |
| + status = 200 |
| + } |
| + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(`{}`)), Header: make(http.Header)}, nil |
| + })}}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + h.db.Exec("INSERT INTO registrations(nonce_hash,host,created,provisioned) VALUES('push',?,1,1)", host) |
| + handler := h.Handler() |
| + post := func(path, auth string, body any, want int) { |
| + t.Helper() |
| + raw, _ := json.Marshal(body) |
| + r := httptest.NewRequest("POST", "https://ocho.chat/api/push/"+path, bytes.NewReader(raw)) |
| + r.Header.Set("Origin", "https://ocho.chat") |
| + r.Header.Set("Authorization", "Bearer "+auth) |
| + w := httptest.NewRecorder() |
| + handler.ServeHTTP(w, r) |
| + if w.Code != want { |
| + t.Fatalf("%s status %d want %d: %s", path, w.Code, want, w.Body.String()) |
| + } |
| + } |
| + secret := encoded(bytes.Repeat([]byte{42}, 32)) |
| + p := map[string]any{"origin": "https://" + host, "secret": secret, "token": strings.Repeat("ab", 32), "topic": "chat.ocho.app", "sandbox": true, "enabled": true} |
| + post("register", "wrong", p, 403) |
| + p["topic"] = "other.app" |
| + post("register", "owner", p, 400) |
| + p["topic"] = "chat.ocho.app" |
| + post("register", "owner", p, 200) |
| + post("register", "owner", p, 200) |
| + post("wake", "owner", nil, 403) |
| + post("wake", encoded(bytes.Repeat([]byte{2}, 32)), nil, 410) |
| + post("wake", secret, nil, 200) |
| + post("wake", secret, nil, 429) |
| + if sender.calls != 1 { |
| + t.Fatal("rate limit bypassed") |
| + } |
| + sender.err = apns.ErrGone |
| + h.db.Exec("UPDATE ocho_push_routes SET last_sent=0") |
| + post("wake", secret, nil, 410) |
| + post("wake", secret, nil, 410) |
| + var n int |
| + h.db.QueryRow("SELECT count(*) FROM ocho_push_routes").Scan(&n) |
| + if n != 0 { |
| + t.Fatal("dead token retained") |
| + } |
| + post("register", "owner", p, 200) |
| + p["enabled"] = false |
| + post("register", "owner", p, 200) |
| + post("wake", secret, nil, 410) |
| +} |