mount public data from the atmosphere to a virtual filesystem (macos only) pdfs.at
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(atproto/oauth): add structured Scope type with collection-aware containment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+489
+257
Packages/ATProto/Sources/ATProto/OAuth/Scope.swift
··· 1 + import Foundation 2 + 3 + /// atproto OAuth scope, per https://atproto.com/specs/permission. 4 + /// 5 + /// pdfs only uses three resource types. `.rpc`, `.identity`, `.account` 6 + /// exist in the spec but aren't needed for the filesystem-over-PDS use 7 + /// case, so they're not modeled here. Parsing unknown resource types 8 + /// returns nil. 9 + public enum Scope: Hashable, Sendable, Codable { 10 + case atproto 11 + case repo(collections: Set<Collection>, actions: Set<Action>?) 12 + case blob(accepts: Set<String>) 13 + 14 + public enum Collection: Hashable, Sendable { 15 + case wildcard 16 + case nsid(String) 17 + 18 + var rawValue: String { 19 + switch self { 20 + case .wildcard: return "*" 21 + case .nsid(let s): return s 22 + } 23 + } 24 + 25 + static func parse(_ s: String) -> Collection? { 26 + if s == "*" { return .wildcard } 27 + guard !s.isEmpty else { return nil } 28 + return .nsid(s) 29 + } 30 + } 31 + 32 + public enum Action: String, Hashable, Sendable, CaseIterable { 33 + case create, update, delete 34 + } 35 + 36 + /// Convenience factory for a collection-scoped repo permission with 37 + /// default actions (create+update+delete). 38 + public static func repo(collection: String) -> Scope { 39 + .repo(collections: [.nsid(collection)], actions: nil) 40 + } 41 + 42 + // MARK: - Scope-string parsing 43 + 44 + /// Parses a single scope string per the atproto permission grammar. 45 + /// Returns nil for unrecognized resource types or malformed input. 46 + public init?(_ rawValue: String) { 47 + guard !rawValue.isEmpty else { return nil } 48 + // Split resource / positional / query. 49 + let (resource, positional, query) = Self.split(rawValue) 50 + guard let resource else { return nil } 51 + 52 + switch resource { 53 + case "atproto": 54 + guard positional == nil, query.isEmpty else { return nil } 55 + self = .atproto 56 + 57 + case "repo": 58 + // Collection: positional OR `?collection=...` array. Not both. 59 + var collections: Set<Collection> = [] 60 + if let pos = positional { 61 + if query["collection"] != nil { return nil } // both forms forbidden 62 + guard let c = Collection.parse(pos) else { return nil } 63 + collections = [c] 64 + } 65 + if let queryCollections = query["collection"] { 66 + for qc in queryCollections { 67 + guard let c = Collection.parse(qc) else { return nil } 68 + collections.insert(c) 69 + } 70 + } 71 + guard !collections.isEmpty else { return nil } 72 + 73 + // Actions: optional array. nil = all three default. 74 + let actions: Set<Action>? 75 + if let queryActions = query["action"] { 76 + var parsed: Set<Action> = [] 77 + for qa in queryActions { 78 + guard let a = Action(rawValue: qa) else { return nil } 79 + parsed.insert(a) 80 + } 81 + actions = parsed.isEmpty ? nil : parsed 82 + } else { 83 + actions = nil 84 + } 85 + self = .repo(collections: collections, actions: actions) 86 + 87 + case "blob": 88 + // MIME: positional single OR `?accept=...` array. Not both. 89 + var accepts: Set<String> = [] 90 + if let pos = positional { 91 + if query["accept"] != nil { return nil } 92 + accepts = [pos] 93 + } 94 + if let qa = query["accept"] { 95 + for m in qa { accepts.insert(m) } 96 + } 97 + guard !accepts.isEmpty else { return nil } 98 + self = .blob(accepts: accepts) 99 + 100 + default: 101 + return nil 102 + } 103 + } 104 + 105 + // MARK: - Scope-string formatting 106 + 107 + public var rawValue: String { 108 + switch self { 109 + case .atproto: 110 + return "atproto" 111 + case .repo(let collections, let actions): 112 + let sortedCollections = collections.map(\.rawValue).sorted() 113 + var s: String 114 + if sortedCollections.count == 1 { 115 + s = "repo:\(sortedCollections[0])" 116 + } else { 117 + let joined = sortedCollections.map { "collection=\($0)" }.joined(separator: "&") 118 + s = "repo?\(joined)" 119 + } 120 + if let actions, !actions.isEmpty { 121 + let sortedActions = actions.map(\.rawValue).sorted() 122 + let joined = sortedActions.map { "action=\($0)" }.joined(separator: "&") 123 + s += (s.contains("?") ? "&" : "?") + joined 124 + } 125 + return s 126 + case .blob(let accepts): 127 + let sorted = accepts.sorted() 128 + if sorted.count == 1 { 129 + return "blob:\(sorted[0])" 130 + } 131 + let joined = sorted.map { "accept=\($0)" }.joined(separator: "&") 132 + return "blob?\(joined)" 133 + } 134 + } 135 + 136 + // MARK: - Set helpers 137 + 138 + /// Space-separated scope string form (alphabetically sorted). 139 + public static func formatted(_ scopes: Set<Scope>) -> String { 140 + scopes.map(\.rawValue).sorted().joined(separator: " ") 141 + } 142 + 143 + /// Parses a space-separated scope string. Unknown tokens are ignored. 144 + public static func parse(_ s: String) -> Set<Scope> { 145 + Set(s.split(whereSeparator: \.isWhitespace).compactMap { Scope(String($0)) }) 146 + } 147 + 148 + // MARK: - Containment 149 + 150 + /// Does `granted` semantically cover every requested scope? 151 + /// Wildcard collections satisfy specific collections; granted action 152 + /// supersets (including nil = all three) satisfy narrower requests; 153 + /// blob `*/*` satisfies any specific MIME pattern; blob `image/*` 154 + /// satisfies `image/png` but not `video/mp4`. 155 + public static func satisfies(granted: Set<Scope>, requested: Set<Scope>) -> Bool { 156 + requested.allSatisfy { r in 157 + granted.contains { g in g.satisfies(r) } 158 + } 159 + } 160 + 161 + /// True iff `self` is at least as broad as `other` in the same resource 162 + /// family. Across resource families, always false. 163 + func satisfies(_ other: Scope) -> Bool { 164 + switch (self, other) { 165 + case (.atproto, .atproto): 166 + return true 167 + case let (.repo(gCols, gActions), .repo(rCols, rActions)): 168 + // Every requested collection must be covered by granted collections. 169 + let collectionsCover = rCols.allSatisfy { rc in 170 + gCols.contains(.wildcard) || gCols.contains(rc) 171 + } 172 + // Granted actions nil = all three, which covers anything. Else 173 + // granted must be a superset of requested (nil requested = all three). 174 + let grantedActions = gActions ?? Set(Action.allCases) 175 + let requestedActions = rActions ?? Set(Action.allCases) 176 + let actionsCover = requestedActions.isSubset(of: grantedActions) 177 + return collectionsCover && actionsCover 178 + case let (.blob(gAccepts), .blob(rAccepts)): 179 + return rAccepts.allSatisfy { r in 180 + gAccepts.contains { g in Self.mimeSatisfies(granted: g, requested: r) } 181 + } 182 + default: 183 + return false 184 + } 185 + } 186 + 187 + /// `*/*` satisfies anything. `image/*` satisfies `image/png`. Exact-exact. 188 + /// Prefix-only patterns like `*/html` are not valid per spec and not handled. 189 + static func mimeSatisfies(granted: String, requested: String) -> Bool { 190 + if granted == "*/*" { return true } 191 + if granted == requested { return true } 192 + // Wildcard on subtype: "image/*" 193 + let gParts = granted.split(separator: "/", maxSplits: 1).map(String.init) 194 + let rParts = requested.split(separator: "/", maxSplits: 1).map(String.init) 195 + guard gParts.count == 2, rParts.count == 2 else { return false } 196 + if gParts[1] == "*" && gParts[0] == rParts[0] { return true } 197 + return false 198 + } 199 + 200 + // MARK: - Codable (round-trips via the scope string form) 201 + 202 + public init(from decoder: Decoder) throws { 203 + let container = try decoder.singleValueContainer() 204 + let raw = try container.decode(String.self) 205 + guard let scope = Scope(raw) else { 206 + throw DecodingError.dataCorruptedError( 207 + in: container, debugDescription: "invalid scope string: \(raw)" 208 + ) 209 + } 210 + self = scope 211 + } 212 + 213 + public func encode(to encoder: Encoder) throws { 214 + var container = encoder.singleValueContainer() 215 + try container.encode(rawValue) 216 + } 217 + 218 + // MARK: - Raw string splitting 219 + 220 + /// Returns (resource, positional?, query[name: [values]]). 221 + /// Invalid inputs get resource=nil and the caller returns nil upstream. 222 + static func split(_ s: String) -> (resource: String?, positional: String?, query: [String: [String]]) { 223 + // Split "?" first. 224 + let qIdx = s.firstIndex(of: "?") 225 + let head = qIdx.map { String(s[..<$0]) } ?? s 226 + let queryString = qIdx.map { String(s[s.index(after: $0)...]) } ?? "" 227 + 228 + // Head: "resource" or "resource:positional" 229 + let headParts = head.split(separator: ":", maxSplits: 1).map(String.init) 230 + guard !headParts[0].isEmpty else { return (nil, nil, [:]) } 231 + let resource = headParts[0] 232 + let positional: String? = { 233 + if headParts.count == 2 { 234 + // Empty positional = malformed ("repo:" or "repo:?"). 235 + return headParts[1].isEmpty ? nil : headParts[1] 236 + } 237 + return nil 238 + }() 239 + // "repo:" with nothing after is malformed → treat resource as nil. 240 + if head.contains(":") && positional == nil { 241 + return (nil, nil, [:]) 242 + } 243 + 244 + // Query: &-separated name=value, percent-decoded. 245 + var query: [String: [String]] = [:] 246 + if !queryString.isEmpty { 247 + for pair in queryString.split(separator: "&") { 248 + let kv = pair.split(separator: "=", maxSplits: 1).map(String.init) 249 + guard kv.count == 2 else { continue } 250 + let name = kv[0].removingPercentEncoding ?? kv[0] 251 + let value = kv[1].removingPercentEncoding ?? kv[1] 252 + query[name, default: []].append(value) 253 + } 254 + } 255 + return (resource, positional, query) 256 + } 257 + }
+232
Packages/ATProto/Tests/ATProtoTests/OAuth/ScopeTests.swift
··· 1 + import Foundation 2 + import Testing 3 + @testable import ATProto 4 + 5 + @Suite("Scope") 6 + struct ScopeTests { 7 + // MARK: - parse round-trips 8 + 9 + @Test("parses atproto") 10 + func parseAtproto() { 11 + #expect(Scope("atproto") == .atproto) 12 + } 13 + 14 + @Test("parses repo with positional collection, default actions = all three") 15 + func parseRepoPositional() { 16 + let scope = Scope("repo:app.bsky.feed.post") 17 + guard case let .repo(collections, actions) = scope else { 18 + Issue.record("expected .repo") 19 + return 20 + } 21 + #expect(collections == [.nsid("app.bsky.feed.post")]) 22 + #expect(actions == nil) // nil = all three default 23 + } 24 + 25 + @Test("parses repo with wildcard") 26 + func parseRepoWildcard() { 27 + let scope = Scope("repo:*") 28 + guard case let .repo(collections, _) = scope else { 29 + Issue.record("expected .repo") 30 + return 31 + } 32 + #expect(collections == [.wildcard]) 33 + } 34 + 35 + @Test("parses repo with actions query") 36 + func parseRepoActions() { 37 + let scope = Scope("repo:app.bsky.feed.post?action=create&action=update") 38 + guard case let .repo(_, actions) = scope else { 39 + Issue.record("expected .repo") 40 + return 41 + } 42 + #expect(actions == Set([Scope.Action.create, Scope.Action.update])) 43 + } 44 + 45 + @Test("parses repo with multiple collections as query params") 46 + func parseRepoMultipleCollections() { 47 + let scope = Scope("repo?collection=app.bsky.feed.post&collection=app.bsky.feed.like") 48 + guard case let .repo(collections, _) = scope else { 49 + Issue.record("expected .repo") 50 + return 51 + } 52 + #expect(collections == Set([ 53 + .nsid("app.bsky.feed.post"), 54 + .nsid("app.bsky.feed.like"), 55 + ])) 56 + } 57 + 58 + @Test("parses blob with positional MIME") 59 + func parseBlobPositional() { 60 + let scope = Scope("blob:*/*") 61 + guard case let .blob(accepts) = scope else { 62 + Issue.record("expected .blob") 63 + return 64 + } 65 + #expect(accepts == ["*/*"]) 66 + } 67 + 68 + @Test("parses blob with accept query") 69 + func parseBlobAcceptQuery() { 70 + let scope = Scope("blob?accept=image/*&accept=video/*") 71 + guard case let .blob(accepts) = scope else { 72 + Issue.record("expected .blob") 73 + return 74 + } 75 + #expect(accepts == ["image/*", "video/*"]) 76 + } 77 + 78 + @Test("rejects empty and unknown scope strings") 79 + func rejectUnknown() { 80 + #expect(Scope("") == nil) 81 + #expect(Scope("garbage") == nil) 82 + #expect(Scope("repo:") == nil) 83 + // Illegal: positional + same-name query arg 84 + #expect(Scope("repo:app.bsky.feed.post?collection=app.bsky.feed.like") == nil) 85 + } 86 + 87 + // MARK: - format round-trips 88 + 89 + @Test("atproto formats as \"atproto\"") 90 + func formatAtproto() { 91 + #expect(Scope.atproto.rawValue == "atproto") 92 + } 93 + 94 + @Test("repo formats with positional collection when possible") 95 + func formatRepoPositional() { 96 + let scope = Scope.repo(collections: [.nsid("app.bsky.feed.post")], actions: nil) 97 + #expect(scope.rawValue == "repo:app.bsky.feed.post") 98 + } 99 + 100 + @Test("repo formats with sorted action query when actions specified") 101 + func formatRepoWithActions() { 102 + let scope = Scope.repo( 103 + collections: [.nsid("app.bsky.feed.post")], 104 + actions: [.update, .create] 105 + ) 106 + #expect(scope.rawValue == "repo:app.bsky.feed.post?action=create&action=update") 107 + } 108 + 109 + @Test("repo formats multi-collection via query form (sorted)") 110 + func formatRepoMultiCollection() { 111 + let scope = Scope.repo( 112 + collections: [.nsid("app.bsky.feed.like"), .nsid("app.bsky.feed.post")], 113 + actions: nil 114 + ) 115 + #expect(scope.rawValue == "repo?collection=app.bsky.feed.like&collection=app.bsky.feed.post") 116 + } 117 + 118 + @Test("blob formats with positional when single accept") 119 + func formatBlobPositional() { 120 + #expect(Scope.blob(accepts: ["*/*"]).rawValue == "blob:*/*") 121 + } 122 + 123 + @Test("blob formats with query when multiple accepts") 124 + func formatBlobMulti() { 125 + let scope = Scope.blob(accepts: ["image/*", "video/*"]) 126 + #expect(scope.rawValue == "blob?accept=image/*&accept=video/*") 127 + } 128 + 129 + @Test("round-trip parse → format for representative scopes") 130 + func roundTrip() { 131 + let strings = [ 132 + "atproto", 133 + "repo:*", 134 + "repo:app.bsky.feed.post", 135 + "repo:app.bsky.feed.post?action=create&action=update", 136 + "repo?collection=app.bsky.feed.like&collection=app.bsky.feed.post", 137 + "blob:*/*", 138 + "blob?accept=image/*&accept=video/*", 139 + ] 140 + for s in strings { 141 + guard let parsed = Scope(s) else { 142 + Issue.record("failed to parse \(s)") 143 + continue 144 + } 145 + #expect(parsed.rawValue == s) 146 + } 147 + } 148 + 149 + // MARK: - Set serialization 150 + 151 + @Test("Set serializes space-separated, sorted") 152 + func setFormat() { 153 + let set: Set<Scope> = [ 154 + .atproto, 155 + .repo(collections: [.nsid("app.bsky.feed.post")], actions: nil), 156 + ] 157 + #expect(Scope.formatted(set) == "atproto repo:app.bsky.feed.post") 158 + } 159 + 160 + @Test("Set parses from space-separated, ignoring unknown tokens") 161 + func setParse() { 162 + let set = Scope.parse("atproto nonsense repo:app.bsky.feed.post blob:*/*") 163 + #expect(set.contains(.atproto)) 164 + #expect(set.contains(.repo(collections: [.nsid("app.bsky.feed.post")], actions: nil))) 165 + #expect(set.contains(.blob(accepts: ["*/*"]))) 166 + } 167 + 168 + // MARK: - Containment (used by ensureScope) 169 + 170 + @Test("granted atproto satisfies requested atproto") 171 + func containsAtproto() { 172 + let granted: Set<Scope> = [.atproto] 173 + let requested: Set<Scope> = [.atproto] 174 + #expect(Scope.satisfies(granted: granted, requested: requested)) 175 + } 176 + 177 + @Test("granted repo wildcard satisfies any specific repo request") 178 + func containsRepoWildcard() { 179 + let granted: Set<Scope> = [.repo(collections: [.wildcard], actions: nil)] 180 + let requested: Set<Scope> = [ 181 + .repo(collections: [.nsid("app.bsky.feed.post")], actions: [.create]), 182 + ] 183 + #expect(Scope.satisfies(granted: granted, requested: requested)) 184 + } 185 + 186 + @Test("granted collection + all-actions satisfies specific action on that collection") 187 + func containsActionSubset() { 188 + let granted: Set<Scope> = [ 189 + .repo(collections: [.nsid("app.bsky.feed.post")], actions: nil), 190 + ] 191 + let requested: Set<Scope> = [ 192 + .repo(collections: [.nsid("app.bsky.feed.post")], actions: [.create]), 193 + ] 194 + #expect(Scope.satisfies(granted: granted, requested: requested)) 195 + } 196 + 197 + @Test("granted collection does NOT satisfy a different collection request") 198 + func containsCollectionMismatch() { 199 + let granted: Set<Scope> = [ 200 + .repo(collections: [.nsid("app.bsky.feed.post")], actions: nil), 201 + ] 202 + let requested: Set<Scope> = [ 203 + .repo(collections: [.nsid("app.bsky.actor.profile")], actions: nil), 204 + ] 205 + #expect(!Scope.satisfies(granted: granted, requested: requested)) 206 + } 207 + 208 + @Test("granted action subset does NOT satisfy broader action request") 209 + func containsActionBroader() { 210 + let granted: Set<Scope> = [ 211 + .repo(collections: [.nsid("app.bsky.feed.post")], actions: [.create]), 212 + ] 213 + let requested: Set<Scope> = [ 214 + .repo(collections: [.nsid("app.bsky.feed.post")], actions: [.create, .delete]), 215 + ] 216 + #expect(!Scope.satisfies(granted: granted, requested: requested)) 217 + } 218 + 219 + @Test("granted blob */* satisfies any blob request") 220 + func containsBlobWildcard() { 221 + let granted: Set<Scope> = [.blob(accepts: ["*/*"])] 222 + let requested: Set<Scope> = [.blob(accepts: ["image/png"])] 223 + #expect(Scope.satisfies(granted: granted, requested: requested)) 224 + } 225 + 226 + @Test("granted blob image/* does NOT satisfy video blob request") 227 + func containsBlobMismatch() { 228 + let granted: Set<Scope> = [.blob(accepts: ["image/*"])] 229 + let requested: Set<Scope> = [.blob(accepts: ["video/mp4"])] 230 + #expect(!Scope.satisfies(granted: granted, requested: requested)) 231 + } 232 + }