Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Jared.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
B33278811CB2CB1300BBDD3A /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B332787F1CB2CB1300BBDD3A /* Main.storyboard */; };
B3327DC42206607B009DD882 /* ContactHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3327DC32206607B009DD882 /* ContactHelper.swift */; };
B3327DC622066427009DD882 /* WebHookManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3327DC522066427009DD882 /* WebHookManager.swift */; };
EC66DC00000000000000A002 /* WebhookDeliveryClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC66DC00000000000000A001 /* WebhookDeliveryClient.swift */; };
EC66DC00000000000000A005 /* WebhookRouteFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC66DC00000000000000A004 /* WebhookRouteFactory.swift */; };
EC000001000000000000AA40 /* ScheduleModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA43 /* ScheduleModule.swift */; };
EC000001000000000000AA41 /* ScheduleModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA43 /* ScheduleModule.swift */; };
EC000001000000000000AA44 /* ScheduleModuleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA45 /* ScheduleModuleTests.swift */; };
Expand Down Expand Up @@ -113,6 +115,8 @@
B3BF17CA244FDA3F00CC44C5 /* MessageDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BF17C5244EC47700CC44C5 /* MessageDelegate.swift */; };
B3C59284224DD17900116ECB /* JaredWebServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C59283224DD17900116ECB /* JaredWebServer.swift */; };
B3EF1DA02206849000953DE7 /* WebHookManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3327DC522066427009DD882 /* WebHookManager.swift */; };
EC66DC00000000000000A003 /* WebhookDeliveryClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC66DC00000000000000A001 /* WebhookDeliveryClient.swift */; };
EC66DC00000000000000A006 /* WebhookRouteFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC66DC00000000000000A004 /* WebhookRouteFactory.swift */; };
B3EF1DA4220687DD00953DE7 /* URLProtocolMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EF1DA3220687DD00953DE7 /* URLProtocolMock.swift */; };
B3EF1DA62206DFEA00953DE7 /* Route.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EF1DA52206DFEA00953DE7 /* Route.swift */; };
B3EF1DAC2206E17900953DE7 /* Entities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EF1DAB2206E17900953DE7 /* Entities.swift */; };
Expand Down Expand Up @@ -199,6 +203,8 @@
B33278821CB2CB1300BBDD3A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
B3327DC32206607B009DD882 /* ContactHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactHelper.swift; sourceTree = "<group>"; };
B3327DC522066427009DD882 /* WebHookManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebHookManager.swift; sourceTree = "<group>"; };
EC66DC00000000000000A001 /* WebhookDeliveryClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebhookDeliveryClient.swift; sourceTree = "<group>"; };
EC66DC00000000000000A004 /* WebhookRouteFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebhookRouteFactory.swift; sourceTree = "<group>"; };
EC000001000000000000AA43 /* ScheduleModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleModule.swift; sourceTree = "<group>"; };
EC000001000000000000AA45 /* ScheduleModuleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleModuleTests.swift; sourceTree = "<group>"; };
EC000001000000000000AA53 /* MessageSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSource.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -459,6 +465,8 @@
B3BF17C1244EBE9A00CC44C5 /* Router.swift */,
B39A65DF1CC1FB1B003E26B0 /* PluginManager.swift */,
B3327DC522066427009DD882 /* WebHookManager.swift */,
EC66DC00000000000000A001 /* WebhookDeliveryClient.swift */,
EC66DC00000000000000A004 /* WebhookRouteFactory.swift */,
EC000001000000000000AA43 /* ScheduleModule.swift */,
EC000001000000000000AA53 /* MessageSource.swift */,
EC000001000000000000AA57 /* InMemoryMessageSource.swift */,
Expand Down Expand Up @@ -777,6 +785,8 @@
B3327DC42206607B009DD882 /* ContactHelper.swift in Sources */,
B332787C1CB2CB1300BBDD3A /* ViewController.swift in Sources */,
B3327DC622066427009DD882 /* WebHookManager.swift in Sources */,
EC66DC00000000000000A002 /* WebhookDeliveryClient.swift in Sources */,
EC66DC00000000000000A005 /* WebhookRouteFactory.swift in Sources */,
EC000001000000000000AA40 /* ScheduleModule.swift in Sources */,
EC000001000000000000AA50 /* MessageSource.swift in Sources */,
EC000001000000000000AA54 /* InMemoryMessageSource.swift in Sources */,
Expand Down Expand Up @@ -856,6 +866,8 @@
B32DE51524D51F9C00AB3A71 /* RouterTests.swift in Sources */,
B3EF1DA4220687DD00953DE7 /* URLProtocolMock.swift in Sources */,
B3EF1DA02206849000953DE7 /* WebHookManager.swift in Sources */,
EC66DC00000000000000A003 /* WebhookDeliveryClient.swift in Sources */,
EC66DC00000000000000A006 /* WebhookRouteFactory.swift in Sources */,
B347A4AF24DD1CD000E93657 /* GlobalTest.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
179 changes: 13 additions & 166 deletions Jared/WebHookManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,48 +9,34 @@
import Foundation
import JaredFramework
import os
import CryptoKit

private let logger = Logger(subsystem: "com.zekesnider.jared", category: "webhooks")

class WebHookManager: MessageDelegate, RoutingModule {
var urlSession: URLSession
var webhooks = [RichWebhook]()
var routes = [Route]()
var sender: MessageSender
var keychain: KeychainAccessor
var description = "Routes provided by webhooks"
/// Base nanosecond unit for retry backoff (2^attempt × base). Override in tests for speed.
var retryDelayBase: UInt64 = 1_000_000_000

/// On-disk delivery log (newest first, capped). Survives app restarts so the
/// management UI can show history across launches.
let deliveryStore: WebhookDeliveryStore

/// Snapshot of persisted deliveries, newest first. Re-read from disk on access
/// so callers see records written by deliveries that happened in this session.
var deliveryLog: [WebhookDeliveryRecord] { deliveryStore.load() }

private func record(_ webhookURL: String, deliveryId: String, attempt: Int,
statusCode: Int? = nil, error: String? = nil) {
let rec = WebhookDeliveryRecord(deliveryId: deliveryId, webhookURL: webhookURL,
date: Date(), statusCode: statusCode,
errorDescription: error, attempt: attempt)
deliveryStore.append(rec)
DispatchQueue.main.async {
NotificationCenter.default.post(name: .webhookDelivered, object: self,
userInfo: ["url": webhookURL])
}
var client: WebhookDeliveryClient
private var routeFactory: WebhookRouteFactory

var retryDelayBase: UInt64 {
get { client.retryDelayBase }
set { client.retryDelayBase = newValue }
}

public init(webhooks: [RichWebhook]?, session: URLSessionConfiguration = .ephemeral,
sender: MessageSender, keychain: KeychainAccessor = KeychainStore(),
deliveryStore: WebhookDeliveryStore = WebHookManager.defaultDeliveryStore()) {
session.timeoutIntervalForResource = 10.0
self.sender = sender
self.keychain = keychain
self.deliveryStore = deliveryStore
urlSession = URLSession(configuration: session)
self.client = WebhookDeliveryClient(session: session, keychain: keychain,
sender: sender, deliveryStore: deliveryStore)
self.routeFactory = WebhookRouteFactory(client: self.client)
updateHooks(to: webhooks)
}

Expand Down Expand Up @@ -78,170 +64,31 @@ class WebHookManager: MessageDelegate, RoutingModule {
inFlight -= 1
}
let w = webhook
group.addTask { await self.deliverWebhook(w, message: message) }
group.addTask { await self.client.deliver(w, message: message) }
inFlight += 1
}
}
}
}

// MARK: - Delivery

func deliverWebhook(_ webhook: RichWebhook, message: Message) async {
guard let parsedUrl = URL(string: webhook.url) else {
logger.error("Webhook \(webhook.url, privacy: .public): invalid URL — skipping delivery")
return
}

guard let webhookBody = WebHookManager.createWebhookBody(message) else {
logger.error("Webhook \(webhook.url, privacy: .public): failed to encode message body")
return
}

let deliveryId = UUID().uuidString
let maxRetries = webhook.effectiveMaxRetries

for attempt in 0...maxRetries {
var request = WebHookManager.makeDeliveryRequest(
url: parsedUrl,
webhook: webhook,
body: webhookBody,
deliveryId: deliveryId,
keychain: keychain
)

do {
let (data, response) = try await urlSession.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else { return }

if (400...499).contains(httpResponse.statusCode) {
logger.warning("Webhook \(webhook.url, privacy: .public): 4xx \(httpResponse.statusCode, privacy: .public) — not retrying")
record(webhook.url, deliveryId: deliveryId, attempt: attempt + 1, statusCode: httpResponse.statusCode)
return
}

if (200...299).contains(httpResponse.statusCode) {
logger.notice("Webhook \(webhook.url, privacy: .public): delivered (attempt \(attempt + 1, privacy: .public), status \(httpResponse.statusCode, privacy: .public))")
record(webhook.url, deliveryId: deliveryId, attempt: attempt + 1, statusCode: httpResponse.statusCode)
if webhook.mode == .command {
guard let decoded = try? JSONDecoder().decode(WebhookResponse.self, from: data) else {
logger.warning("Webhook \(webhook.url, privacy: .public): unable to parse command response")
return
}
if decoded.success, let replyText = decoded.body?.message {
sender.send(replyText, to: message.RespondTo())
} else if let decodedError = decoded.error {
logger.warning("Webhook \(webhook.url, privacy: .public): error response: \(decodedError, privacy: .public)")
}
}
return
}

logger.warning("Webhook \(webhook.url, privacy: .public): status \(httpResponse.statusCode, privacy: .public) on attempt \(attempt + 1, privacy: .public)")
} catch {
logger.error("Webhook \(webhook.url, privacy: .public): request failed on attempt \(attempt + 1, privacy: .public): \(error.localizedDescription, privacy: .public)")
record(webhook.url, deliveryId: deliveryId, attempt: attempt + 1, error: error.localizedDescription)
}

if attempt < maxRetries {
let backoffNs = UInt64(pow(2.0, Double(attempt))) * retryDelayBase
try? await Task.sleep(nanoseconds: backoffNs)
}
}

logger.error("Webhook \(webhook.url, privacy: .public): all \(maxRetries + 1, privacy: .public) attempt(s) exhausted")
}

// MARK: - Configuration

public func updateHooks(to hooks: [RichWebhook]?) {
self.webhooks = (hooks ?? []).filter { $0.isEnabled }.map { hook in
var newHook = hook
// Persist inline auth.secret to Keychain on first load
if let inlineSecret = newHook.auth?.secret {
keychain.save(secret: inlineSecret, for: newHook.url)
client.keychain.save(secret: inlineSecret, for: newHook.url)
}
// Signing is keyed by Keychain; keep auth enabled when a secret exists
if newHook.auth == nil, keychain.secret(for: newHook.url) != nil {
if newHook.auth == nil, client.keychain.secret(for: newHook.url) != nil {
newHook.auth = WebhookAuth(secret: nil)
}
newHook.routes = (newHook.routes ?? []).map { route in
var newRoute = route
newRoute.call = { [weak self] msg in
Task { await self?.deliverWebhook(newHook, message: msg) }
}
return newRoute
}
newHook.routes = routeFactory.routes(from: newHook)
return newHook
}
self.routes = self.webhooks.flatMap { $0.routes ?? [] }
UserDefaults.standard.set(self.webhooks.count, forKey: JaredConstants.webhookCount)
logger.notice("Webhooks updated: \(self.webhooks.map { $0.url }.joined(separator: ", "), privacy: .public)")
}

static func createWebhookBody(_ message: Message) -> Data? {
try? JSONEncoder().encode(message)
}

/// Test payload uses the same Message JSON shape as production, plus `_jared_test: true`.
static func createTestWebhookBody() -> Data? {
let message = Message(
body: TextBody("/test"),
date: Date(),
sender: Person(givenName: "Test", handle: "test@example.com", isMe: false),
recipient: Person(givenName: "Test", handle: "test@example.com", isMe: true)
)
guard let base = createWebhookBody(message),
var json = String(data: base, encoding: .utf8),
json.hasSuffix("}") else {
return nil
}
// Append without JSONSerialization round-trip so key order matches JSONEncoder output.
json.removeLast()
json += ",\"_jared_test\":true}"
return json.data(using: .utf8)
}

static func richWebhook(from dictionary: [String: Any]) -> RichWebhook? {
guard let data = try? JSONSerialization.data(withJSONObject: dictionary) else { return nil }
return try? JSONDecoder().decode(RichWebhook.self, from: data)
}

/// Resolves signing from config `auth` or an existing Keychain secret.
static func richWebhookForDelivery(from dictionary: [String: Any], keychain: KeychainAccessor) -> RichWebhook? {
guard var webhook = richWebhook(from: dictionary) else { return nil }
if webhook.auth == nil, keychain.secret(for: webhook.url) != nil {
webhook.auth = WebhookAuth(secret: nil)
}
return webhook
}

static func makeDeliveryRequest(
url: URL,
webhook: RichWebhook,
body: Data,
deliveryId: String,
keychain: KeychainAccessor
) -> URLRequest {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = body
request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.addValue(deliveryId, forHTTPHeaderField: "X-Jared-Delivery-Id")
request.addValue(webhook.url, forHTTPHeaderField: "X-Jared-Webhook-Id")
request.timeoutInterval = webhook.effectiveTimeout

if webhook.auth != nil {
if let secret = keychain.secret(for: webhook.url) {
let key = SymmetricKey(data: Data(secret.utf8))
let sig = HMAC<SHA256>.authenticationCode(for: body, using: key)
let hexSig = sig.map { String(format: "%02x", $0) }.joined()
request.addValue("sha256=\(hexSig)", forHTTPHeaderField: "X-Jared-Signature")
} else {
logger.warning("Webhook \(webhook.url, privacy: .public): auth configured but no Keychain secret found — delivering unsigned")
}
}

return request
}
}
Loading
Loading