Something went wrong. Try again.
Proposal for Atmosphere Groups standard on atproto spaces
Something went wrong. Try again.
JavaScript
at lex-refactor
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403import 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.`,);