Something went wrong. Try again.
Proposal for Atmosphere Groups standard on atproto spaces
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388import assert from "node:assert/strict";import { readdir, readFile } from "node:fs/promises";import { resolve } 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 directory = resolve(root, "lexicons/group/opensocial");const files = (await readdir(directory)) .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, ns + file.slice(0, -5), `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);const lexicons = new Lexicons(structuredClone(docs));
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),);
// 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", "label", ].map((name) => ns + name),);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.equal(author, value.group); assert.equal(parsed.value.authority, value.invitee); }}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"); } }}for (const doc of docs) { if (["query", "procedure"].includes(doc.defs.main?.type)) { assert.ok( authorizedMethods.has(doc.id), `No permission set covers ${doc.id}`, ); }}const allowsSpace = (set, target) => permissionSets .get(ns + set) .some( (permission) => permission instanceof SpacePermission && permission.withResolvedAuthority(member).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 memberSpace = { type: ns + "members", authority: group, skey: "self" };assert.ok(allowsSpace("authRead", { ...memberSpace, action: "read" }));assert.ok( !allowsSpace("authRead", { ...memberSpace, action: "create", collection: ns + "acceptance", }),);assert.ok( allowsSpace("authParticipate", { ...memberSpace, action: "create", collection: ns + "acceptance", }),);for (const collection of [ "membership", "permissions", "access", "role", "label",]) { assert.ok( !allowsSpace("authParticipate", { ...memberSpace, action: "create", collection: ns + collection, }), );}assert.ok( !allowsSpace("authParticipate", { ...memberSpace, manage: "delete" }),);assert.ok( !allowsSpace("authRead", { ...memberSpace, type: "com.atmoboards.forum", action: "read", }),);const inbox = { type: ns + "invites", authority: member, skey: "self", action: "read",};assert.ok(allowsSpace("authParticipate", inbox));assert.ok(!allowsSpace("authParticipate", { ...inbox, authority: group }));assert.ok(allowsRpc("authParticipate", "createReport"));assert.ok(!allowsRpc("authParticipate", "assignRoles"));assert.ok(!allowsRpc("authParticipate", "applyLabel"));assert.ok(allowsRpc("authManageMembers", "assignRoles"));assert.ok(!allowsRpc("authManageMembers", "putRole"));assert.ok(allowsRpc("authModerate", "applyLabel"));assert.ok(allowsRpc("authManageGroup", "putRole"));
input("updateProfile", { group, profile: { displayName: "Garden", joinPolicy: "approval", url: "https://garden.example.com/", },});rejects(() => input("updateProfile", { group, profile: { displayName: "Garden", joinPolicy: "approval", url: "not a URL", }, }),);
// Permissioned URIs must round-trip, while space references reject record URIs.input("createReport", { group, subject, reasonType: "com.atproto.moderation.defs#reasonOther",});rejects(() => input("createReport", { group, subject, reasonType: "" }));rejects(() => lexicons.assertValidXrpcOutput(ns + "getSubjectHistory", { events: [ { id: "report-1", createdAt: "2026-09-29T12:00:00.000Z", event: { $type: ns + "defs#reportEvent", reasonType: "" }, }, ], }),);input("applyLabel", { group, subject, val: "!hide" });output("applyLabel", { uri: recordUri, cid });input("updateSpace", { group, space, access: { public: false, readRoles: ["member"], grants: [] },});rejects(() => input("updateSpace", { group, space: recordUri, access: { public: false, readRoles: [], grants: [] }, }),);rejects(() => input("applyLabel", { group, subject: { $type: ns + "defs#accountSubject", did: "not-a-did" }, val: "!hide", }),);rejects(() => input("applyLabel", { group, subject, val: "" }));
// Replacement operations require the complete new permission-bearing payload.input("assignRoles", { group, member, roles: [] });rejects(() => input("assignRoles", { group, member }));rejects(() => input("assignRoles", { group, member, roles: ["invalid/role"] }));input("putRole", { group, role: "helper", value: { displayName: "Helper" }, actions: ["invite"], assignable: [],});rejects(() => input("putRole", { group, role: "helper", value: { displayName: "Helper" }, actions: ["invite"], }),);rejects(() => input("updateSpace", { group, space, access: { public: false } }),);
// 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 });output("requestJoin", { status: "admitted", membership: { uri: recordUri, cid },});output("requestJoin", { status: "pending", request: { id: "request-1", member, createdAt: "2026-09-29T12:00:00.000Z" },});rejects(() => output("requestJoin", { status: "unknown" }));
// Moderator transitions and query bounds are constrained at the schema layer.input("resolveSubject", { group, subject, status: "escalated", note: "Needs another moderator.",});rejects(() => input("resolveSubject", { group, subject, status: "open" }));params("getSubjectHistory", { group, subject: recordUri, cid, limit: 100 });params("getSubjectHistory", { group, subject: member });params("uploadImage", { group });rejects(() => params("uploadImage", {}));rejects(() => params("listSubjects", { group, limit: 101 }));rejects(() => params("listJoinRequests", { group, limit: 0 }));
console.log( `Validated ${docs.length} documents, compiled ${schemas.size} definitions, checked ${examples.length} records, ${permissionSets.size} permission sets, and API boundary cases.`,);