atproto pds in zig pds.zat.dev
pds atproto
24

Configure Feed

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

Support OAuth permission sets

zzstoatzz (May 24, 2026, 6:08 PM -0500) 4268ad45 04884f57

+444 -5
+23 -5
src/atproto/oauth.zig
··· 2 2 const auth = @import("../auth/tokens.zig"); 3 3 const config = @import("../core/config.zig"); 4 4 const http_api = @import("../http/api.zig"); 5 + const permission_sets = @import("oauth/permission_sets.zig"); 5 6 const store = @import("../storage/store.zig"); 6 7 const zat = @import("zat"); 7 8 ··· 25 26 else 26 27 config.publicUrl(); 27 28 const body = try std.fmt.allocPrint(arena.allocator(), 28 - \\{{"resource":{f},"authorization_servers":[{f}],"scopes_supported":["atproto","transition:generic","transition:email","transition:chat.bsky","repo:*","blob:*/*","rpc:*","account:*","identity:*"],"bearer_methods_supported":["header"],"resource_documentation":"https://atproto.com"}} 29 + \\{{"resource":{f},"authorization_servers":[{f}],"scopes_supported":["atproto","transition:generic","transition:email","transition:chat.bsky","repo:*","blob:*/*","rpc:*","account:*","identity:*","include:*"],"bearer_methods_supported":["header"],"resource_documentation":"https://atproto.com"}} 29 30 , .{ std.json.fmt(resource, .{}), std.json.fmt(config.publicUrl(), .{}) }); 30 31 try http_api.json(request, .ok, body); 31 32 } ··· 36 37 const allocator = arena.allocator(); 37 38 const issuer = config.publicUrl(); 38 39 const body = try std.fmt.allocPrint(allocator, 39 - \\{{"issuer":{f},"request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"scopes_supported":["atproto","transition:generic","transition:email","transition:chat.bsky","repo:*","blob:*/*","rpc:*","account:*","identity:*"],"subject_types_supported":["public"],"response_types_supported":["code"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"authorization_response_iss_parameter_supported":true,"client_id_metadata_document_supported":true,"require_pushed_authorization_requests":true,"token_endpoint_auth_methods_supported":["none","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["ES256","ES256K"],"dpop_signing_alg_values_supported":["ES256","ES256K"],"prompt_values_supported":["none","login","consent","select_account","create"],"protected_resources":[{f}],"jwks_uri":{f},"authorization_endpoint":{f},"token_endpoint":{f},"revocation_endpoint":{f},"introspection_endpoint":{f},"pushed_authorization_request_endpoint":{f}}} 40 + \\{{"issuer":{f},"request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"scopes_supported":["atproto","transition:generic","transition:email","transition:chat.bsky","repo:*","blob:*/*","rpc:*","account:*","identity:*","include:*"],"subject_types_supported":["public"],"response_types_supported":["code"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"authorization_response_iss_parameter_supported":true,"client_id_metadata_document_supported":true,"require_pushed_authorization_requests":true,"token_endpoint_auth_methods_supported":["none","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["ES256","ES256K"],"dpop_signing_alg_values_supported":["ES256","ES256K"],"prompt_values_supported":["none","login","consent","select_account","create"],"protected_resources":[{f}],"jwks_uri":{f},"authorization_endpoint":{f},"token_endpoint":{f},"revocation_endpoint":{f},"introspection_endpoint":{f},"pushed_authorization_request_endpoint":{f}}} 40 41 , .{ 41 42 std.json.fmt(issuer, .{}), 42 43 std.json.fmt(issuer, .{}), ··· 108 109 const body = try authRequestInfoJson(allocator, oauth_request, client); 109 110 return http_api.json(request, .ok, body); 110 111 } 111 - const scopes_html = try scopesHtml(allocator, oauth_request.scope); 112 + const scopes_html = scopesHtml(allocator, oauth_request.scope) catch { 113 + return oauthError(request, .bad_request, "invalid_scope", "Failed to resolve requested permission set"); 114 + }; 112 115 const issuer = config.publicUrl(); 113 116 const html = try std.fmt.allocPrint(allocator, 114 117 \\<!doctype html> ··· 325 328 const access = try auth.createSessionJwt(allocator, "access", account); 326 329 const refresh = try auth.createSessionJwt(allocator, "refresh", account); 327 330 const expires_at = now() + token_expires_in; 328 - try store.putOAuthToken(account.did, client_id, scope, access, refresh, expires_at); 331 + const token_scope = permission_sets.expandScopes(allocator, scope) catch { 332 + return oauthError(request, .bad_request, "invalid_scope", "Failed to resolve requested permission set"); 333 + }; 334 + try store.putOAuthToken(account.did, client_id, token_scope, access, refresh, expires_at); 329 335 const body = try std.fmt.allocPrint(allocator, "{{\"access_token\":{f},\"refresh_token\":{f},\"token_type\":\"DPoP\",\"expires_in\":{d},\"scope\":{f},\"sub\":{f}}}", .{ 330 336 std.json.fmt(access, .{}), 331 337 std.json.fmt(refresh, .{}), 332 338 token_expires_in, 333 - std.json.fmt(scope, .{}), 339 + std.json.fmt(token_scope, .{}), 334 340 std.json.fmt(account.did, .{}), 335 341 }); 336 342 try http_api.json(request, .ok, body); ··· 575 581 while (scopes.next()) |scope| { 576 582 if (scope.len == 0) continue; 577 583 any = true; 584 + if (std.mem.startsWith(u8, scope, "include:")) { 585 + const permission_set = try permission_sets.resolvePermissionSet(allocator, scope); 586 + try out.writer.print( 587 + "<li class=\"scope\"><code class=\"scope-code\">{s}</code><span class=\"scope-text\"><strong>{s}</strong><br>{s}</span></li>", 588 + .{ 589 + try htmlEscape(allocator, scope), 590 + try htmlEscape(allocator, permission_set.title), 591 + try htmlEscape(allocator, permission_set.detail), 592 + }, 593 + ); 594 + continue; 595 + } 578 596 const display = scopeDisplay(scope); 579 597 try out.writer.print( 580 598 "<li class=\"scope\"><code class=\"scope-code\">{s}</code><span class=\"scope-text\">{s}</span></li>",
+421
src/atproto/oauth/permission_sets.zig
··· 1 + const std = @import("std"); 2 + const store = @import("../../storage/store.zig"); 3 + const zat = @import("zat"); 4 + 5 + const max_dns_txt_response_size = 1024 * 1024; 6 + const max_lexicon_response_size = 1024 * 1024; 7 + const doh_endpoint = "https://cloudflare-dns.com/dns-query"; 8 + 9 + pub const PermissionSet = struct { 10 + title: []const u8, 11 + detail: []const u8, 12 + expanded_scope: []const u8, 13 + }; 14 + 15 + pub fn expandScopes(allocator: std.mem.Allocator, scope_text: []const u8) ![]const u8 { 16 + var out: std.Io.Writer.Allocating = .init(allocator); 17 + var first = true; 18 + var scopes = std.mem.splitScalar(u8, scope_text, ' '); 19 + while (scopes.next()) |scope| { 20 + if (scope.len == 0) continue; 21 + const expanded = if (std.mem.startsWith(u8, scope, "include:")) 22 + try expandIncludeScope(allocator, scope) 23 + else 24 + scope; 25 + 26 + var expanded_parts = std.mem.splitScalar(u8, expanded, ' '); 27 + while (expanded_parts.next()) |part| { 28 + if (part.len == 0) continue; 29 + if (!first) try out.writer.writeByte(' '); 30 + first = false; 31 + try out.writer.writeAll(part); 32 + } 33 + } 34 + return out.toOwnedSlice(); 35 + } 36 + 37 + pub fn resolvePermissionSet(allocator: std.mem.Allocator, include_scope: []const u8) !PermissionSet { 38 + const parsed = try parseIncludeScope(include_scope); 39 + const lexicon = try fetchPermissionSetLexicon(allocator, parsed.nsid); 40 + const main = try mainPermissionSet(lexicon); 41 + const expanded = try buildExpandedScope(allocator, parsed.nsid, parsed.aud, main); 42 + if (expanded.len == 0) return error.EmptyPermissionSet; 43 + 44 + return .{ 45 + .title = try allocator.dupe(u8, jsonString(main, "title") orelse parsed.nsid), 46 + .detail = try allocator.dupe(u8, jsonString(main, "detail") orelse "permissions requested by this app."), 47 + .expanded_scope = expanded, 48 + }; 49 + } 50 + 51 + fn expandIncludeScope(allocator: std.mem.Allocator, include_scope: []const u8) ![]const u8 { 52 + const permission_set = try resolvePermissionSet(allocator, include_scope); 53 + return permission_set.expanded_scope; 54 + } 55 + 56 + const IncludeScope = struct { 57 + nsid: []const u8, 58 + aud: ?[]const u8, 59 + }; 60 + 61 + fn parseIncludeScope(scope: []const u8) !IncludeScope { 62 + const rest = if (std.mem.startsWith(u8, scope, "include:")) scope["include:".len..] else return error.InvalidIncludeScope; 63 + const query_start = std.mem.indexOfScalar(u8, rest, '?'); 64 + const nsid = if (query_start) |idx| rest[0..idx] else rest; 65 + if (zat.Nsid.parse(nsid) == null) return error.InvalidNsid; 66 + 67 + var aud: ?[]const u8 = null; 68 + if (query_start) |idx| { 69 + var params = std.mem.splitScalar(u8, rest[idx + 1 ..], '&'); 70 + while (params.next()) |param| { 71 + if (std.mem.startsWith(u8, param, "aud=")) { 72 + aud = param["aud=".len..]; 73 + break; 74 + } 75 + } 76 + } 77 + return .{ .nsid = nsid, .aud = aud }; 78 + } 79 + 80 + fn fetchPermissionSetLexicon(allocator: std.mem.Allocator, nsid: []const u8) !std.json.Parsed(std.json.Value) { 81 + const did = try resolveLexiconAuthority(allocator, nsid); 82 + var did_resolver = zat.DidResolver.init(store.currentIo(), allocator); 83 + defer did_resolver.deinit(); 84 + var did_doc = try did_resolver.resolve(zat.Did.parse(did) orelse return error.InvalidLexiconDid); 85 + defer did_doc.deinit(); 86 + const endpoint = did_doc.pdsEndpoint() orelse return error.LexiconPdsNotFound; 87 + 88 + const escaped_did = try percentEncode(allocator, did); 89 + const escaped_nsid = try percentEncode(allocator, nsid); 90 + const url = try std.fmt.allocPrint( 91 + allocator, 92 + "{s}/xrpc/com.atproto.repo.getRecord?repo={s}&collection=com.atproto.lexicon.schema&rkey={s}", 93 + .{ endpoint, escaped_did, escaped_nsid }, 94 + ); 95 + 96 + var transport = zat.HttpTransport.init(store.currentIo(), allocator); 97 + defer transport.deinit(); 98 + const result = try transport.fetch(.{ 99 + .url = url, 100 + .accept = "application/json", 101 + .max_response_size = max_lexicon_response_size, 102 + .redirect_behavior = .not_allowed, 103 + }); 104 + if (result.status != .ok) return error.LexiconFetchFailed; 105 + return try std.json.parseFromSlice(std.json.Value, allocator, result.body, .{}); 106 + } 107 + 108 + fn resolveLexiconAuthority(allocator: std.mem.Allocator, nsid: []const u8) ![]const u8 { 109 + const parsed = zat.Nsid.parse(nsid) orelse return error.InvalidNsid; 110 + const authority = try nsidAuthorityDomain(allocator, parsed.authority()); 111 + const dns_name = try std.fmt.allocPrint(allocator, "_lexicon.{s}", .{authority}); 112 + const url = try std.fmt.allocPrint(allocator, "{s}?name={s}&type=TXT", .{ doh_endpoint, dns_name }); 113 + 114 + var transport = zat.HttpTransport.init(store.currentIo(), allocator); 115 + defer transport.deinit(); 116 + const result = try transport.fetch(.{ 117 + .url = url, 118 + .accept = "application/dns-json", 119 + .max_response_size = max_dns_txt_response_size, 120 + .redirect_behavior = .not_allowed, 121 + }); 122 + if (result.status != .ok) return error.LexiconDnsFailed; 123 + 124 + const parsed_json = try std.json.parseFromSlice(std.json.Value, allocator, result.body, .{}); 125 + const root = switch (parsed_json.value) { 126 + .object => |object| object, 127 + else => return error.InvalidDnsResponse, 128 + }; 129 + const answers = switch (root.get("Answer") orelse return error.NoLexiconDnsRecords) { 130 + .array => |array| array, 131 + else => return error.InvalidDnsResponse, 132 + }; 133 + for (answers.items) |item| { 134 + const answer = switch (item) { 135 + .object => |object| object, 136 + else => continue, 137 + }; 138 + const data = jsonString(answer, "data") orelse continue; 139 + const did = extractDidFromTxt(data) orelse continue; 140 + if (zat.Did.parse(did) != null) return try allocator.dupe(u8, did); 141 + } 142 + return error.NoValidLexiconDid; 143 + } 144 + 145 + fn mainPermissionSet(lexicon: std.json.Parsed(std.json.Value)) !std.json.ObjectMap { 146 + const root = switch (lexicon.value) { 147 + .object => |object| object, 148 + else => return error.InvalidLexicon, 149 + }; 150 + const value = switch (root.get("value") orelse return error.InvalidLexicon) { 151 + .object => |object| object, 152 + else => return error.InvalidLexicon, 153 + }; 154 + const defs = switch (value.get("defs") orelse return error.InvalidLexicon) { 155 + .object => |object| object, 156 + else => return error.InvalidLexicon, 157 + }; 158 + const main = switch (defs.get("main") orelse return error.InvalidLexicon) { 159 + .object => |object| object, 160 + else => return error.InvalidLexicon, 161 + }; 162 + const def_type = jsonString(main, "type") orelse return error.InvalidLexicon; 163 + if (!std.mem.eql(u8, def_type, "permission-set")) return error.NotPermissionSet; 164 + return main; 165 + } 166 + 167 + fn buildExpandedScope( 168 + allocator: std.mem.Allocator, 169 + include_nsid: []const u8, 170 + include_aud: ?[]const u8, 171 + main: std.json.ObjectMap, 172 + ) ![]const u8 { 173 + const permissions = switch (main.get("permissions") orelse return error.InvalidPermissionSet) { 174 + .array => |array| array, 175 + else => return error.InvalidPermissionSet, 176 + }; 177 + var out: std.Io.Writer.Allocating = .init(allocator); 178 + var first = true; 179 + for (permissions.items) |item| { 180 + const permission = switch (item) { 181 + .object => |object| object, 182 + else => continue, 183 + }; 184 + const resource = jsonString(permission, "resource") orelse continue; 185 + if (std.mem.eql(u8, resource, "repo")) { 186 + try appendRepoScopes(&out.writer, &first, include_nsid, permission); 187 + } else if (std.mem.eql(u8, resource, "rpc")) { 188 + try appendRpcScopes(allocator, &out.writer, &first, include_nsid, include_aud, permission); 189 + } 190 + } 191 + return out.toOwnedSlice(); 192 + } 193 + 194 + fn appendRepoScopes( 195 + writer: *std.Io.Writer, 196 + first: *bool, 197 + include_nsid: []const u8, 198 + permission: std.json.ObjectMap, 199 + ) !void { 200 + const collections = switch (permission.get("collection") orelse return) { 201 + .array => |array| array, 202 + else => return, 203 + }; 204 + const actions_value = switch (permission.get("action") orelse .null) { 205 + .array => |array| array, 206 + else => null, 207 + }; 208 + if (actions_value) |actions| { 209 + if (!validRepoActions(actions.items)) return; 210 + } 211 + 212 + for (collections.items) |collection_value| { 213 + const collection = switch (collection_value) { 214 + .string => |string| string, 215 + else => return, 216 + }; 217 + if (!isParentAuthorityOf(include_nsid, collection)) return; 218 + } 219 + 220 + for (collections.items) |collection_value| { 221 + const collection = switch (collection_value) { 222 + .string => |string| string, 223 + else => unreachable, 224 + }; 225 + if (!first.*) try writer.writeByte(' '); 226 + first.* = false; 227 + try writer.print("repo:{s}", .{collection}); 228 + if (actions_value) |actions| { 229 + if (!isDefaultRepoActions(actions.items)) { 230 + try appendActionParams(writer, actions.items); 231 + } 232 + } 233 + } 234 + } 235 + 236 + fn appendRpcScopes( 237 + allocator: std.mem.Allocator, 238 + writer: *std.Io.Writer, 239 + first: *bool, 240 + include_nsid: []const u8, 241 + include_aud: ?[]const u8, 242 + permission: std.json.ObjectMap, 243 + ) !void { 244 + const lxms = switch (permission.get("lxm") orelse return) { 245 + .array => |array| array, 246 + else => return, 247 + }; 248 + const explicit_aud = jsonString(permission, "aud"); 249 + const inherit_aud = jsonBool(permission, "inheritAud") == true; 250 + if (explicit_aud != null and !std.mem.eql(u8, explicit_aud.?, "*")) return; 251 + if (explicit_aud != null and inherit_aud) return; 252 + const aud = explicit_aud orelse if (inherit_aud) include_aud else null; 253 + if (aud == null) return; 254 + 255 + for (lxms.items) |lxm_value| { 256 + const lxm = switch (lxm_value) { 257 + .string => |string| string, 258 + else => return, 259 + }; 260 + if (!isParentAuthorityOf(include_nsid, lxm)) return; 261 + } 262 + 263 + for (lxms.items) |lxm_value| { 264 + const lxm = switch (lxm_value) { 265 + .string => |string| string, 266 + else => unreachable, 267 + }; 268 + const escaped_aud = try percentEncode(allocator, aud.?); 269 + if (!first.*) try writer.writeByte(' '); 270 + first.* = false; 271 + try writer.print("rpc:{s}?aud={s}", .{ lxm, escaped_aud }); 272 + } 273 + } 274 + 275 + fn isParentAuthorityOf(include_nsid: []const u8, other: []const u8) bool { 276 + if (std.mem.eql(u8, other, "*")) return false; 277 + if (zat.Nsid.parse(other) == null) return false; 278 + const dot = std.mem.lastIndexOfScalar(u8, include_nsid, '.') orelse return false; 279 + if (dot >= other.len - 1) return false; 280 + return std.mem.eql(u8, include_nsid[0..dot], other[0..dot]) and other[dot] == '.'; 281 + } 282 + 283 + fn validRepoActions(actions: []const std.json.Value) bool { 284 + if (actions.len == 0) return false; 285 + for (actions) |action| { 286 + if (action != .string) return false; 287 + if (!(std.mem.eql(u8, action.string, "create") or 288 + std.mem.eql(u8, action.string, "update") or 289 + std.mem.eql(u8, action.string, "delete"))) return false; 290 + } 291 + return true; 292 + } 293 + 294 + fn isDefaultRepoActions(actions: []const std.json.Value) bool { 295 + return actions.len == 3 and 296 + hasRepoAction(actions, "create") and 297 + hasRepoAction(actions, "update") and 298 + hasRepoAction(actions, "delete"); 299 + } 300 + 301 + fn hasRepoAction(actions: []const std.json.Value, needle: []const u8) bool { 302 + for (actions) |action| { 303 + if (action == .string and std.mem.eql(u8, action.string, needle)) return true; 304 + } 305 + return false; 306 + } 307 + 308 + fn appendActionParams(writer: *std.Io.Writer, actions: []const std.json.Value) !void { 309 + var first = true; 310 + inline for (&.{ "create", "update", "delete" }) |candidate| { 311 + if (hasRepoAction(actions, candidate)) { 312 + try writer.writeByte(if (first) '?' else '&'); 313 + first = false; 314 + try writer.print("action={s}", .{candidate}); 315 + } 316 + } 317 + } 318 + 319 + fn jsonString(object: std.json.ObjectMap, key: []const u8) ?[]const u8 { 320 + return switch (object.get(key) orelse return null) { 321 + .string => |string| string, 322 + else => null, 323 + }; 324 + } 325 + 326 + fn jsonBool(object: std.json.ObjectMap, key: []const u8) ?bool { 327 + return switch (object.get(key) orelse return null) { 328 + .bool => |value| value, 329 + else => null, 330 + }; 331 + } 332 + 333 + fn extractDidFromTxt(txt_data: []const u8) ?[]const u8 { 334 + var data = txt_data; 335 + if (data.len >= 2 and data[0] == '"' and data[data.len - 1] == '"') { 336 + data = data[1 .. data.len - 1]; 337 + } 338 + if (std.mem.startsWith(u8, data, "did=")) return data["did=".len..]; 339 + return null; 340 + } 341 + 342 + fn nsidAuthorityDomain(allocator: std.mem.Allocator, authority: []const u8) ![]const u8 { 343 + var segments: std.ArrayList([]const u8) = .empty; 344 + defer segments.deinit(allocator); 345 + var parts = std.mem.splitScalar(u8, authority, '.'); 346 + while (parts.next()) |part| { 347 + try segments.append(allocator, part); 348 + } 349 + var out: std.Io.Writer.Allocating = .init(allocator); 350 + var index = segments.items.len; 351 + while (index > 0) { 352 + index -= 1; 353 + if (index != segments.items.len - 1) try out.writer.writeByte('.'); 354 + try out.writer.writeAll(segments.items[index]); 355 + } 356 + return out.toOwnedSlice(); 357 + } 358 + 359 + fn percentEncode(allocator: std.mem.Allocator, value: []const u8) ![]const u8 { 360 + var out: std.Io.Writer.Allocating = .init(allocator); 361 + for (value) |c| { 362 + if ((c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9') or c == '-' or c == '_' or c == '.' or c == '~') { 363 + try out.writer.writeByte(c); 364 + } else { 365 + try out.writer.print("%{X:0>2}", .{c}); 366 + } 367 + } 368 + return out.toOwnedSlice(); 369 + } 370 + 371 + test "permission set expansion includes repo permissions under include authority" { 372 + const allocator = std.testing.allocator; 373 + const text = 374 + \\{"value":{"defs":{"main":{"type":"permission-set","title":"demo","detail":"demo detail","permissions":[{"type":"permission","resource":"repo","action":["create","update","delete"],"collection":["fm.plyr.track","fm.plyr.like"]}]}}}} 375 + ; 376 + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, text, .{}); 377 + defer parsed.deinit(); 378 + const main = try mainPermissionSet(parsed); 379 + const expanded = try buildExpandedScope(allocator, "fm.plyr.authFullApp", null, main); 380 + defer allocator.free(expanded); 381 + try std.testing.expectEqualStrings("repo:fm.plyr.track repo:fm.plyr.like", expanded); 382 + } 383 + 384 + test "permission set expansion rejects mixed-authority repo permission" { 385 + const allocator = std.testing.allocator; 386 + const text = 387 + \\{"value":{"defs":{"main":{"type":"permission-set","permissions":[{"type":"permission","resource":"repo","action":["create","update","delete"],"collection":["fm.plyr.track","app.bsky.feed.post"]}]}}}} 388 + ; 389 + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, text, .{}); 390 + defer parsed.deinit(); 391 + const main = try mainPermissionSet(parsed); 392 + const expanded = try buildExpandedScope(allocator, "fm.plyr.authFullApp", null, main); 393 + defer allocator.free(expanded); 394 + try std.testing.expectEqualStrings("", expanded); 395 + } 396 + 397 + test "rpc permissions inherit include audience" { 398 + const allocator = std.testing.allocator; 399 + const text = 400 + \\{"value":{"defs":{"main":{"type":"permission-set","permissions":[{"type":"permission","resource":"rpc","inheritAud":true,"lxm":["fm.plyr.getThing","com.atproto.repo.getRecord"]}]}}}} 401 + ; 402 + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, text, .{}); 403 + defer parsed.deinit(); 404 + const main = try mainPermissionSet(parsed); 405 + const expanded = try buildExpandedScope(allocator, "fm.plyr.authFullApp", "did:web:api.plyr.fm#svc", main); 406 + defer allocator.free(expanded); 407 + try std.testing.expectEqualStrings("rpc:fm.plyr.getThing?aud=did%3Aweb%3Aapi.plyr.fm%23svc", expanded); 408 + } 409 + 410 + test "rpc permissions reject specific explicit audiences" { 411 + const allocator = std.testing.allocator; 412 + const text = 413 + \\{"value":{"defs":{"main":{"type":"permission-set","permissions":[{"type":"permission","resource":"rpc","aud":"did:web:api.plyr.fm#svc","lxm":["fm.plyr.getThing"]}]}}}} 414 + ; 415 + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, text, .{}); 416 + defer parsed.deinit(); 417 + const main = try mainPermissionSet(parsed); 418 + const expanded = try buildExpandedScope(allocator, "fm.plyr.authFullApp", null, main); 419 + defer allocator.free(expanded); 420 + try std.testing.expectEqualStrings("", expanded); 421 + }