diff --git a/astro.config.mjs b/astro.config.mjs
index 94161fe..9a25ce0 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -28,6 +28,7 @@ export default defineConfig({
scopes: {
additionalScopes: [
"repo:community.lexicon.calendar.rsvp?action=create&action=update",
+ "repo:community.opensocial.membership?action=create&action=update",
],
},
driver: {
diff --git a/lexicons/community.opensocial.approveMember.json b/lexicons/community.opensocial.approveMember.json
new file mode 100644
index 0000000..7b06925
--- /dev/null
+++ b/lexicons/community.opensocial.approveMember.json
@@ -0,0 +1,34 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.approveMember",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Approve a pending member's join request.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["communityDid", "adminDid", "userDid"],
+ "properties": {
+ "communityDid": { "type": "string", "format": "did" },
+ "adminDid": { "type": "string", "format": "did" },
+ "userDid": { "type": "string", "format": "did" }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "properties": {}
+ }
+ },
+ "errors": [
+ { "name": "CommunityNotFound" },
+ { "name": "PermissionDenied" },
+ { "name": "NoPendingRequest" }
+ ]
+ }
+ }
+}
diff --git a/lexicons/community.opensocial.authBasic.json b/lexicons/community.opensocial.authBasic.json
new file mode 100644
index 0000000..03d3549
--- /dev/null
+++ b/lexicons/community.opensocial.authBasic.json
@@ -0,0 +1,19 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.authBasic",
+ "description": "Permission set for basic Open Social access — joining and managing community memberships.",
+ "defs": {
+ "main": {
+ "type": "permission-set",
+ "title": "Open Social Basic Access",
+ "detail": "Grants the ability to create, update, and delete community membership records in the user's own repository. This is the minimum permission needed for joining and leaving communities.",
+ "permissions": [
+ {
+ "type": "permission",
+ "resource": "repo",
+ "collection": ["community.opensocial.membership"]
+ }
+ ]
+ }
+ }
+}
diff --git a/lexicons/community.opensocial.createRecord.json b/lexicons/community.opensocial.createRecord.json
new file mode 100644
index 0000000..156fe40
--- /dev/null
+++ b/lexicons/community.opensocial.createRecord.json
@@ -0,0 +1,40 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.createRecord",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Create a record in a community's PDS repo on behalf of a member.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["communityDid", "userDid", "collection", "record"],
+ "properties": {
+ "communityDid": { "type": "string", "format": "did" },
+ "userDid": { "type": "string", "format": "did" },
+ "collection": { "type": "string" },
+ "record": { "type": "unknown" },
+ "rkey": { "type": "string" }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["uri", "cid"],
+ "properties": {
+ "uri": { "type": "string", "format": "at-uri" },
+ "cid": { "type": "string" }
+ }
+ }
+ },
+ "errors": [
+ { "name": "CommunityNotFound" },
+ { "name": "PermissionDenied" },
+ { "name": "InvalidInput" }
+ ]
+ }
+ }
+}
diff --git a/lexicons/community.opensocial.deleteCommunity.json b/lexicons/community.opensocial.deleteCommunity.json
new file mode 100644
index 0000000..9a9f14f
--- /dev/null
+++ b/lexicons/community.opensocial.deleteCommunity.json
@@ -0,0 +1,33 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.deleteCommunity",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Delete a community. Only the sole remaining admin can delete.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["communityDid", "adminDid"],
+ "properties": {
+ "communityDid": { "type": "string", "format": "did" },
+ "adminDid": { "type": "string", "format": "did" }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "properties": {}
+ }
+ },
+ "errors": [
+ { "name": "CommunityNotFound" },
+ { "name": "PermissionDenied" },
+ { "name": "MultipleAdmins" }
+ ]
+ }
+ }
+}
diff --git a/lexicons/community.opensocial.deleteRecord.json b/lexicons/community.opensocial.deleteRecord.json
new file mode 100644
index 0000000..d93f6d7
--- /dev/null
+++ b/lexicons/community.opensocial.deleteRecord.json
@@ -0,0 +1,35 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.deleteRecord",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Delete a record from a community's PDS repo.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["communityDid", "userDid", "collection", "rkey"],
+ "properties": {
+ "communityDid": { "type": "string", "format": "did" },
+ "userDid": { "type": "string", "format": "did" },
+ "collection": { "type": "string" },
+ "rkey": { "type": "string" }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "properties": {}
+ }
+ },
+ "errors": [
+ { "name": "CommunityNotFound" },
+ { "name": "PermissionDenied" },
+ { "name": "RecordNotFound" }
+ ]
+ }
+ }
+}
diff --git a/lexicons/community.opensocial.getPermissions.json b/lexicons/community.opensocial.getPermissions.json
new file mode 100644
index 0000000..9eec1b1
--- /dev/null
+++ b/lexicons/community.opensocial.getPermissions.json
@@ -0,0 +1,52 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.getPermissions",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Get permissions and user roles for a community.",
+ "parameters": {
+ "type": "params",
+ "required": ["communityDid"],
+ "properties": {
+ "communityDid": { "type": "string", "format": "did" },
+ "userDid": { "type": "string", "format": "did" }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["permissions", "userRoles"],
+ "properties": {
+ "permissions": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "#permission"
+ }
+ },
+ "userRoles": {
+ "type": "array",
+ "items": { "type": "string" }
+ }
+ }
+ }
+ },
+ "errors": [
+ { "name": "CommunityNotFound" }
+ ]
+ },
+ "permission": {
+ "type": "object",
+ "required": ["collection", "canCreate", "canRead", "canUpdate", "canDelete"],
+ "properties": {
+ "collection": { "type": "string" },
+ "canCreate": { "type": "string" },
+ "canRead": { "type": "string" },
+ "canUpdate": { "type": "string" },
+ "canDelete": { "type": "string" }
+ }
+ }
+ }
+}
diff --git a/lexicons/community.opensocial.getRecord.json b/lexicons/community.opensocial.getRecord.json
new file mode 100644
index 0000000..e8e5e50
--- /dev/null
+++ b/lexicons/community.opensocial.getRecord.json
@@ -0,0 +1,37 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.getRecord",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Get a specific record from a community's collection.",
+ "parameters": {
+ "type": "params",
+ "required": ["communityDid", "collection", "rkey"],
+ "properties": {
+ "communityDid": { "type": "string", "format": "did" },
+ "collection": { "type": "string" },
+ "rkey": { "type": "string" },
+ "userDid": { "type": "string", "format": "did" }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["uri", "cid", "value"],
+ "properties": {
+ "uri": { "type": "string", "format": "at-uri" },
+ "cid": { "type": "string" },
+ "value": { "type": "unknown" }
+ }
+ }
+ },
+ "errors": [
+ { "name": "CommunityNotFound" },
+ { "name": "PermissionDenied" },
+ { "name": "RecordNotFound" }
+ ]
+ }
+ }
+}
diff --git a/lexicons/community.opensocial.hierarchy.json b/lexicons/community.opensocial.hierarchy.json
new file mode 100644
index 0000000..d360ac4
--- /dev/null
+++ b/lexicons/community.opensocial.hierarchy.json
@@ -0,0 +1,48 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.hierarchy",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "Records one side of a parent-child hierarchy relationship between communities. Each community stores their own record. A relationship is active only when both communities have a record referencing each other with status=approved.",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": [
+ "role",
+ "counterpartyDid",
+ "status",
+ "requestedBy",
+ "createdAt"
+ ],
+ "properties": {
+ "role": {
+ "type": "string",
+ "description": "Whether this community is the parent or child in this relationship",
+ "knownValues": ["parent", "child"]
+ },
+ "counterpartyDid": {
+ "type": "string",
+ "format": "did",
+ "description": "DID of the other community in this relationship"
+ },
+ "status": {
+ "type": "string",
+ "description": "Status of this side of the relationship",
+ "knownValues": ["pending", "approved"]
+ },
+ "requestedBy": {
+ "type": "string",
+ "format": "did",
+ "description": "DID of the admin who created this record (initiated or approved the relationship)"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "When this record was created"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/community.opensocial.joinCommunity.json b/lexicons/community.opensocial.joinCommunity.json
new file mode 100644
index 0000000..1cfe04f
--- /dev/null
+++ b/lexicons/community.opensocial.joinCommunity.json
@@ -0,0 +1,50 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.joinCommunity",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Join a community. For open communities, joins immediately. For admin-approved, submits a request.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["communityDid", "userDid"],
+ "properties": {
+ "communityDid": { "type": "string", "format": "did" },
+ "userDid": { "type": "string", "format": "did" },
+ "membershipCid": { "type": "string" }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["status", "message"],
+ "properties": {
+ "status": { "type": "string" },
+ "message": { "type": "string" },
+ "membership": {
+ "type": "ref",
+ "ref": "#membership"
+ }
+ }
+ }
+ },
+ "errors": [
+ { "name": "CommunityNotFound" },
+ { "name": "AlreadyMember" },
+ { "name": "AlreadyPending" }
+ ]
+ },
+ "membership": {
+ "type": "object",
+ "properties": {
+ "communityDid": { "type": "string", "format": "did" },
+ "memberDid": { "type": "string", "format": "did" },
+ "joinedAt": { "type": "string", "format": "datetime" }
+ }
+ }
+ }
+}
diff --git a/lexicons/community.opensocial.leaveCommunity.json b/lexicons/community.opensocial.leaveCommunity.json
new file mode 100644
index 0000000..b083f21
--- /dev/null
+++ b/lexicons/community.opensocial.leaveCommunity.json
@@ -0,0 +1,33 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.leaveCommunity",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Leave a community.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["communityDid", "userDid"],
+ "properties": {
+ "communityDid": { "type": "string", "format": "did" },
+ "userDid": { "type": "string", "format": "did" }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "properties": {}
+ }
+ },
+ "errors": [
+ { "name": "CommunityNotFound" },
+ { "name": "NotMember" },
+ { "name": "CannotLeaveAsAdmin" }
+ ]
+ }
+ }
+}
diff --git a/lexicons/community.opensocial.membership.json b/lexicons/community.opensocial.membership.json
new file mode 100644
index 0000000..0b2a848
--- /dev/null
+++ b/lexicons/community.opensocial.membership.json
@@ -0,0 +1,26 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.membership",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "User's membership in a community (stored in user's repo)",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": ["community", "joinedAt"],
+ "properties": {
+ "community": {
+ "type": "string",
+ "format": "did",
+ "description": "DID of the community"
+ },
+ "joinedAt": {
+ "type": "string",
+ "format": "datetime"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/community.opensocial.membershipProof.json b/lexicons/community.opensocial.membershipProof.json
new file mode 100644
index 0000000..9c6f4a3
--- /dev/null
+++ b/lexicons/community.opensocial.membershipProof.json
@@ -0,0 +1,36 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.membershipProof",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "Community's proof/confirmation of a member's membership (stored in community's repo). Deleting this record effectively removes the member from the community, even if their own membership record persists in their PDS.",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": ["memberDid", "cid"],
+ "properties": {
+ "memberDid": {
+ "type": "string",
+ "format": "did",
+ "description": "DID of the member whose membership is being confirmed"
+ },
+ "membershipRef": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI reference to the user's community.opensocial.membership record"
+ },
+ "cid": {
+ "type": "string",
+ "description": "CID of the member's community.opensocial.membership record"
+ },
+ "confirmedAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "When the membership was confirmed by the community"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/community.opensocial.profile.json b/lexicons/community.opensocial.profile.json
new file mode 100644
index 0000000..33174c1
--- /dev/null
+++ b/lexicons/community.opensocial.profile.json
@@ -0,0 +1,85 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.profile",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "Community profile metadata",
+ "key": "literal:self",
+ "record": {
+ "type": "object",
+ "required": ["displayName", "createdAt"],
+ "properties": {
+ "displayName": {
+ "type": "string",
+ "maxLength": 64,
+ "description": "The community's display name"
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 512,
+ "description": "Brief description of the community"
+ },
+ "avatar": {
+ "type": "blob",
+ "accept": ["image/png", "image/jpeg"],
+ "maxSize": 1000000
+ },
+ "banner": {
+ "type": "blob",
+ "accept": ["image/png", "image/jpeg"],
+ "maxSize": 1000000
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["open", "admin-approved", "private"],
+ "description": "The type of community: open (anyone can join), admin-approved (requires approval), or private (invite-only)"
+ },
+ "guidelines": {
+ "type": "string",
+ "maxLength": 3000,
+ "description": "Community guidelines and rules"
+ },
+ "labelers": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "format": "did"
+ },
+ "description": "List of labeler DIDs for community moderation"
+ },
+ "links": {
+ "type": "array",
+ "maxLength": 16,
+ "description": "External links surfaced on the community's page (e.g. website, Discord, docs)",
+ "items": {
+ "type": "ref",
+ "ref": "#linkEntry"
+ }
+ }
+ }
+ }
+ },
+ "linkEntry": {
+ "type": "object",
+ "required": ["name", "url"],
+ "properties": {
+ "name": {
+ "type": "string",
+ "maxLength": 64,
+ "description": "Display label for the link"
+ },
+ "url": {
+ "type": "string",
+ "format": "uri",
+ "maxLength": 1024,
+ "description": "Absolute URL the link points to"
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/community.opensocial.putRecord.json b/lexicons/community.opensocial.putRecord.json
new file mode 100644
index 0000000..01e1217
--- /dev/null
+++ b/lexicons/community.opensocial.putRecord.json
@@ -0,0 +1,40 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.putRecord",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Put (create or update) a record in a community's PDS repo.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["communityDid", "userDid", "collection", "rkey", "record"],
+ "properties": {
+ "communityDid": { "type": "string", "format": "did" },
+ "userDid": { "type": "string", "format": "did" },
+ "collection": { "type": "string" },
+ "rkey": { "type": "string" },
+ "record": { "type": "unknown" }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["uri", "cid"],
+ "properties": {
+ "uri": { "type": "string", "format": "at-uri" },
+ "cid": { "type": "string" }
+ }
+ }
+ },
+ "errors": [
+ { "name": "CommunityNotFound" },
+ { "name": "PermissionDenied" },
+ { "name": "InvalidInput" }
+ ]
+ }
+ }
+}
diff --git a/lexicons/community.opensocial.rejectMember.json b/lexicons/community.opensocial.rejectMember.json
new file mode 100644
index 0000000..489be4d
--- /dev/null
+++ b/lexicons/community.opensocial.rejectMember.json
@@ -0,0 +1,34 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.rejectMember",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Reject a pending member's join request.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["communityDid", "adminDid", "userDid"],
+ "properties": {
+ "communityDid": { "type": "string", "format": "did" },
+ "adminDid": { "type": "string", "format": "did" },
+ "userDid": { "type": "string", "format": "did" }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "properties": {}
+ }
+ },
+ "errors": [
+ { "name": "CommunityNotFound" },
+ { "name": "PermissionDenied" },
+ { "name": "NoPendingRequest" }
+ ]
+ }
+ }
+}
diff --git a/lexicons/community.opensocial.removeMember.json b/lexicons/community.opensocial.removeMember.json
new file mode 100644
index 0000000..ca957b8
--- /dev/null
+++ b/lexicons/community.opensocial.removeMember.json
@@ -0,0 +1,35 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.removeMember",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Remove a member from a community (admin only).",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["communityDid", "adminDid", "memberDid"],
+ "properties": {
+ "communityDid": { "type": "string", "format": "did" },
+ "adminDid": { "type": "string", "format": "did" },
+ "memberDid": { "type": "string", "format": "did" }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "properties": {}
+ }
+ },
+ "errors": [
+ { "name": "CommunityNotFound" },
+ { "name": "PermissionDenied" },
+ { "name": "MemberNotFound" },
+ { "name": "CannotRemoveAdmin" }
+ ]
+ }
+ }
+}
diff --git a/lexicons/community.opensocial.sharedContent.json b/lexicons/community.opensocial.sharedContent.json
new file mode 100644
index 0000000..86fe351
--- /dev/null
+++ b/lexicons/community.opensocial.sharedContent.json
@@ -0,0 +1,78 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.sharedContent",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "A wrapper record stored in a community's repo that references external content (e.g. a standard.site document) shared by a member. Deleting this record removes the content from the community.",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": [
+ "type",
+ "documentUri",
+ "documentCid",
+ "sharedBy",
+ "title",
+ "sharedAt"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "The kind of content being shared",
+ "knownValues": ["document", "event"]
+ },
+ "documentUri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the source record (e.g. at://did:plc:abc/site.standard.document/3mjpdo4ylyy23)"
+ },
+ "documentCid": {
+ "type": "string",
+ "description": "CID of the source record at time of sharing, for version pinning"
+ },
+ "sharedBy": {
+ "type": "string",
+ "format": "did",
+ "description": "DID of the member who shared the content"
+ },
+ "title": {
+ "type": "string",
+ "maxGraphemes": 512,
+ "description": "Cached title from the source record for display without re-fetching"
+ },
+ "path": {
+ "type": "string",
+ "maxGraphemes": 1024,
+ "description": "Cached URL path from the source record"
+ },
+ "sharedAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "When the content was shared with the community"
+ },
+ "startsAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Cached event start time (only present when type=event)"
+ },
+ "endsAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Cached event end time (only present when type=event)"
+ },
+ "location": {
+ "type": "string",
+ "maxGraphemes": 512,
+ "description": "Cached event location string (only present when type=event)"
+ },
+ "mode": {
+ "type": "string",
+ "description": "Cached event mode (only present when type=event)",
+ "knownValues": ["in-person", "virtual", "hybrid"]
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/community.opensocial.sharedDocument.json b/lexicons/community.opensocial.sharedDocument.json
new file mode 100644
index 0000000..eec409b
--- /dev/null
+++ b/lexicons/community.opensocial.sharedDocument.json
@@ -0,0 +1,80 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.sharedDocument",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "A document shared by a member with a community. Stored in the community's repo. The record contains pre-resolved fields (url, source, author) so consumers can display the document without additional lookups.",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": [
+ "documentUri",
+ "documentCid",
+ "sharedBy",
+ "title",
+ "sharedAt",
+ "url",
+ "source",
+ "author"
+ ],
+ "properties": {
+ "documentUri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the source record (e.g. at://did:plc:abc/site.standard.document/3mjpdo4ylyy23)"
+ },
+ "documentCid": {
+ "type": "string",
+ "description": "CID of the source record at time of sharing, for version pinning"
+ },
+ "sharedBy": {
+ "type": "string",
+ "format": "did",
+ "description": "DID of the member who shared the content"
+ },
+ "title": {
+ "type": "string",
+ "maxGraphemes": 512,
+ "description": "Title of the document"
+ },
+ "url": {
+ "type": "string",
+ "format": "uri",
+ "description": "Pre-resolved web URL for the document (e.g. https://example.com/blog/my-post)"
+ },
+ "source": {
+ "type": "string",
+ "maxGraphemes": 128,
+ "description": "Platform or publication name where the document originates (e.g. 'leaflet', 'whtwnd')",
+ "knownValues": ["leaflet", "whtwnd", "unknown"]
+ },
+ "author": {
+ "type": "string",
+ "format": "did",
+ "description": "DID of the original content creator (may differ from sharedBy)"
+ },
+ "path": {
+ "type": "string",
+ "maxGraphemes": 1024,
+ "description": "URL path from the source record"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "maxGraphemes": 128
+ },
+ "maxLength": 32,
+ "description": "Tags from the source document"
+ },
+ "sharedAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "When the document was shared with the community"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/community.opensocial.sharedEvent.json b/lexicons/community.opensocial.sharedEvent.json
new file mode 100644
index 0000000..f7509ae
--- /dev/null
+++ b/lexicons/community.opensocial.sharedEvent.json
@@ -0,0 +1,91 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.sharedEvent",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "An event shared by a member with a community. Stored in the community's repo. The record contains pre-resolved fields (url, source, author) so consumers can display the event without additional lookups.",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": [
+ "documentUri",
+ "documentCid",
+ "sharedBy",
+ "title",
+ "sharedAt",
+ "url",
+ "source",
+ "author"
+ ],
+ "properties": {
+ "documentUri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the source event record (e.g. at://did:plc:abc/community.lexicon.calendar.event/3mjpdo4ylyy23)"
+ },
+ "documentCid": {
+ "type": "string",
+ "description": "CID of the source record at time of sharing, for version pinning"
+ },
+ "sharedBy": {
+ "type": "string",
+ "format": "did",
+ "description": "DID of the member who shared the event"
+ },
+ "title": {
+ "type": "string",
+ "maxGraphemes": 512,
+ "description": "Title/name of the event"
+ },
+ "url": {
+ "type": "string",
+ "format": "uri",
+ "description": "Pre-resolved web URL for the event (e.g. https://smokesignal.events/did/rkey or OpenMeet URL)"
+ },
+ "source": {
+ "type": "string",
+ "maxGraphemes": 128,
+ "description": "Platform name where the event originates (e.g. 'smokesignal', 'openmeet')",
+ "knownValues": ["smokesignal", "openmeet", "unknown"]
+ },
+ "author": {
+ "type": "string",
+ "format": "did",
+ "description": "DID of the original event creator (may differ from sharedBy)"
+ },
+ "path": {
+ "type": "string",
+ "maxGraphemes": 1024,
+ "description": "URL path if applicable"
+ },
+ "startsAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Event start time"
+ },
+ "endsAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Event end time"
+ },
+ "location": {
+ "type": "string",
+ "maxGraphemes": 512,
+ "description": "Event location display string"
+ },
+ "mode": {
+ "type": "string",
+ "description": "Event attendance mode",
+ "knownValues": ["in-person", "virtual", "hybrid"]
+ },
+ "sharedAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "When the event was shared with the community"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/parked-lexicons/community.opensocial.admins.json b/parked-lexicons/community.opensocial.admins.json
new file mode 100644
index 0000000..231eb48
--- /dev/null
+++ b/parked-lexicons/community.opensocial.admins.json
@@ -0,0 +1,34 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.admins",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "List of community administrators",
+ "key": "literal:self",
+ "record": {
+ "type": "object",
+ "required": ["admins"],
+ "properties": {
+ "admins": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["did"],
+ "properties": {
+ "did": {
+ "type": "string",
+ "format": "did"
+ },
+ "addedAt": {
+ "type": "string",
+ "format": "datetime"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/parked-lexicons/community.opensocial.getCommunity.json b/parked-lexicons/community.opensocial.getCommunity.json
new file mode 100644
index 0000000..8e392d3
--- /dev/null
+++ b/parked-lexicons/community.opensocial.getCommunity.json
@@ -0,0 +1,51 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.getCommunity",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Get details about a community.",
+ "parameters": {
+ "type": "params",
+ "required": ["did"],
+ "properties": {
+ "did": { "type": "string", "format": "did" },
+ "userDid": { "type": "string", "format": "did" }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["community", "isAdmin"],
+ "properties": {
+ "community": {
+ "type": "object",
+ "required": ["did", "handle", "displayName", "description", "type", "admins", "createdAt", "memberCount"],
+ "properties": {
+ "did": { "type": "string", "format": "did" },
+ "handle": { "type": "string" },
+ "displayName": { "type": "string" },
+ "description": { "type": "string" },
+ "guidelines": { "type": "string" },
+ "type": { "type": "string" },
+ "avatar": { "type": "unknown" },
+ "banner": { "type": "unknown" },
+ "admins": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "createdAt": { "type": "string", "format": "datetime" },
+ "memberCount": { "type": "integer" }
+ }
+ },
+ "isAdmin": { "type": "boolean" }
+ }
+ }
+ },
+ "errors": [
+ { "name": "CommunityNotFound" }
+ ]
+ }
+ }
+}
diff --git a/parked-lexicons/community.opensocial.getMembers.json b/parked-lexicons/community.opensocial.getMembers.json
new file mode 100644
index 0000000..fa1681a
--- /dev/null
+++ b/parked-lexicons/community.opensocial.getMembers.json
@@ -0,0 +1,48 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.getMembers",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "List members of a community.",
+ "parameters": {
+ "type": "params",
+ "required": ["communityDid"],
+ "properties": {
+ "communityDid": { "type": "string", "format": "did" },
+ "userDid": { "type": "string", "format": "did" },
+ "limit": { "type": "integer", "default": 50, "minimum": 1, "maximum": 100 },
+ "cursor": { "type": "string" }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["members", "total"],
+ "properties": {
+ "members": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "did": { "type": "string", "format": "did" },
+ "handle": { "type": "string" },
+ "displayName": { "type": "string" },
+ "avatar": { "type": "string" },
+ "isAdmin": { "type": "boolean" },
+ "confirmedAt": { "type": "string", "format": "datetime" }
+ }
+ }
+ },
+ "cursor": { "type": "string" },
+ "total": { "type": "integer" }
+ }
+ }
+ },
+ "errors": [
+ { "name": "CommunityNotFound" }
+ ]
+ }
+ }
+}
diff --git a/parked-lexicons/community.opensocial.getPendingMembers.json b/parked-lexicons/community.opensocial.getPendingMembers.json
new file mode 100644
index 0000000..e7824c0
--- /dev/null
+++ b/parked-lexicons/community.opensocial.getPendingMembers.json
@@ -0,0 +1,45 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.getPendingMembers",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "List pending member requests for a community (admin only).",
+ "parameters": {
+ "type": "params",
+ "required": ["communityDid", "adminDid"],
+ "properties": {
+ "communityDid": { "type": "string", "format": "did" },
+ "adminDid": { "type": "string", "format": "did" }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["members"],
+ "properties": {
+ "members": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["userDid", "requestedAt"],
+ "properties": {
+ "userDid": { "type": "string", "format": "did" },
+ "requestedAt": { "type": "string", "format": "datetime" },
+ "handle": { "type": "string" },
+ "displayName": { "type": "string" },
+ "avatar": { "type": "string" }
+ }
+ }
+ }
+ }
+ }
+ },
+ "errors": [
+ { "name": "CommunityNotFound" },
+ { "name": "PermissionDenied" }
+ ]
+ }
+ }
+}
diff --git a/parked-lexicons/community.opensocial.listRecords.json b/parked-lexicons/community.opensocial.listRecords.json
new file mode 100644
index 0000000..c5244c1
--- /dev/null
+++ b/parked-lexicons/community.opensocial.listRecords.json
@@ -0,0 +1,47 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.listRecords",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "List records in a community's collection.",
+ "parameters": {
+ "type": "params",
+ "required": ["communityDid", "collection"],
+ "properties": {
+ "communityDid": { "type": "string", "format": "did" },
+ "collection": { "type": "string" },
+ "limit": { "type": "integer", "default": 50, "minimum": 1, "maximum": 100 },
+ "cursor": { "type": "string" },
+ "userDid": { "type": "string", "format": "did" }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["records"],
+ "properties": {
+ "records": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["uri", "cid", "value"],
+ "properties": {
+ "uri": { "type": "string", "format": "at-uri" },
+ "cid": { "type": "string" },
+ "value": { "type": "unknown" }
+ }
+ }
+ },
+ "cursor": { "type": "string" }
+ }
+ }
+ },
+ "errors": [
+ { "name": "CommunityNotFound" },
+ { "name": "PermissionDenied" }
+ ]
+ }
+ }
+}
diff --git a/parked-lexicons/community.opensocial.searchCommunities.json b/parked-lexicons/community.opensocial.searchCommunities.json
new file mode 100644
index 0000000..eed4eb7
--- /dev/null
+++ b/parked-lexicons/community.opensocial.searchCommunities.json
@@ -0,0 +1,53 @@
+{
+ "lexicon": 1,
+ "id": "community.opensocial.searchCommunities",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Search for communities.",
+ "parameters": {
+ "type": "params",
+ "properties": {
+ "query": { "type": "string" },
+ "userDid": { "type": "string", "format": "did" },
+ "limit": { "type": "integer", "default": 25, "maximum": 100 },
+ "cursor": { "type": "string" }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["communities"],
+ "properties": {
+ "communities": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "did",
+ "handle",
+ "displayName",
+ "type",
+ "memberCount"
+ ],
+ "properties": {
+ "did": { "type": "string", "format": "did" },
+ "handle": { "type": "string" },
+ "displayName": { "type": "string" },
+ "type": { "type": "string" },
+ "isAdmin": { "type": "boolean" },
+ "memberCount": { "type": "integer" },
+ "createdAt": { "type": "string", "format": "datetime" },
+ "avatar": { "type": "string" }
+ }
+ }
+ },
+ "cursor": { "type": "string" }
+ }
+ }
+ },
+ "errors": [{ "name": "QueryTooShort" }]
+ }
+ }
+}
diff --git a/scripts/community-lexicons.mjs b/scripts/community-lexicons.mjs
new file mode 100644
index 0000000..6c0c432
--- /dev/null
+++ b/scripts/community-lexicons.mjs
@@ -0,0 +1,161 @@
+#!/usr/bin/env node
+// Resolve every community in src/data/communities.yml to its PDS and tally
+// which lexicons (NSIDs) appear in each repo via com.atproto.repo.describeRepo.
+//
+// Usage:
+// node scripts/community-lexicons.mjs
+// node scripts/community-lexicons.mjs --json # raw JSON report
+// node scripts/community-lexicons.mjs --prefix app.bsky. # only NSIDs with prefix
+
+import { readFile } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import yaml from "js-yaml";
+import { IdResolver } from "@atproto/identity";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const YML = join(__dirname, "..", "src", "data", "communities.yml");
+
+const args = process.argv.slice(2);
+const asJson = args.includes("--json");
+const prefixArg = args.find((a) => a.startsWith("--prefix"));
+const prefix = prefixArg?.includes("=")
+ ? prefixArg.split("=")[1]
+ : prefixArg
+ ? args[args.indexOf(prefixArg) + 1]
+ : undefined;
+
+const idResolver = new IdResolver();
+
+/** @typedef {{ name: string, handle: string }} Community */
+/** @typedef {{ community: Community, did?: string, pds?: string, collections?: string[], error?: string }} Row */
+
+function pdsFromDidDoc(doc) {
+ const services = doc?.service ?? [];
+ const svc = services.find(
+ (s) =>
+ s.id === "#atproto_pds" ||
+ s.id?.endsWith("#atproto_pds") ||
+ s.type === "AtprotoPersonalDataServer",
+ );
+ return typeof svc?.serviceEndpoint === "string" ? svc.serviceEndpoint : undefined;
+}
+
+/** @returns {Promise
+ {joinStatus.message} +
+ )} + + {!loggedInUser ? ( + + ) : isMember ? ( + + ) : ( + + )} + {mergedPosts.length > 0 ? (