diff --git a/packages/oauth-provider/src/par.ts b/packages/oauth-provider/src/par.ts index 04c1bec..f31fb7f 100644 --- a/packages/oauth-provider/src/par.ts +++ b/packages/oauth-provider/src/par.ts @@ -190,7 +190,7 @@ export class PARHandler { params.scope = scope; const allowIncludes = !!this.permissionSetResolver; try { - parseScope(scope, { + params.scope = parseScope(scope, { allowIncludes, allowSpaceScopes: this.allowSpaceScopes, }); diff --git a/packages/oauth-provider/src/provider.ts b/packages/oauth-provider/src/provider.ts index 52d1ef0..191097d 100644 --- a/packages/oauth-provider/src/provider.ts +++ b/packages/oauth-provider/src/provider.ts @@ -23,11 +23,7 @@ import { isTokenValid, AUTH_CODE_TTL, } from "./tokens.js"; -import { - renderConsentUI, - renderErrorPage, - getConsentUiCsp, -} from "./ui.js"; +import { renderConsentUI, renderErrorPage, getConsentUiCsp } from "./ui.js"; import type { PermissionSetBundle, SpaceScopeInfo } from "./ui.js"; import { IncludeScope } from "@atproto/oauth-scopes"; import { authenticateClient, ClientAuthError } from "./client-auth.js"; @@ -226,9 +222,7 @@ export class ATProtoOAuthProvider { * consent — unlike permission sets, the scope's meaning is fully carried * by the scope string itself. */ - private async resolveSpaceMetadata( - scope: string, - ): Promise { + private async resolveSpaceMetadata(scope: string): Promise { if (!this.spacesEnabled) return []; const spaces: SpaceScopeInfo[] = []; for (const token of scope.split(" ")) { @@ -243,22 +237,18 @@ export class ATProtoOAuthProvider { this.permissionSetResolver?.resolveSpaceDeclaration ) { try { - const decl = - await this.permissionSetResolver.resolveSpaceDeclaration( - perm.type as Parameters< - NonNullable< - PermissionSetResolver["resolveSpaceDeclaration"] - > - >[0], - ); + const decl = await this.permissionSetResolver.resolveSpaceDeclaration( + perm.type as Parameters< + NonNullable + >[0], + ); if (decl?.name) { info.name = decl.name; } else { info.error = "Space type declaration was not found"; } } catch (e) { - info.error = - e instanceof Error ? e.message : "Resolution failed"; + info.error = e instanceof Error ? e.message : "Resolution failed"; } } spaces.push(info); @@ -284,9 +274,7 @@ export class ATProtoOAuthProvider { ? async (nsid) => { const decl = await resolver.resolveSpaceDeclaration!( nsid as Parameters< - NonNullable< - PermissionSetResolver["resolveSpaceDeclaration"] - > + NonNullable >[0], ); return decl?.collections ?? null; @@ -467,11 +455,10 @@ export class ATProtoOAuthProvider { // expanded later, at code-issuance time, so the consent UI can show // bundle titles in their original include form. const scope = params.scope ?? ATPROTO_SCOPE; - params.scope = scope; const allowIncludes = !!this.permissionSetResolver; const allowSpaceScopes = this.spacesEnabled; try { - parseScope(scope, { allowIncludes, allowSpaceScopes }); + params.scope = parseScope(scope, { allowIncludes, allowSpaceScopes }); } catch (e) { if (e instanceof ScopeParseError) { return await this.renderError("invalid_scope", e.message); @@ -613,13 +600,15 @@ export class ATProtoOAuthProvider { // stored scope contains only concrete granular permissions. const requestedScope = params.scope ?? ATPROTO_SCOPE; let scope = requestedScope; - if ( - this.permissionSetResolver && - requestedScope.includes("include:") - ) { + if (this.permissionSetResolver && requestedScope.includes("include:")) { try { - scope = await expandScope(requestedScope, this.permissionSetResolver); - parseScope(scope, { allowSpaceScopes: this.spacesEnabled }); + const expandedScope = await expandScope( + requestedScope, + this.permissionSetResolver, + ); + scope = parseScope(expandedScope, { + allowSpaceScopes: this.spacesEnabled, + }); } catch (e) { if (e instanceof ScopeParseError) { const errorUrl = new URL(redirectUri); @@ -1061,9 +1050,7 @@ export class ATProtoOAuthProvider { */ async verifyAccessToken( request: Request, - check?: - | string - | ((perms: ScopePermissionsTransition) => void), + check?: string | ((perms: ScopePermissionsTransition) => void), ): Promise { // Extract token from Authorization header const tokenInfo = extractAccessToken(request); @@ -1214,13 +1201,18 @@ export class ATProtoOAuthProvider { const allowIncludes = !!this.permissionSetResolver; let scope = requestedScope; try { - parseScope(requestedScope, { + scope = parseScope(requestedScope, { allowIncludes, allowSpaceScopes: this.spacesEnabled, }); if (allowIncludes && requestedScope.includes("include:")) { - scope = await expandScope(requestedScope, this.permissionSetResolver); - parseScope(scope, { allowSpaceScopes: this.spacesEnabled }); + const expandedScope = await expandScope( + requestedScope, + this.permissionSetResolver, + ); + scope = parseScope(expandedScope, { + allowSpaceScopes: this.spacesEnabled, + }); } } catch (e) { if (e instanceof ScopeParseError) { diff --git a/packages/oauth-provider/src/scopes.ts b/packages/oauth-provider/src/scopes.ts index 49a25f9..1ebdabc 100644 --- a/packages/oauth-provider/src/scopes.ts +++ b/packages/oauth-provider/src/scopes.ts @@ -1,11 +1,12 @@ /** * Scope parsing and matching, built on @atproto/oauth-scopes. * - * Granular scopes (`repo:`, `rpc:`, `blob:`, `account:`, `identity:`) are - * parsed structurally. Permission-set includes (`include:NSID?aud=...`) are - * resolved at authorize-time via an injected {@link PermissionSetResolver} - * and expanded into concrete granular scopes inline before the auth code is - * stored — so resource-server checks never need network access. + * Unsupported or malformed scope tokens are filtered out (via + * `isAtprotoOauthScope`) rather than rejected. Permission-set includes + * (`include:NSID?aud=...`) are resolved at authorize-time via an injected + * {@link PermissionSetResolver} and expanded into concrete granular scopes + * inline before the auth code is stored — so resource-server checks never + * need network access. */ import type { Nsid as AtcuteNsid } from "@atcute/lexicons/syntax"; @@ -16,6 +17,7 @@ import { IncludeScope, RepoPermission, RpcPermission, + isAtprotoOauthScope, ScopeMissingError, ScopePermissionsTransition, ScopesSet, @@ -23,7 +25,12 @@ import { import * as oauthScopes from "@atproto/oauth-scopes"; import type { PermissionSetResolver } from "./permission-sets.js"; -export { IncludeScope, ScopeMissingError, ScopePermissionsTransition, ScopesSet }; +export { + IncludeScope, + ScopeMissingError, + ScopePermissionsTransition, + ScopesSet, +}; /** * `SpacePermission` is only present in the `spaces-alpha` builds of @@ -94,10 +101,10 @@ const STRUCTURAL_PARSERS: Record< export interface ParseScopeOptions { /** - * When true, `include:` scopes are accepted (and structurally validated) - * but not expanded — the returned ScopesSet may still contain them. - * Use this at authorize-time, then call {@link expandScope} to resolve - * the includes before storing. + * When true, `include:` scopes are accepted but not expanded — the + * returned scope string may still contain them. Use this at + * authorize-time, then call {@link expandScope} to resolve the includes + * before storing. * * When false (default), `include:` scopes throw a ScopeParseError. Use * this on already-expanded scope strings (e.g. when re-validating a @@ -113,14 +120,22 @@ export interface ParseScopeOptions { } /** - * Validate a space-separated scope string. Returns the parsed ScopesSet on - * success. + * Filter and validate a space-separated scope string, returning the cleaned + * scope string on success. Tokens not recognized by `isAtprotoOauthScope` + * are silently dropped; a missing "atproto" base scope or a disallowed + * `include:` scope throws a {@link ScopeParseError}. */ export function parseScope( input: string | undefined | null, { allowIncludes = false, allowSpaceScopes = false }: ParseScopeOptions = {}, -): ScopesSet { - const set = ScopesSet.fromString(input ?? ""); +): string { + const filtered = + (input ?? "") + .split(" ") + .filter(Boolean) + .filter(isAtprotoOauthScope) + .join(" ") || undefined; + const set = ScopesSet.fromString(filtered); if (!set.has(ATPROTO_SCOPE)) { throw new ScopeParseError( @@ -149,7 +164,11 @@ export function parseScope( const colon = scope.indexOf(":"); const question = scope.indexOf("?"); const end = - colon === -1 ? question : question === -1 ? colon : Math.min(colon, question); + colon === -1 + ? question + : question === -1 + ? colon + : Math.min(colon, question); const resource = end === -1 ? scope : scope.slice(0, end); if (resource === "space" && !allowSpaceScopes) { throw new ScopeParseError( @@ -158,9 +177,7 @@ export function parseScope( ); } const parser = - STRUCTURAL_PARSERS[ - resource as (typeof GRANULAR_RESOURCES)[number] - ]; + STRUCTURAL_PARSERS[resource as (typeof GRANULAR_RESOURCES)[number]]; if (!parser) { throw new ScopeParseError(`Unknown scope resource: ${scope}`, scope); } @@ -169,7 +186,7 @@ export function parseScope( } } - return set; + return Array.from(set).join(" "); } /** @@ -261,9 +278,7 @@ export interface FinalizeSpaceScopesOptions { * without default collections (reads unaffected, writes constrained to * the explicitly requested collections). */ - resolveSpaceCollections?: ( - nsid: string, - ) => Promise; + resolveSpaceCollections?: (nsid: string) => Promise; } /** @@ -293,24 +308,15 @@ export async function finalizeSpaceScopes( } if (perm.isSelfAuthority) { - perm = perm.withResolvedAuthority( - userDid as `did:${string}:${string}`, - ); + perm = perm.withResolvedAuthority(userDid as `did:${string}:${string}`); } - if ( - !perm.hasCollections && - perm.type !== "*" && - resolveSpaceCollections - ) { + if (!perm.hasCollections && perm.type !== "*" && resolveSpaceCollections) { try { const collections = await resolveSpaceCollections(perm.type); if (collections && collections.length > 0) { perm = perm.withDefaultCollections( - collections as readonly ( - | "*" - | `${string}.${string}.${string}` - )[], + collections as readonly ("*" | `${string}.${string}.${string}`)[], ); } } catch { diff --git a/packages/oauth-provider/test/helpers.ts b/packages/oauth-provider/test/helpers.ts index a049d25..363fb3e 100644 --- a/packages/oauth-provider/test/helpers.ts +++ b/packages/oauth-provider/test/helpers.ts @@ -4,6 +4,7 @@ */ import { base64url } from "jose"; +import { parseScope, type ParseScopeOptions, ScopesSet } from "../src/scopes"; // ============================================ // DPoP Test Helpers @@ -249,3 +250,17 @@ export async function generateClientKeyPair( return result; } + +/* + * Parse a scope string into a ScopesSet + * @param scopes The scope string + * @param options The parse options + * @returns The ScopesSet + */ +export function parsedScopesSet( + scopes: string, + options?: ParseScopeOptions, +): ScopesSet { + const parsed = parseScope(scopes, options); + return ScopesSet.fromString(parsed); +} diff --git a/packages/oauth-provider/test/oauth-flow.test.ts b/packages/oauth-provider/test/oauth-flow.test.ts index e9436d3..be066fb 100644 --- a/packages/oauth-provider/test/oauth-flow.test.ts +++ b/packages/oauth-provider/test/oauth-flow.test.ts @@ -286,6 +286,50 @@ describe("OAuth Flow", () => { expect(json.expires_in).toBeGreaterThan(0); }); + it("reports the code's stored scope in the token response", async () => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + const keyPair = await generateDpopKeyPair("ES256"); + + const code = "test-code-scope-reporting"; + await storage.saveAuthCode(code, { + clientId: testClient.clientId, + redirectUri: testClient.redirectUris[0]!, + codeChallenge: challenge, + codeChallengeMethod: "S256", + scope: "atproto repo:app.bsky.feed.post", + sub: testUser.sub, + expiresAt: Date.now() + 60_000, + }); + + const dpopProof = await createDpopProof( + keyPair.privateKey, + keyPair.publicJwk, + { htm: "POST", htu: "https://pds.example.com/oauth/token" }, + "ES256", + ); + const response = await provider.handleToken( + new Request("https://pds.example.com/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + DPoP: dpopProof, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: testClient.clientId, + redirect_uri: testClient.redirectUris[0]!, + code_verifier: verifier, + }).toString(), + }), + ); + expect(response.status).toBe(200); + + const json = (await response.json()) as { scope?: string }; + expect(json.scope).toBe("atproto repo:app.bsky.feed.post"); + }); + it("rejects invalid PKCE verifier", async () => { const verifier = generateCodeVerifier(); const { code } = await getAuthCode(verifier); @@ -653,9 +697,10 @@ describe("OAuth Flow", () => { }); describe("Granular Scopes", () => { - async function authorizeAndToken( - scope: string, - ): Promise<{ accessToken: string; keyPair: Awaited> }> { + async function authorizeAndToken(scope: string): Promise<{ + accessToken: string; + keyPair: Awaited>; + }> { const verifier = generateCodeVerifier(); const challenge = await generateCodeChallenge(verifier); const keyPair = await generateDpopKeyPair("ES256"); @@ -802,8 +847,7 @@ describe("OAuth Flow", () => { code_challenge: challenge, code_challenge_method: "S256", state: "test-state", - scope: - "atproto include:com.example.basic?aud=did:web:foo%23svc", + scope: "atproto include:com.example.basic?aud=did:web:foo%23svc", }); const response = await provider.handlePAR( new Request("https://pds.example.com/oauth/par", { @@ -951,29 +995,5 @@ describe("OAuth Flow", () => { /com\.example\.missing/, ); }); - - it("PAR rejects malformed granular scope", async () => { - const verifier = generateCodeVerifier(); - const challenge = await generateCodeChallenge(verifier); - const parBody = new URLSearchParams({ - client_id: testClient.clientId, - redirect_uri: testClient.redirectUris[0]!, - response_type: "code", - code_challenge: challenge, - code_challenge_method: "S256", - state: "test-state", - scope: "atproto repo:not a real nsid", - }); - const response = await provider.handlePAR( - new Request("https://pds.example.com/oauth/par", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: parBody.toString(), - }), - ); - expect(response.status).toBe(400); - const json = (await response.json()) as { error: string }; - expect(json.error).toBe("invalid_scope"); - }); }); }); diff --git a/packages/oauth-provider/test/par.test.ts b/packages/oauth-provider/test/par.test.ts index 9406ede..740b51b 100644 --- a/packages/oauth-provider/test/par.test.ts +++ b/packages/oauth-provider/test/par.test.ts @@ -80,6 +80,29 @@ describe("PAR Handler", () => { expect(json).toHaveProperty("expires_in", 300); }); + it("accepts a request with unsupported scopes and stores the filtered scope", async () => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + + const request = createPARRequest({ + client_id: "did:web:client.example.com", + redirect_uri: "https://client.example.com/callback", + response_type: "code", + code_challenge: challenge, + code_challenge_method: "S256", + state: "random-state", + scope: "atproto repo:not a real nsid madeup:thing include:bad", + }); + + const response = await handler.handlePushRequest(request); + expect(response.status).toBe(201); + + const json = (await response.json()) as { request_uri: string }; + const parData = await storage.getPAR(json.request_uri); + expect(parData).not.toBeNull(); + expect(parData!.params.scope).toBe("atproto"); + }); + it("rejects request with wrong content type", async () => { const request = new Request("https://example.com/oauth/par", { method: "POST", diff --git a/packages/oauth-provider/test/scopes.test.ts b/packages/oauth-provider/test/scopes.test.ts index b41529a..cda322f 100644 --- a/packages/oauth-provider/test/scopes.test.ts +++ b/packages/oauth-provider/test/scopes.test.ts @@ -5,28 +5,32 @@ import { ScopeMissingError, ScopeParseError, expandScope, - parseScope, permissionsFor, } from "../src/scopes.js"; +import { parsedScopesSet } from "./helpers.js"; describe("parseScope", () => { it("accepts the bare atproto scope", () => { - const set = parseScope("atproto"); + const set = parsedScopesSet("atproto"); expect(set.has("atproto")).toBe(true); }); it("requires the atproto scope to be present", () => { - expect(() => parseScope("")).toThrow(ScopeParseError); - expect(() => parseScope("transition:generic")).toThrow(ScopeParseError); + expect(() => parsedScopesSet("")).toThrow(ScopeParseError); + expect(() => parsedScopesSet("transition:generic")).toThrow( + ScopeParseError, + ); }); it("accepts transitional scopes alongside atproto", () => { - const set = parseScope("atproto transition:generic transition:chat.bsky"); + const set = parsedScopesSet( + "atproto transition:generic transition:chat.bsky", + ); expect(set.size).toBe(3); }); it("accepts granular repo scopes", () => { - const set = parseScope( + const set = parsedScopesSet( "atproto repo:app.bsky.feed.post repo:*?action=delete", ); expect(set.has("repo:app.bsky.feed.post")).toBe(true); @@ -34,34 +38,44 @@ describe("parseScope", () => { }); it("accepts granular rpc scopes with audience", () => { - const set = parseScope( + const set = parsedScopesSet( "atproto rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app%23bsky_appview", ); expect(set.size).toBe(2); }); it("accepts blob, account, identity scopes", () => { - const set = parseScope( + const set = parsedScopesSet( "atproto blob:image/* account:email?action=manage identity:handle", ); expect(set.size).toBe(4); }); - it("rejects malformed granular scopes", () => { - expect(() => parseScope("atproto repo:not a real nsid")).toThrow( - ScopeParseError, + it("silently drops malformed scope tokens", () => { + const set = parsedScopesSet("atproto repo:not a real nsid"); + expect(set.has("atproto")).toBe(true); + expect(set.size).toBe(1); + }); + + it("silently drops scopes with unsupported actions", () => { + const set = parsedScopesSet( + "atproto repo:com.example.post?action=read repo:com.example.post?action=create", ); - expect(() => - parseScope("atproto rpc:app.bsky.feed.getTimeline"), // missing aud - ).toThrow(ScopeParseError); + expect(set.has("atproto")).toBe(true); + expect(set.has("repo:com.example.post?action=read")).toBe(false); + expect(set.has("repo:com.example.post?action=create")).toBe(true); + expect(set.has("repo:com.example.post")).toBe(false); + expect(set.size).toBe(2); }); - it("rejects unknown resources", () => { - expect(() => parseScope("atproto madeup:thing")).toThrow(ScopeParseError); + it("silently drops unknown scope resources", () => { + const set = parsedScopesSet("atproto madeup:thing"); + expect(set.has("atproto")).toBe(true); + expect(set.size).toBe(1); }); it("accepts repo scopes in query-only form (no positional)", () => { - const set = parseScope("atproto repo?collection=app.bsky.feed.post"); + const set = parsedScopesSet("atproto repo?collection=app.bsky.feed.post"); expect(set.has("repo?collection=app.bsky.feed.post")).toBe(true); }); @@ -71,18 +85,20 @@ describe("parseScope", () => { "&collection=site.standard.graph.recommend" + "&collection=site.standard.graph.subscription" + "&collection=site.standard.publication"; - const set = parseScope(`atproto ${scope}`); + const set = parsedScopesSet(`atproto ${scope}`); expect(set.has(scope)).toBe(true); }); it("rejects include: scopes by default (strict mode)", () => { expect(() => - parseScope("atproto include:com.example.basic?aud=did:web:foo%23svc"), + parsedScopesSet( + "atproto include:com.example.basic?aud=did:web:foo%23svc", + ), ).toThrow(/Permission sets cannot be requested/); }); it("accepts include: scopes when allowIncludes is true", () => { - const set = parseScope( + const set = parsedScopesSet( "atproto include:com.example.basic?aud=did:web:foo%23svc", { allowIncludes: true }, ); @@ -105,7 +121,9 @@ describe("permissionsFor", () => { }); it("scopes the action when ?action= is given", () => { - const perms = permissionsFor("atproto repo:app.bsky.feed.post?action=create"); + const perms = permissionsFor( + "atproto repo:app.bsky.feed.post?action=create", + ); expect( perms.allowsRepo({ collection: "app.bsky.feed.post", action: "create" }), ).toBe(true); @@ -124,18 +142,14 @@ describe("permissionsFor", () => { it("transition:generic does NOT grant account perms", () => { const perms = permissionsFor("atproto transition:generic"); - expect( - perms.allowsAccount({ attr: "email", action: "manage" }), - ).toBe(false); + expect(perms.allowsAccount({ attr: "email", action: "manage" })).toBe( + false, + ); }); it("transition:email grants account:email", () => { - const perms = permissionsFor( - "atproto transition:generic transition:email", - ); - expect( - perms.allowsAccount({ attr: "email", action: "read" }), - ).toBe(true); + const perms = permissionsFor("atproto transition:generic transition:email"); + expect(perms.allowsAccount({ attr: "email", action: "read" })).toBe(true); }); it("assertRepo throws ScopeMissingError when not granted", () => { diff --git a/packages/oauth-provider/test/space-scopes.test.ts b/packages/oauth-provider/test/space-scopes.test.ts index b67597b..88e399f 100644 --- a/packages/oauth-provider/test/space-scopes.test.ts +++ b/packages/oauth-provider/test/space-scopes.test.ts @@ -6,6 +6,7 @@ import { parseSpaceScope, permissionsFor, } from "../src/scopes.js"; +import { parsedScopesSet } from "./helpers.js"; describe("parseScope with space scopes", () => { it("rejects space scopes by default", () => { @@ -18,36 +19,37 @@ describe("parseScope with space scopes", () => { }); it("rejects the named-param space form by default too", () => { - expect(() => - parseScope("atproto space?type=app.bsky.group"), - ).toThrow(/not enabled/); + expect(() => parseScope("atproto space?type=app.bsky.group")).toThrow( + /not enabled/, + ); }); it("accepts valid space scopes when enabled", () => { - const set = parseScope("atproto space:app.bsky.group", { + const set = parsedScopesSet("atproto space:app.bsky.group", { allowSpaceScopes: true, }); expect(set.has("space:app.bsky.group")).toBe(true); }); it("accepts parameterised space scopes when enabled", () => { - const set = parseScope( + const set = parsedScopesSet( "atproto space:app.bsky.group?authority=did:plc:abc123&skey=3kbcq3p7ad400", { allowSpaceScopes: true }, ); expect(set.size).toBe(2); }); - it("rejects malformed space scopes even when enabled", () => { - // Positional type is required - expect(() => parseScope("atproto space", { allowSpaceScopes: true })).toThrow( - ScopeParseError, - ); - expect(() => - parseScope("atproto space:app.bsky.group?bogus=1", { - allowSpaceScopes: true, - }), - ).toThrow(ScopeParseError); + it("filters out malformed space scopes even when enabled", () => { + // // Positional type is required + const set1 = parsedScopesSet("atproto space:", { allowSpaceScopes: true }); + expect(set1.size).toBe(1); + expect(set1.has("atproto")).toBe(true); + + const set2 = parsedScopesSet("atproto space:app.bsky.group?bogus=1", { + allowSpaceScopes: true, + }); + expect(set2.size).toBe(1); + expect(set2.has("atproto")).toBe(true); }); }); @@ -79,8 +81,7 @@ describe("finalizeSpaceScopes", () => { }); it("leaves explicit authorities untouched", async () => { - const input = - "atproto space:app.bsky.group?authority=did:plc:abc123"; + const input = "atproto space:app.bsky.group?authority=did:plc:abc123"; const scope = await finalizeSpaceScopes(input, { userDid: did }); expect(scope).toContain("authority=did:plc:abc123"); expect(scope).not.toContain(did); @@ -129,13 +130,10 @@ describe("finalizeSpaceScopes", () => { describe("space permission checks", () => { it("grants match after self-resolution", async () => { const did = "did:web:alice.test"; - const stored = await finalizeSpaceScopes( - "atproto space:app.bsky.group", - { - userDid: did, - resolveSpaceCollections: async () => ["app.bsky.feed.post"], - }, - ); + const stored = await finalizeSpaceScopes("atproto space:app.bsky.group", { + userDid: did, + resolveSpaceCollections: async () => ["app.bsky.feed.post"], + }); const perms = permissionsFor(stored); expect( perms.allowsSpace({