commit 6a782e7c4a85b37e5fe730a868339d40cd4a084e
parent 38c018d3f263f07cd3f6faffdbe063b4368b669f
Author: triesap <tyson@radroots.org>
Date: Sun, 14 Jun 2026 14:48:10 -0700
external-actions: add contracts and fakes
Diffstat:
4 files changed, 392 insertions(+), 0 deletions(-)
diff --git a/Sources/RadrootsKit/RadrootsExternalActions.swift b/Sources/RadrootsKit/RadrootsExternalActions.swift
@@ -0,0 +1,201 @@
+import Foundation
+
+public enum RadrootsExternalActionDestinationKind: String, Sendable, Equatable, Hashable, CaseIterable {
+ case appSettings
+ case web
+ case nostr
+ case appleMaps
+}
+
+public struct RadrootsExternalActionDestination: Sendable, Equatable, Hashable {
+ public let kind: RadrootsExternalActionDestinationKind
+ public let url: URL?
+
+ private init(kind: RadrootsExternalActionDestinationKind, url: URL?) {
+ self.kind = kind
+ self.url = url
+ }
+
+ public static let appSettings = RadrootsExternalActionDestination(kind: .appSettings, url: nil)
+
+ public static func web(_ value: String) throws -> Self {
+ try Self(kind: .web, url: RadrootsExternalActionValidation.normalizedWebURL(value))
+ }
+
+ public static func nostr(_ value: String) throws -> Self {
+ try Self(kind: .nostr, url: RadrootsExternalActionValidation.normalizedNostrURI(value))
+ }
+
+ public static func appleMaps(_ value: String) throws -> Self {
+ try Self(kind: .appleMaps, url: RadrootsExternalActionValidation.normalizedAppleMapsURL(value))
+ }
+
+ public static func appleMaps(
+ coordinate: RadrootsLocationCoordinate,
+ label: String? = nil
+ ) throws -> Self {
+ try Self(
+ kind: .appleMaps,
+ url: RadrootsExternalActionValidation.appleMapsURL(coordinate: coordinate, label: label)
+ )
+ }
+}
+
+public struct RadrootsExternalActionRequest: Sendable, Equatable, Hashable {
+ public let destination: RadrootsExternalActionDestination
+
+ public init(destination: RadrootsExternalActionDestination) {
+ self.destination = destination
+ }
+}
+
+public struct RadrootsExternalActionCapability: Sendable, Equatable, Hashable {
+ public let destination: RadrootsExternalActionDestination
+ public let canOpen: Bool
+
+ public init(destination: RadrootsExternalActionDestination, canOpen: Bool) {
+ self.destination = destination
+ self.canOpen = canOpen
+ }
+}
+
+public enum RadrootsExternalActionError: Error, Equatable, Sendable {
+ case invalidRequest(String)
+ case blockedByPolicy(String)
+ case unavailable(String)
+ case transientFailure(String)
+ case permanentFailure(String)
+}
+
+extension RadrootsExternalActionError: LocalizedError {
+ public var errorDescription: String? {
+ switch self {
+ case .invalidRequest(let message):
+ message
+ case .blockedByPolicy(let message):
+ message
+ case .unavailable(let message):
+ message
+ case .transientFailure(let message):
+ message
+ case .permanentFailure(let message):
+ message
+ }
+ }
+}
+
+public protocol RadrootsExternalActions: Sendable {
+ func canOpen(_ destination: RadrootsExternalActionDestination) async -> RadrootsExternalActionCapability
+ func open(_ request: RadrootsExternalActionRequest) async throws
+}
+
+public enum RadrootsExternalActionValidation {
+ public static func normalizedWebURL(_ value: String) throws -> URL {
+ let trimmed = try trimmedNonEmpty(value, field: "web url")
+ try rejectWhitespaceOrControl(trimmed, field: "web url")
+ guard let components = URLComponents(string: trimmed),
+ components.scheme?.lowercased() == "https",
+ components.host?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false,
+ components.user == nil,
+ components.password == nil,
+ let url = components.url else {
+ throw RadrootsExternalActionError.blockedByPolicy("external web urls must use https with a host")
+ }
+ return url
+ }
+
+ public static func normalizedNostrURI(_ value: String) throws -> URL {
+ let trimmed = try trimmedNonEmpty(value, field: "nostr uri")
+ try rejectWhitespaceOrControl(trimmed, field: "nostr uri")
+ guard trimmed.lowercased().hasPrefix("nostr:") else {
+ throw RadrootsExternalActionError.blockedByPolicy("nostr uri must use the nostr scheme")
+ }
+ let payload = String(trimmed.dropFirst("nostr:".count))
+ guard !payload.isEmpty else {
+ throw RadrootsExternalActionError.invalidRequest("nostr uri payload must not be empty")
+ }
+ guard payload.range(of: "^[A-Za-z0-9]+$", options: .regularExpression) != nil else {
+ throw RadrootsExternalActionError.invalidRequest("nostr uri payload must be bech32-like public text")
+ }
+ let normalizedPayload = payload.lowercased()
+ if normalizedPayload.hasPrefix("nsec") {
+ throw RadrootsExternalActionError.blockedByPolicy("nostr secret payloads cannot be opened externally")
+ }
+ let allowedPrefixes = ["npub1", "nprofile1", "note1", "nevent1", "naddr1", "nrelay1"]
+ guard allowedPrefixes.contains(where: { normalizedPayload.hasPrefix($0) }) else {
+ throw RadrootsExternalActionError.blockedByPolicy("nostr uri payload must be a public Nostr identifier")
+ }
+ guard let url = URL(string: "nostr:\(normalizedPayload)") else {
+ throw RadrootsExternalActionError.invalidRequest("nostr uri is not a valid url")
+ }
+ return url
+ }
+
+ public static func normalizedAppleMapsURL(_ value: String) throws -> URL {
+ let trimmed = try trimmedNonEmpty(value, field: "apple maps url")
+ try rejectWhitespaceOrControl(trimmed, field: "apple maps url")
+ guard let components = URLComponents(string: trimmed),
+ components.scheme?.lowercased() == "https",
+ components.host?.lowercased() == "maps.apple.com",
+ components.user == nil,
+ components.password == nil,
+ let url = components.url else {
+ throw RadrootsExternalActionError.blockedByPolicy("apple maps urls must use https://maps.apple.com")
+ }
+ return url
+ }
+
+ public static func appleMapsURL(
+ coordinate: RadrootsLocationCoordinate,
+ label: String? = nil
+ ) throws -> URL {
+ var components = URLComponents()
+ components.scheme = "https"
+ components.host = "maps.apple.com"
+ components.path = "/"
+ var queryItems = [
+ URLQueryItem(
+ name: "ll",
+ value: "\(coordinate.latitude),\(coordinate.longitude)"
+ )
+ ]
+ if let label {
+ let normalizedLabel = try normalizedOptionalLabel(label)
+ if let normalizedLabel {
+ queryItems.append(URLQueryItem(name: "q", value: normalizedLabel))
+ }
+ }
+ components.queryItems = queryItems
+ guard let url = components.url else {
+ throw RadrootsExternalActionError.permanentFailure("failed to construct apple maps url")
+ }
+ return url
+ }
+
+ static func trimmedNonEmpty(_ value: String, field: String) throws -> String {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ throw RadrootsExternalActionError.invalidRequest("\(field) must not be empty")
+ }
+ return trimmed
+ }
+
+ static func rejectWhitespaceOrControl(_ value: String, field: String) throws {
+ let forbidden = CharacterSet.whitespacesAndNewlines.union(.controlCharacters)
+ guard value.unicodeScalars.allSatisfy({ !forbidden.contains($0) }) else {
+ throw RadrootsExternalActionError.invalidRequest("\(field) cannot contain whitespace or control characters")
+ }
+ }
+
+ static func normalizedOptionalLabel(_ value: String) throws -> String? {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ return nil
+ }
+ let forbidden = CharacterSet.controlCharacters
+ guard trimmed.unicodeScalars.allSatisfy({ !forbidden.contains($0) }) else {
+ throw RadrootsExternalActionError.invalidRequest("apple maps label cannot contain control characters")
+ }
+ return trimmed
+ }
+}
diff --git a/Sources/RadrootsKitTesting/RadrootsExternalActionsTesting.swift b/Sources/RadrootsKitTesting/RadrootsExternalActionsTesting.swift
@@ -0,0 +1,78 @@
+import Foundation
+import RadrootsKit
+
+public actor RadrootsFakeExternalActions: RadrootsExternalActions {
+ private var capabilityOverrides: [RadrootsExternalActionDestination: Bool]
+ private var defaultCanOpen: Bool
+ private var openOutcome: Result<Void, RadrootsExternalActionError>
+ private var capabilityRequestCountValue: Int
+ private var openRequestCountValue: Int
+ private var lastCapabilityDestinationValue: RadrootsExternalActionDestination?
+ private var openedDestinationsValue: [RadrootsExternalActionDestination]
+
+ public init(
+ capabilityOverrides: [RadrootsExternalActionDestination: Bool] = [:],
+ defaultCanOpen: Bool = true,
+ openOutcome: Result<Void, RadrootsExternalActionError> = .success(())
+ ) {
+ self.capabilityOverrides = capabilityOverrides
+ self.defaultCanOpen = defaultCanOpen
+ self.openOutcome = openOutcome
+ self.capabilityRequestCountValue = 0
+ self.openRequestCountValue = 0
+ self.lastCapabilityDestinationValue = nil
+ self.openedDestinationsValue = []
+ }
+
+ public func setCapability(_ canOpen: Bool, for destination: RadrootsExternalActionDestination) {
+ capabilityOverrides[destination] = canOpen
+ }
+
+ public func setDefaultCapability(_ canOpen: Bool) {
+ defaultCanOpen = canOpen
+ }
+
+ public func setOpenOutcome(_ outcome: Result<Void, RadrootsExternalActionError>) {
+ openOutcome = outcome
+ }
+
+ public func canOpen(_ destination: RadrootsExternalActionDestination) async -> RadrootsExternalActionCapability {
+ capabilityRequestCountValue += 1
+ lastCapabilityDestinationValue = destination
+ return RadrootsExternalActionCapability(
+ destination: destination,
+ canOpen: capabilityOverrides[destination] ?? defaultCanOpen
+ )
+ }
+
+ public func open(_ request: RadrootsExternalActionRequest) async throws {
+ openRequestCountValue += 1
+ openedDestinationsValue.append(request.destination)
+ switch openOutcome {
+ case .success:
+ return
+ case .failure(let error):
+ throw error
+ }
+ }
+
+ public var capabilityRequestCount: Int {
+ capabilityRequestCountValue
+ }
+
+ public var openRequestCount: Int {
+ openRequestCountValue
+ }
+
+ public var lastCapabilityDestination: RadrootsExternalActionDestination? {
+ lastCapabilityDestinationValue
+ }
+
+ public var openedDestinations: [RadrootsExternalActionDestination] {
+ openedDestinationsValue
+ }
+
+ public var lastOpenedDestination: RadrootsExternalActionDestination? {
+ openedDestinationsValue.last
+ }
+}
diff --git a/Tests/RadrootsKitTestingTests/RadrootsExternalActionsTestingTests.swift b/Tests/RadrootsKitTestingTests/RadrootsExternalActionsTestingTests.swift
@@ -0,0 +1,37 @@
+import Foundation
+import Testing
+import RadrootsKit
+import RadrootsKitTesting
+
+@Test func fakeExternalActionsRecordsCapabilityAndOpenRequests() async throws {
+ let web = try RadrootsExternalActionDestination.web("https://radroots.org")
+ let nostr = try RadrootsExternalActionDestination.nostr("nostr:npub1qqqqqq")
+ let actions = RadrootsFakeExternalActions(
+ capabilityOverrides: [nostr: false],
+ defaultCanOpen: true
+ )
+
+ #expect(await actions.canOpen(web).canOpen)
+ #expect(!(await actions.canOpen(nostr).canOpen))
+ try await actions.open(RadrootsExternalActionRequest(destination: web))
+
+ #expect(await actions.capabilityRequestCount == 2)
+ #expect(await actions.openRequestCount == 1)
+ #expect(await actions.lastCapabilityDestination == nostr)
+ #expect(await actions.lastOpenedDestination == web)
+ #expect(await actions.openedDestinations == [web])
+}
+
+@Test func fakeExternalActionsReturnsConfiguredFailures() async throws {
+ let destination = RadrootsExternalActionDestination.appSettings
+ let actions = RadrootsFakeExternalActions(
+ openOutcome: .failure(.unavailable("external actions unavailable"))
+ )
+
+ await #expect(throws: RadrootsExternalActionError.unavailable("external actions unavailable")) {
+ try await actions.open(RadrootsExternalActionRequest(destination: destination))
+ }
+
+ #expect(await actions.openRequestCount == 1)
+ #expect(await actions.lastOpenedDestination == destination)
+}
diff --git a/Tests/RadrootsKitTests/RadrootsExternalActionsTests.swift b/Tests/RadrootsKitTests/RadrootsExternalActionsTests.swift
@@ -0,0 +1,76 @@
+import Foundation
+import Testing
+@testable import RadrootsKit
+
+@Test func webDestinationAcceptsOnlyHttpsUrlsWithHosts() throws {
+ let destination = try RadrootsExternalActionDestination.web(" https://radroots.org/field ")
+
+ #expect(destination.kind == .web)
+ #expect(destination.url?.absoluteString == "https://radroots.org/field")
+
+ #expect(throws: RadrootsExternalActionError.blockedByPolicy("external web urls must use https with a host")) {
+ _ = try RadrootsExternalActionDestination.web("http://radroots.org")
+ }
+ #expect(throws: RadrootsExternalActionError.blockedByPolicy("external web urls must use https with a host")) {
+ _ = try RadrootsExternalActionDestination.web("wss://radroots.org")
+ }
+ #expect(throws: RadrootsExternalActionError.blockedByPolicy("external web urls must use https with a host")) {
+ _ = try RadrootsExternalActionDestination.web("https:///missing-host")
+ }
+ #expect(throws: RadrootsExternalActionError.invalidRequest("web url cannot contain whitespace or control characters")) {
+ _ = try RadrootsExternalActionDestination.web("https://radroots.org/a b")
+ }
+}
+
+@Test func nostrDestinationAllowsPublicIdentifiersAndRejectsSecrets() throws {
+ let destination = try RadrootsExternalActionDestination.nostr("nostr:NPUB1qqqqqq")
+
+ #expect(destination.kind == .nostr)
+ #expect(destination.url?.absoluteString == "nostr:npub1qqqqqq")
+
+ _ = try RadrootsExternalActionDestination.nostr("nostr:nprofile1qqqqqq")
+ _ = try RadrootsExternalActionDestination.nostr("nostr:note1qqqqqq")
+ _ = try RadrootsExternalActionDestination.nostr("nostr:nevent1qqqqqq")
+ _ = try RadrootsExternalActionDestination.nostr("nostr:naddr1qqqqqq")
+ _ = try RadrootsExternalActionDestination.nostr("nostr:nrelay1qqqqqq")
+
+ #expect(throws: RadrootsExternalActionError.blockedByPolicy("nostr secret payloads cannot be opened externally")) {
+ _ = try RadrootsExternalActionDestination.nostr("nostr:nsec1qqqqqq")
+ }
+ #expect(throws: RadrootsExternalActionError.blockedByPolicy("nostr uri payload must be a public Nostr identifier")) {
+ _ = try RadrootsExternalActionDestination.nostr("nostr:relay1qqqqqq")
+ }
+ #expect(throws: RadrootsExternalActionError.invalidRequest("nostr uri cannot contain whitespace or control characters")) {
+ _ = try RadrootsExternalActionDestination.nostr("nostr:npub1qq q")
+ }
+}
+
+@Test func appleMapsDestinationBuildsSafeMapsUrls() throws {
+ let coordinate = try RadrootsLocationCoordinate(latitude: 49.2827, longitude: -123.1207)
+ let destination = try RadrootsExternalActionDestination.appleMaps(
+ coordinate: coordinate,
+ label: "Field Site"
+ )
+
+ #expect(destination.kind == .appleMaps)
+ #expect(destination.url?.scheme == "https")
+ #expect(destination.url?.host == "maps.apple.com")
+ #expect(destination.url?.absoluteString.contains("ll=49.2827,-123.1207") == true)
+ #expect(destination.url?.absoluteString.contains("q=Field%20Site") == true)
+
+ _ = try RadrootsExternalActionDestination.appleMaps("https://maps.apple.com/?q=Field")
+
+ #expect(throws: RadrootsExternalActionError.blockedByPolicy("apple maps urls must use https://maps.apple.com")) {
+ _ = try RadrootsExternalActionDestination.appleMaps("https://example.com/maps")
+ }
+}
+
+@Test func externalActionRequestPreservesDestination() throws {
+ let destination = try RadrootsExternalActionDestination.web("https://radroots.org")
+ let request = RadrootsExternalActionRequest(destination: destination)
+ let capability = RadrootsExternalActionCapability(destination: destination, canOpen: true)
+
+ #expect(request.destination == destination)
+ #expect(capability.destination == destination)
+ #expect(capability.canOpen)
+}