import assert from "node:assert/strict"; import { readdir, readFile } from "node:fs/promises"; import { resolve, sep } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; // Use a checkout with the draft space/space-ref extensions, without vendoring // upstream schemas or depending on an incompatible published SDK release. const root = fileURLToPath(new URL("../", import.meta.url)); const atproto = resolve(process.argv[2] ?? resolve(root, "../atproto")); const moduleAt = (path) => import(pathToFileURL(resolve(atproto, path)).href); const { lexiconDoc, Lexicons } = await moduleAt( "packages/lexicon/dist/index.js", ); const { LexiconIterableIndexer, LexiconSchemaBuilder } = await moduleAt( "packages/lex/lex-document/dist/index.js", ); const { parseAtUriString } = await moduleAt("packages/syntax/dist/index.js"); const { IncludeScope, RpcPermission, SpacePermission } = await moduleAt( "packages/oauth/oauth-scopes/dist/index.js", ); const ns = "group.opensocial."; const label = "com.atproto.label.label"; const directory = resolve(root, "lexicons"); const files = (await readdir(directory, { recursive: true })) .filter((file) => file.endsWith(".json")) .sort(); const docs = await Promise.all( files.map(async (file) => { const doc = JSON.parse(await readFile(resolve(directory, file), "utf8")); assert.equal( doc.id, file.slice(0, -5).split(sep).join("."), `NSID/path mismatch: ${file}`, ); // Also catch unsupported fields that the schema parser would silently strip. assert.deepEqual(lexiconDoc.parse(doc), doc, `Invalid schema: ${file}`); return doc; }), ); assert.equal(new Set(docs.map((doc) => doc.id)).size, docs.length); // Upstream schemas referenced by the group lexicons. const upstream = await Promise.all( [ "com/atproto/label/defs.json", "com/atproto/moderation/defs.json", "com/atproto/repo/strongRef.json", ].map(async (file) => JSON.parse(await readFile(resolve(atproto, "lexicons", file), "utf8")), ), ); const lexicons = new Lexicons(structuredClone([...docs, ...upstream])); function checkRefs(value, id) { if (!value || typeof value !== "object") return; if (value.type === "ref" || value.type === "union") { for (const ref of value.refs ?? [value.ref]) { assert.ok( lexicons.getDef(ref.startsWith("#") ? id + ref : ref), `Missing ref: ${ref}`, ); } } if (value.type === "space") { assert.equal(value.key, "literal:self"); assert.equal(new Set(value.collections).size, value.collections.length); for (const collection of value.collections) { assert.equal( lexicons.getDef(collection)?.type, "record", `Not a record: ${collection}`, ); } } for (const item of Object.values(value)) checkRefs(item, id); } for (const doc of docs) checkRefs(doc, doc.id); const schemas = await LexiconSchemaBuilder.buildAll( new LexiconIterableIndexer([...docs, ...upstream]), ); // Guard against accidentally leaving an entry from the proposal unimplemented. const proposal = await readFile(resolve(root, "proposal.md"), "utf8"); for (const match of proposal.matchAll( /^\| `group\.opensocial\.([A-Za-z]+)` \|/gm, )) { assert.ok(lexicons.get(ns + match[1]), `Missing proposal entry: ${match[1]}`); } for (const match of proposal .split("### Methods")[1] .matchAll(/^\| `([A-Za-z]+)` \|/gm)) { assert.ok( ["query", "procedure"].includes(lexicons.getDef(ns + match[1])?.type), `Missing method: ${match[1]}`, ); } assert.equal(lexicons.getDef(ns + "createReport")?.type, "procedure"); const examples = JSON.parse( await readFile(resolve(root, "examples/records.json"), "utf8"), ); const covered = new Set(); const authorityAuthored = new Set( [ "profile", "rule", "role", "permissions", "space", "membership", "access", "labeler", ] .map((name) => ns + name) .concat(label), ); for (const { uri, value } of examples) { const parsed = parseAtUriString(uri); assert.ok(parsed.success, `Invalid example URI: ${uri}`); assert.equal(parsed.value.collection, value.$type); const schema = schemas.get(value.$type + "#main"); schema.parse(value); lexicons.assertValidRecord(value.$type, value); covered.add(value.$type); const author = parsed.value.author ?? parsed.value.authority; if (authorityAuthored.has(value.$type)) { assert.equal(author, parsed.value.authority, `Wrong record author: ${uri}`); } if (value.$type === ns + "membership") assert.equal(parsed.value.rkey, value.member); if (value.$type === ns + "acceptance") { assert.equal(parsed.value.rkey, "self"); // These fixtures demonstrate a bidirectional membership pair. This is not // a requirement that all acceptance records have a current membership. assert.ok( examples.some(({ uri: candidateUri, value: candidate }) => { if ( candidate.$type !== ns + "membership" || candidate.member !== author ) return false; const candidateParts = parseAtUriString(candidateUri); return ( candidateParts.success && candidateParts.value.authority === parsed.value.authority ); }), `Acceptance example has no matching membership: ${uri}`, ); } if (value.$type === ns + "invite") assert.notEqual(author, parsed.value.authority); } for (const doc of docs) { if (doc.defs.main?.type === "record") assert.ok(covered.has(doc.id), `No example: ${doc.id}`); } const group = "did:plc:abcdefghijklmnopqrstuvwx"; const member = "did:plc:zyxwvutsrqponmlkjihgfedcb"; const space = `at://${group}/space/${ns}members/self`; const recordUri = `${space}/${member}/${ns}acceptance/self`; const cid = "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"; const subject = { $type: ns + "defs#recordSubject", uri: recordUri, cid }; const input = (name, value) => lexicons.assertValidXrpcInput(ns + name, value); const output = (name, value) => lexicons.assertValidXrpcOutput(ns + name, value); const params = (name, value) => lexicons.assertValidXrpcParams(ns + name, value); const rejects = (fn) => assert.throws(fn); // Exercise permission-set expansion with the actual OAuth scope implementation: // valid Lexicon shape alone does not establish that permissions survive expansion. const permissionSets = new Map(); const authorizedMethods = new Set(); for (const doc of docs) { if (doc.defs.main?.type !== "permission-set") continue; const expanded = new IncludeScope(doc.id).toPermissions(doc.defs.main); assert.equal( expanded.length, doc.defs.main.permissions.length, `Dropped permission in ${doc.id}`, ); permissionSets.set(doc.id, expanded); for (const permission of expanded) { if (permission instanceof RpcPermission) { for (const method of permission.lxm) { assert.ok( ["query", "procedure"].includes(lexicons.getDef(method)?.type), `Unknown RPC: ${method}`, ); authorizedMethods.add(method); } } else { assert.ok( permission instanceof SpacePermission, `Unexpected resource in ${doc.id}`, ); assert.equal(lexicons.getDef(permission.type)?.type, "space"); } } } // Space creation and deletion are gated by manage scopes, not rpc: scopes. const manageMethods = new Set( ["createSpace", "deleteSpace"].map((m) => ns + m), ); for (const doc of docs) { if (["query", "procedure"].includes(doc.defs.main?.type)) { assert.ok( authorizedMethods.has(doc.id) !== manageMethods.has(doc.id), `Wrong permission set coverage for ${doc.id}`, ); } } // Group-DID sets resolve `self` to the group; participate resolves it to the member. const allowsSpace = (set, target, self = group) => permissionSets .get(ns + set) .some( (permission) => permission instanceof SpacePermission && permission.withResolvedAuthority(self).matches(target), ); const allowsRpc = (set, method) => permissionSets.get(ns + set).some( (permission) => permission instanceof RpcPermission && permission.matches({ aud: `${group}#atproto_space_host`, lxm: ns + method, }), ); const metaSpace = { type: ns + "meta", authority: group, skey: "self" }; const memberSpace = { type: ns + "members", authority: group, skey: "self" }; const write = (target, collection) => ({ ...target, action: "create", collection, }); const inbox = { type: ns + "invites", authority: member, skey: "self" }; // Group-DID sets can read the group spaces they write in, but never an inbox. for (const set of ["configure", "manageSpaces", "moderate"]) { assert.ok(allowsSpace(set, { ...metaSpace, action: "read" })); assert.ok(allowsSpace(set, { ...memberSpace, action: "read" })); } assert.ok(allowsSpace("manageMembers", { ...inbox, action: "read_self" })); assert.ok(!allowsSpace("manageMembers", { ...inbox, action: "read" })); // Each group-DID set writes exactly the records the proposal assigns to it. const setWrites = { configure: [ [metaSpace, ns + "profile"], [metaSpace, ns + "rule"], [metaSpace, ns + "labeler"], [memberSpace, ns + "role"], [memberSpace, ns + "permissions"], ], manageSpaces: [ [memberSpace, ns + "space"], [memberSpace, ns + "access"], [metaSpace, ns + "access"], ], manageMembers: [ [memberSpace, ns + "membership"], [inbox, ns + "invite"], ], moderate: [ [metaSpace, label], [memberSpace, label], ], }; for (const [set, writes] of Object.entries(setWrites)) { for (const [other, otherWrites] of Object.entries(setWrites)) { for (const [target, collection] of otherWrites) { assert.equal( allowsSpace(set, write(target, collection)), set === other, `${set} ${set === other ? "cannot" : "can"} write ${collection}`, ); } } assert.ok(!allowsSpace(set, { ...memberSpace, manage: "delete" })); assert.ok( !allowsSpace( set, write({ ...memberSpace, authority: member }, ns + "role"), ), ); } assert.ok(allowsRpc("manageMembers", "denyJoinRequest")); assert.ok(allowsRpc("moderate", "resolveSubject")); assert.ok(!allowsRpc("moderate", "denyJoinRequest")); assert.ok(!allowsRpc("configure", "listSubjects")); // Personal-account participation. assert.ok( allowsSpace("participate", { ...memberSpace, action: "read" }, member), ); assert.ok( allowsSpace("participate", write(memberSpace, ns + "acceptance"), member), ); for (const collection of ["membership", "permissions", "access", "role"]) { assert.ok( !allowsSpace("participate", write(memberSpace, ns + collection), member), ); } assert.ok(!allowsSpace("participate", write(memberSpace, label), member)); assert.ok(allowsSpace("participate", { ...inbox, action: "read" }, member)); assert.ok(allowsSpace("participate", { ...inbox, manage: "create" }, member)); assert.ok( !allowsSpace( "participate", { ...inbox, authority: group, action: "read" }, member, ), ); assert.ok(allowsRpc("participate", "createReport")); assert.ok(!allowsRpc("participate", "denyJoinRequest")); // Personal calls name the group; group-DID calls take it from the session. input("createReport", { group, subject, reasonType: "com.atproto.moderation.defs#reasonOther", }); output("getSubjectHistory", { events: [ { id: "label-1", createdAt: "2026-09-29T12:00:00.000Z", event: { $type: ns + "defs#labelEvent", uri: `${space}/${group}/${label}/3jzfcijpj2z2a`, val: "!hide", deleted: true, }, }, ], }); input("createSpace", { type: "com.atmoboards.forum", access: { public: false, grants: [ { role: "member", scopes: ["space:com.atmoboards.forum?action=read"], }, ], }, }); output("createSpace", { space, access: { uri: recordUri, cid } }); rejects(() => input("createSpace", { type: "com.atmoboards.forum", access: { public: false }, }), ); rejects(() => input("createSpace", { type: "com.atmoboards.forum" })); input("deleteSpace", { space }); rejects(() => input("deleteSpace", { space: recordUri })); rejects(() => input("createReport", { group, subject: { $type: ns + "defs#accountSubject", did: "not-a-did" }, reasonType: "com.atproto.moderation.defs#reasonOther", }), ); // Member-authored acceptance needs no group role or permission payload. lexicons.assertValidRecord(ns + "acceptance", { $type: ns + "acceptance", createdAt: "2026-09-29T12:00:00.000Z", }); input("requestJoin", { group }); rejects(() => input("requestJoin", {})); output("requestJoin", { status: "admitted", membership: { uri: recordUri, cid }, }); output("requestJoin", { status: "pending", request: { member, createdAt: "2026-09-29T12:00:00.000Z" }, }); input("denyJoinRequest", { member }); rejects(() => input("denyJoinRequest", { member: "not-a-did" })); // Moderator transitions and query bounds are constrained at the schema layer. input("resolveSubject", { subject, status: "escalated", note: "Needs another moderator.", }); rejects(() => input("resolveSubject", { subject, status: "open" })); params("getSubjectHistory", { subject: recordUri, cid, limit: 100 }); params("getSubjectHistory", { subject: member }); rejects(() => params("listSubjects", { limit: 101 })); rejects(() => params("listJoinRequests", { limit: 0 })); console.log( `Validated ${docs.length} documents, compiled ${schemas.size} definitions, checked ${examples.length} records, ${permissionSets.size} permission sets, and API boundary cases.`, );