From 70afcc7af033f83f162795a5a82491008fe23ebb Mon Sep 17 00:00:00 2001
From: Essential Randomness
Date: Sat, 23 May 2026 02:23:55 -0700
Subject: [PATCH] allow joining community
---
astro.config.mjs | 1 +
.../community.opensocial.approveMember.json | 34 +++
lexicons/community.opensocial.authBasic.json | 19 ++
.../community.opensocial.createRecord.json | 40 ++++
.../community.opensocial.deleteCommunity.json | 33 +++
.../community.opensocial.deleteRecord.json | 35 +++
.../community.opensocial.getPermissions.json | 52 +++++
lexicons/community.opensocial.getRecord.json | 37 +++
lexicons/community.opensocial.hierarchy.json | 48 ++++
.../community.opensocial.joinCommunity.json | 50 ++++
.../community.opensocial.leaveCommunity.json | 33 +++
lexicons/community.opensocial.membership.json | 26 +++
.../community.opensocial.membershipProof.json | 36 +++
lexicons/community.opensocial.profile.json | 85 +++++++
lexicons/community.opensocial.putRecord.json | 40 ++++
.../community.opensocial.rejectMember.json | 34 +++
.../community.opensocial.removeMember.json | 35 +++
.../community.opensocial.sharedContent.json | 78 +++++++
.../community.opensocial.sharedDocument.json | 80 +++++++
.../community.opensocial.sharedEvent.json | 91 ++++++++
.../community.opensocial.admins.json | 34 +++
.../community.opensocial.getCommunity.json | 51 ++++
.../community.opensocial.getMembers.json | 48 ++++
...ommunity.opensocial.getPendingMembers.json | 45 ++++
.../community.opensocial.listRecords.json | 47 ++++
...ommunity.opensocial.searchCommunities.json | 53 +++++
scripts/community-lexicons.mjs | 161 +++++++++++++
scripts/generate-cimd-key.mjs | 32 +++
src/actions/index.ts | 103 ++++++++
src/lib/community/atmosphere.ts | 7 +
src/lib/opensocial/generated/community.ts | 5 +
.../generated/community/opensocial.ts | 8 +
.../opensocial/getPermissions.defs.ts | 35 +++
.../community/opensocial/getPermissions.ts | 6 +
.../opensocial/joinCommunity.defs.ts | 38 +++
.../community/opensocial/joinCommunity.ts | 6 +
.../opensocial/leaveCommunity.defs.ts | 30 +++
.../community/opensocial/leaveCommunity.ts | 6 +
.../community/opensocial/membership.defs.ts | 27 +++
.../community/opensocial/membership.ts | 6 +
src/lib/opensocial/generated/index.ts | 5 +
src/lib/opensocial/membership.ts | 210 +++++++++++++++++
src/middleware.ts | 35 ++-
src/pages/community-content.astro | 219 ++++++++++++++++++
44 files changed, 2103 insertions(+), 1 deletion(-)
create mode 100644 lexicons/community.opensocial.approveMember.json
create mode 100644 lexicons/community.opensocial.authBasic.json
create mode 100644 lexicons/community.opensocial.createRecord.json
create mode 100644 lexicons/community.opensocial.deleteCommunity.json
create mode 100644 lexicons/community.opensocial.deleteRecord.json
create mode 100644 lexicons/community.opensocial.getPermissions.json
create mode 100644 lexicons/community.opensocial.getRecord.json
create mode 100644 lexicons/community.opensocial.hierarchy.json
create mode 100644 lexicons/community.opensocial.joinCommunity.json
create mode 100644 lexicons/community.opensocial.leaveCommunity.json
create mode 100644 lexicons/community.opensocial.membership.json
create mode 100644 lexicons/community.opensocial.membershipProof.json
create mode 100644 lexicons/community.opensocial.profile.json
create mode 100644 lexicons/community.opensocial.putRecord.json
create mode 100644 lexicons/community.opensocial.rejectMember.json
create mode 100644 lexicons/community.opensocial.removeMember.json
create mode 100644 lexicons/community.opensocial.sharedContent.json
create mode 100644 lexicons/community.opensocial.sharedDocument.json
create mode 100644 lexicons/community.opensocial.sharedEvent.json
create mode 100644 parked-lexicons/community.opensocial.admins.json
create mode 100644 parked-lexicons/community.opensocial.getCommunity.json
create mode 100644 parked-lexicons/community.opensocial.getMembers.json
create mode 100644 parked-lexicons/community.opensocial.getPendingMembers.json
create mode 100644 parked-lexicons/community.opensocial.listRecords.json
create mode 100644 parked-lexicons/community.opensocial.searchCommunities.json
create mode 100644 scripts/community-lexicons.mjs
create mode 100644 scripts/generate-cimd-key.mjs
create mode 100644 src/lib/community/atmosphere.ts
create mode 100644 src/lib/opensocial/generated/community.ts
create mode 100644 src/lib/opensocial/generated/community/opensocial.ts
create mode 100644 src/lib/opensocial/generated/community/opensocial/getPermissions.defs.ts
create mode 100644 src/lib/opensocial/generated/community/opensocial/getPermissions.ts
create mode 100644 src/lib/opensocial/generated/community/opensocial/joinCommunity.defs.ts
create mode 100644 src/lib/opensocial/generated/community/opensocial/joinCommunity.ts
create mode 100644 src/lib/opensocial/generated/community/opensocial/leaveCommunity.defs.ts
create mode 100644 src/lib/opensocial/generated/community/opensocial/leaveCommunity.ts
create mode 100644 src/lib/opensocial/generated/community/opensocial/membership.defs.ts
create mode 100644 src/lib/opensocial/generated/community/opensocial/membership.ts
create mode 100644 src/lib/opensocial/generated/index.ts
create mode 100644 src/lib/opensocial/membership.ts
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} */
+async function inspect(community) {
+ try {
+ const did = await idResolver.handle.resolve(community.handle);
+ if (!did) return { community, error: "handle did not resolve" };
+ const doc = await idResolver.did.resolve(did);
+ const pds = pdsFromDidDoc(doc);
+ if (!pds) return { community, did, error: "no PDS in DID doc" };
+
+ const url = `${pds}/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(did)}`;
+ const res = await fetch(url);
+ if (!res.ok) return { community, did, pds, error: `describeRepo HTTP ${res.status}` };
+ const body = await res.json();
+ const collections = Array.isArray(body.collections) ? body.collections : [];
+ return { community, did, pds, collections };
+ } catch (err) {
+ return { community, error: err instanceof Error ? err.message : String(err) };
+ }
+}
+
+function tally(rows) {
+ const counts = new Map();
+ for (const row of rows) {
+ if (!row.collections) continue;
+ for (const nsid of row.collections) {
+ if (prefix && !nsid.startsWith(prefix)) continue;
+ counts.set(nsid, (counts.get(nsid) ?? 0) + 1);
+ }
+ }
+ return { counts };
+}
+
+async function pMap(items, limit, fn) {
+ const out = new Array(items.length);
+ let i = 0;
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
+ while (true) {
+ const idx = i++;
+ if (idx >= items.length) return;
+ out[idx] = await fn(items[idx]);
+ }
+ });
+ await Promise.all(workers);
+ return out;
+}
+
+function renderTable(rows, total) {
+ const { counts } = tally(rows);
+ const sorted = [...counts.entries()].sort(
+ (a, b) => b[1] - a[1] || a[0].localeCompare(b[0]),
+ );
+
+ const ok = rows.filter((r) => r.collections).length;
+ const failed = rows.filter((r) => r.error);
+
+ console.log(`Communities: ${total} resolved: ${ok} failed: ${failed.length}`);
+ if (prefix) console.log(`Filter: NSIDs starting with "${prefix}"`);
+ console.log();
+
+ const widest = sorted.reduce((w, [n]) => Math.max(w, n.length), 8);
+ console.log(`${"NSID".padEnd(widest)} count share`);
+ console.log(`${"-".repeat(widest)} ----- -----`);
+ for (const [nsid, n] of sorted) {
+ const pct = ok ? Math.round((n / ok) * 100) : 0;
+ console.log(`${nsid.padEnd(widest)} ${String(n).padStart(5)} ${String(pct).padStart(3)}%`);
+ }
+
+ if (failed.length) {
+ console.log("\nFailed:");
+ for (const r of failed) {
+ console.log(` ${r.community.handle.padEnd(40)} ${r.error}`);
+ }
+ }
+
+ // Bonus: show communities that publish nothing beyond the default app.bsky.* set.
+ if (!prefix) {
+ const interesting = rows
+ .filter((r) => r.collections)
+ .map((r) => {
+ const beyondBsky = r.collections.filter(
+ (n) => !n.startsWith("app.bsky.") && !n.startsWith("chat.bsky."),
+ );
+ return { handle: r.community.handle, beyondBsky };
+ })
+ .filter((x) => x.beyondBsky.length > 0)
+ .sort((a, b) => b.beyondBsky.length - a.beyondBsky.length);
+
+ if (interesting.length) {
+ console.log("\nNon-bsky lexicons by community:");
+ for (const x of interesting) {
+ console.log(` ${x.handle}`);
+ for (const nsid of x.beyondBsky) console.log(` - ${nsid}`);
+ }
+ }
+ }
+}
+
+async function main() {
+ const text = await readFile(YML, "utf8");
+ /** @type {Community[]} */
+ const communities = yaml.load(text);
+
+ // Limit concurrency — running 23 fetches at once was flaking out the
+ // IdResolver against handles served via .well-known/atproto-did.
+ const rows = await pMap(communities, 5, inspect);
+
+ if (asJson) {
+ process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
+ return;
+ }
+
+ renderTable(rows, communities.length);
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/scripts/generate-cimd-key.mjs b/scripts/generate-cimd-key.mjs
new file mode 100644
index 0000000..a534d41
--- /dev/null
+++ b/scripts/generate-cimd-key.mjs
@@ -0,0 +1,32 @@
+#!/usr/bin/env node
+// Generate an Ed25519 key pair for OpenSocial CIMD (HTTP Signatures).
+// The private key is what you store in env (never commit). The public JWK
+// is what the running server publishes at /.well-known/client-metadata.json.
+//
+// Usage:
+// node scripts/generate-cimd-key.mjs
+//
+// Copy the OPENSOCIAL_CIMD_PRIVATE_KEY_BASE64 line into your deploy env.
+
+import { generateKeyPairSync } from "node:crypto";
+
+const { privateKey, publicKey } = generateKeyPairSync("ed25519");
+
+const privatePem = privateKey.export({ format: "pem", type: "pkcs8" });
+const publicJwk = publicKey.export({ format: "jwk" });
+const privateB64 = Buffer.from(privatePem, "utf8").toString("base64");
+
+const envLines = [
+ "# --- OpenSocial CIMD ---",
+ "# Private key used to sign requests to api.opensocial.community.",
+ `OPENSOCIAL_CIMD_PRIVATE_KEY_BASE64=${privateB64}`,
+ "# Optional. Defaults to opensocial-cimd-1. Bump when rotating.",
+ `OPENSOCIAL_CIMD_KID=opensocial-cimd-${new Date()
+ .toISOString()
+ .slice(0, 10)}`,
+ "",
+ "# Public JWK that will be served at /.well-known/client-metadata.json:",
+ `# ${JSON.stringify(publicJwk)}`,
+];
+
+console.log(envLines.join("\n"));
diff --git a/src/actions/index.ts b/src/actions/index.ts
index 95591a6..e29b1eb 100644
--- a/src/actions/index.ts
+++ b/src/actions/index.ts
@@ -14,6 +14,44 @@ import {
setRsvpStatus,
type RsvpStatus,
} from "../lib/rsvps";
+import { getAtmosphereCommunityDid } from "../lib/community/atmosphere";
+import {
+ OpenSocialCommunityError,
+ ensureUserMembershipRecord,
+ getMembership,
+ joinCommunity,
+ leaveCommunity,
+} from "../lib/opensocial/membership";
+
+function joinRedirect(status: string): string {
+ return `/community-content?join=${encodeURIComponent(status)}`;
+}
+
+function joinStatusFromError(err: OpenSocialCommunityError): string {
+ switch (err.code) {
+ case "AlreadyMember":
+ return "already";
+ case "AlreadyPending":
+ return "pending";
+ case "CommunityNotFound":
+ return "missing";
+ default:
+ return "error";
+ }
+}
+
+function leaveStatusFromError(err: OpenSocialCommunityError): string {
+ switch (err.code) {
+ case "NotMember":
+ return "not-member";
+ case "CommunityNotFound":
+ return "missing";
+ case "CannotLeaveAsAdmin":
+ return "admin-block";
+ default:
+ return "error";
+ }
+}
const EVENT_COLLECTION = "community.lexicon.calendar.event";
@@ -56,6 +94,71 @@ function isPermissionError(error: unknown): boolean {
}
export const server = {
+ joinAtmosphereCommunity: defineAction({
+ accept: "form",
+ handler: async (_input, ctx) => {
+ const loggedInUser = ctx.locals.loggedInUser;
+ if (!loggedInUser) {
+ return { redirectUrl: joinRedirect("signin") };
+ }
+ try {
+ const communityDid = await getAtmosphereCommunityDid();
+ const membership = await getMembership({
+ communityDid,
+ userDid: loggedInUser.did,
+ });
+ if (membership.isMember) {
+ return { redirectUrl: joinRedirect("already") };
+ }
+ const membershipRecord = await ensureUserMembershipRecord({
+ loggedInUser,
+ communityDid,
+ });
+ const result = await joinCommunity({
+ communityDid,
+ userDid: loggedInUser.did,
+ membershipCid: membershipRecord.cid,
+ });
+ return {
+ redirectUrl: joinRedirect(result.status === "pending" ? "pending" : "ok"),
+ };
+ } catch (err) {
+ if (err instanceof OpenSocialCommunityError) {
+ return { redirectUrl: joinRedirect(joinStatusFromError(err)) };
+ }
+ if (isPermissionError(err)) {
+ return { redirectUrl: joinRedirect("permission") };
+ }
+ console.warn("[joinAtmosphereCommunity] unexpected error", err);
+ return { redirectUrl: joinRedirect("error") };
+ }
+ },
+ }),
+
+ leaveAtmosphereCommunity: defineAction({
+ accept: "form",
+ handler: async (_input, ctx) => {
+ const loggedInUser = ctx.locals.loggedInUser;
+ if (!loggedInUser) {
+ return { redirectUrl: joinRedirect("signin") };
+ }
+ try {
+ const communityDid = await getAtmosphereCommunityDid();
+ await leaveCommunity({
+ communityDid,
+ userDid: loggedInUser.did,
+ });
+ return { redirectUrl: joinRedirect("left") };
+ } catch (err) {
+ if (err instanceof OpenSocialCommunityError) {
+ return { redirectUrl: joinRedirect(leaveStatusFromError(err)) };
+ }
+ console.warn("[leaveAtmosphereCommunity] unexpected error", err);
+ return { redirectUrl: joinRedirect("error") };
+ }
+ },
+ }),
+
rsvpEvent: defineAction({
accept: "form",
input: z.object({
diff --git a/src/lib/community/atmosphere.ts b/src/lib/community/atmosphere.ts
new file mode 100644
index 0000000..c0acf86
--- /dev/null
+++ b/src/lib/community/atmosphere.ts
@@ -0,0 +1,7 @@
+import { resolveHandleToDid } from "./identity";
+
+export const ATMOSPHERE_COMMUNITY_HANDLE = "atmosphere.community";
+
+export function getAtmosphereCommunityDid(): Promise {
+ return resolveHandleToDid(ATMOSPHERE_COMMUNITY_HANDLE);
+}
diff --git a/src/lib/opensocial/generated/community.ts b/src/lib/opensocial/generated/community.ts
new file mode 100644
index 0000000..b34bd1c
--- /dev/null
+++ b/src/lib/opensocial/generated/community.ts
@@ -0,0 +1,5 @@
+/*
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
+ */
+
+export * as opensocial from "./community/opensocial.js";
diff --git a/src/lib/opensocial/generated/community/opensocial.ts b/src/lib/opensocial/generated/community/opensocial.ts
new file mode 100644
index 0000000..6ad5cca
--- /dev/null
+++ b/src/lib/opensocial/generated/community/opensocial.ts
@@ -0,0 +1,8 @@
+/*
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
+ */
+
+export * as getPermissions from "./opensocial/getPermissions.js";
+export * as joinCommunity from "./opensocial/joinCommunity.js";
+export * as leaveCommunity from "./opensocial/leaveCommunity.js";
+export * as membership from "./opensocial/membership.js";
diff --git a/src/lib/opensocial/generated/community/opensocial/getPermissions.defs.ts b/src/lib/opensocial/generated/community/opensocial/getPermissions.defs.ts
new file mode 100644
index 0000000..77982fb
--- /dev/null
+++ b/src/lib/opensocial/generated/community/opensocial/getPermissions.defs.ts
@@ -0,0 +1,35 @@
+/*
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
+ */
+
+import { l } from "@atproto/lex";
+
+const $nsid = "community.opensocial.getPermissions";
+
+export { $nsid };
+
+/** Get permissions and user roles for a community. */
+const main =
+ l.query(
+ $nsid,
+ l.params({"communityDid":l.string({"format":"did"}),"userDid":l.optional(l.string({"format":"did"}))}),
+ l.jsonPayload({"permissions":l.array(l.ref((() => permission) as any), ),"userRoles":l.array(l.string(), )}),
+ ["CommunityNotFound"]
+ )
+ ;
+
+export { main };
+
+export type $Params = l.InferMethodParams;
+export type $Output = l.InferMethodOutput;
+export type $OutputBody = l.InferMethodOutputBody;
+
+export const $lxm = main.nsid, $params = main.parameters, $output = main.output;
+
+type Permission = { $type?: "community.opensocial.getPermissions#permission";"collection":string;"canCreate":string;"canRead":string;"canUpdate":string;"canDelete":string };
+
+export type { Permission };
+
+const permission = l.typedObject($nsid, "permission", l.object({"collection":l.string(),"canCreate":l.string(),"canRead":l.string(),"canUpdate":l.string(),"canDelete":l.string()}));
+
+export { permission };
diff --git a/src/lib/opensocial/generated/community/opensocial/getPermissions.ts b/src/lib/opensocial/generated/community/opensocial/getPermissions.ts
new file mode 100644
index 0000000..b7138d6
--- /dev/null
+++ b/src/lib/opensocial/generated/community/opensocial/getPermissions.ts
@@ -0,0 +1,6 @@
+/*
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
+ */
+
+export * from "./getPermissions.defs.js";
+export * as $defs from "./getPermissions.defs.js";
diff --git a/src/lib/opensocial/generated/community/opensocial/joinCommunity.defs.ts b/src/lib/opensocial/generated/community/opensocial/joinCommunity.defs.ts
new file mode 100644
index 0000000..7197196
--- /dev/null
+++ b/src/lib/opensocial/generated/community/opensocial/joinCommunity.defs.ts
@@ -0,0 +1,38 @@
+/*
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
+ */
+
+import { l } from "@atproto/lex";
+
+const $nsid = "community.opensocial.joinCommunity";
+
+export { $nsid };
+
+/** Join a community. For open communities, joins immediately. For admin-approved, submits a request. */
+const main =
+ l.procedure(
+ $nsid,
+ l.params(),
+ l.jsonPayload({"communityDid":l.string({"format":"did"}),"userDid":l.string({"format":"did"}),"membershipCid":l.optional(l.string())}),
+ l.jsonPayload({"status":l.string(),"message":l.string(),"membership":l.optional(l.ref((() => membership) as any))}),
+ ["CommunityNotFound","AlreadyMember","AlreadyPending"]
+ )
+ ;
+
+export { main };
+
+export type $Params = l.InferMethodParams;
+export type $Input = l.InferMethodInput;
+export type $InputBody = l.InferMethodInputBody;
+export type $Output = l.InferMethodOutput;
+export type $OutputBody = l.InferMethodOutputBody;
+
+export const $lxm = main.nsid, $params = main.parameters, $input = main.input, $output = main.output;
+
+type Membership = { $type?: "community.opensocial.joinCommunity#membership";"communityDid"?:l.DidString;"memberDid"?:l.DidString;"joinedAt"?:l.DatetimeString };
+
+export type { Membership };
+
+const membership = l.typedObject($nsid, "membership", l.object({"communityDid":l.optional(l.string({"format":"did"})),"memberDid":l.optional(l.string({"format":"did"})),"joinedAt":l.optional(l.string({"format":"datetime"}))}));
+
+export { membership };
diff --git a/src/lib/opensocial/generated/community/opensocial/joinCommunity.ts b/src/lib/opensocial/generated/community/opensocial/joinCommunity.ts
new file mode 100644
index 0000000..48d050b
--- /dev/null
+++ b/src/lib/opensocial/generated/community/opensocial/joinCommunity.ts
@@ -0,0 +1,6 @@
+/*
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
+ */
+
+export * from "./joinCommunity.defs.js";
+export * as $defs from "./joinCommunity.defs.js";
diff --git a/src/lib/opensocial/generated/community/opensocial/leaveCommunity.defs.ts b/src/lib/opensocial/generated/community/opensocial/leaveCommunity.defs.ts
new file mode 100644
index 0000000..fdd2221
--- /dev/null
+++ b/src/lib/opensocial/generated/community/opensocial/leaveCommunity.defs.ts
@@ -0,0 +1,30 @@
+/*
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
+ */
+
+import { l } from "@atproto/lex";
+
+const $nsid = "community.opensocial.leaveCommunity";
+
+export { $nsid };
+
+/** Leave a community. */
+const main =
+ l.procedure(
+ $nsid,
+ l.params(),
+ l.jsonPayload({"communityDid":l.string({"format":"did"}),"userDid":l.string({"format":"did"})}),
+ l.jsonPayload({}),
+ ["CommunityNotFound","NotMember","CannotLeaveAsAdmin"]
+ )
+ ;
+
+export { main };
+
+export type $Params = l.InferMethodParams;
+export type $Input = l.InferMethodInput;
+export type $InputBody = l.InferMethodInputBody;
+export type $Output = l.InferMethodOutput;
+export type $OutputBody = l.InferMethodOutputBody;
+
+export const $lxm = main.nsid, $params = main.parameters, $input = main.input, $output = main.output;
diff --git a/src/lib/opensocial/generated/community/opensocial/leaveCommunity.ts b/src/lib/opensocial/generated/community/opensocial/leaveCommunity.ts
new file mode 100644
index 0000000..ac1416b
--- /dev/null
+++ b/src/lib/opensocial/generated/community/opensocial/leaveCommunity.ts
@@ -0,0 +1,6 @@
+/*
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
+ */
+
+export * from "./leaveCommunity.defs.js";
+export * as $defs from "./leaveCommunity.defs.js";
diff --git a/src/lib/opensocial/generated/community/opensocial/membership.defs.ts b/src/lib/opensocial/generated/community/opensocial/membership.defs.ts
new file mode 100644
index 0000000..3bb78d3
--- /dev/null
+++ b/src/lib/opensocial/generated/community/opensocial/membership.defs.ts
@@ -0,0 +1,27 @@
+/*
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
+ */
+
+import { l } from "@atproto/lex";
+
+const $nsid = "community.opensocial.membership";
+
+export { $nsid };
+
+/** User's membership in a community (stored in user's repo) */
+type Main = { $type: "community.opensocial.membership";
+
+ /**
+ * DID of the community
+ */
+ "community":l.DidString;"joinedAt":l.DatetimeString };
+
+export type { Main };
+
+/** User's membership in a community (stored in user's repo) */
+const main = l.record<"tid", Main>("tid", $nsid, l.object({"community":l.string({"format":"did"}),"joinedAt":l.string({"format":"datetime"})}));
+
+export { main };
+
+export const $isTypeOf = /*#__PURE__*/ main.isTypeOf.bind(main), $build = /*#__PURE__*/ main.build.bind(main), $type = /*#__PURE__*/ main.$type;
+export const $assert = /*#__PURE__*/ main.assert.bind(main), $check = /*#__PURE__*/ main.check.bind(main), $cast = /*#__PURE__*/ main.cast.bind(main), $ifMatches = /*#__PURE__*/ main.ifMatches.bind(main), $matches = /*#__PURE__*/ main.matches.bind(main), $parse = /*#__PURE__*/ main.parse.bind(main), $safeParse = /*#__PURE__*/ main.safeParse.bind(main), $validate = /*#__PURE__*/ main.validate.bind(main), $safeValidate = /*#__PURE__*/ main.safeValidate.bind(main);
diff --git a/src/lib/opensocial/generated/community/opensocial/membership.ts b/src/lib/opensocial/generated/community/opensocial/membership.ts
new file mode 100644
index 0000000..70e3ccb
--- /dev/null
+++ b/src/lib/opensocial/generated/community/opensocial/membership.ts
@@ -0,0 +1,6 @@
+/*
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
+ */
+
+export * from "./membership.defs.js";
+export * as $defs from "./membership.defs.js";
diff --git a/src/lib/opensocial/generated/index.ts b/src/lib/opensocial/generated/index.ts
new file mode 100644
index 0000000..d0d93a1
--- /dev/null
+++ b/src/lib/opensocial/generated/index.ts
@@ -0,0 +1,5 @@
+/*
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
+ */
+
+export * as community from "./community.js";
diff --git a/src/lib/opensocial/membership.ts b/src/lib/opensocial/membership.ts
new file mode 100644
index 0000000..680126d
--- /dev/null
+++ b/src/lib/opensocial/membership.ts
@@ -0,0 +1,210 @@
+import { TID } from "@atproto/common-web";
+import { XrpcError, asDatetimeString, isDidString } from "@atproto/lex";
+import { getLoggedInAgent } from "@fujocoded/authproto/helpers";
+
+import {
+ getPermissions as getPermissionsMethod,
+ joinCommunity as joinCommunityMethod,
+ leaveCommunity as leaveCommunityMethod,
+ membership as membershipSchema,
+} from "./generated/community/opensocial.js";
+import { createSignedLexClient } from "./xrpc.js";
+
+export const MEMBERSHIP_COLLECTION = "community.opensocial.membership";
+const DEFAULT_SERVICE = "https://api.opensocial.community";
+
+type LoggedInUser = NonNullable;
+type LoggedInAgent = NonNullable>>;
+type JoinCommunityBody = joinCommunityMethod.$defs.$InputBody;
+type JoinCommunityOutput = joinCommunityMethod.$defs.$OutputBody;
+
+interface MembershipState {
+ /** True when the user has any role in the community (member, admin, ...). */
+ isMember: boolean;
+ /** Raw roles returned by the appview, useful for admin/moderator UI. */
+ roles: string[];
+}
+
+interface StrongRef {
+ uri: string;
+ cid: string;
+}
+
+interface MembershipRecordInput {
+ loggedInUser: LoggedInUser;
+ communityDid: string;
+}
+
+export class OpenSocialCommunityError extends Error {
+ constructor(
+ public readonly status: number,
+ /** XRPC error name (e.g. "AlreadyMember") when the response carried one. */
+ public readonly code: string | null,
+ message: string,
+ ) {
+ super(message);
+ this.name = "OpenSocialCommunityError";
+ }
+}
+
+function createOpenSocialClient() {
+ const appId = import.meta.env.OPENSOCIAL_APP_ID;
+ if (!appId || appId.length === 0) {
+ throw new Error(
+ "OPENSOCIAL_APP_ID is not set; cannot sign opensocial requests",
+ );
+ }
+
+ return createSignedLexClient({
+ service: import.meta.env.OPENSOCIAL_SERVICE || DEFAULT_SERVICE,
+ appId,
+ });
+}
+
+function did(value: string, label: string) {
+ if (isDidString(value)) return value;
+ throw new Error(`${label} must be a valid DID`);
+}
+
+async function requireLoggedInAgent(
+ loggedInUser: LoggedInUser,
+): Promise {
+ const agent = await getLoggedInAgent(loggedInUser);
+ if (!agent) {
+ throw new Error("Not logged in");
+ }
+ return agent;
+}
+
+async function findMembershipRecord(
+ agent: LoggedInAgent,
+ { loggedInUser, communityDid }: MembershipRecordInput,
+): Promise {
+ let cursor: string | undefined;
+ do {
+ const response = await agent.com.atproto.repo.listRecords({
+ repo: loggedInUser.did,
+ collection: MEMBERSHIP_COLLECTION,
+ limit: 100,
+ cursor,
+ });
+
+ for (const record of response.data.records) {
+ const parsed = membershipSchema.$safeParse(record.value);
+ if (!parsed.success || parsed.value.community !== communityDid) continue;
+ return { uri: record.uri, cid: record.cid };
+ }
+
+ cursor = response.data.cursor;
+ } while (cursor);
+
+ return null;
+}
+
+/**
+ * Reads the caller-supplied user's roles in a community via getPermissions.
+ * An empty `roles` array means "not a member". Distinct from a pending-approval
+ * state, which the appview surfaces only when attempting to join.
+ */
+export async function getMembership(input: {
+ communityDid: string;
+ userDid: string;
+}): Promise {
+ const res = await xrpc(() =>
+ createOpenSocialClient().xrpc(getPermissionsMethod.main, {
+ params: {
+ communityDid: did(input.communityDid, "communityDid"),
+ userDid: did(input.userDid, "userDid"),
+ },
+ }),
+ );
+ const roles = res.body.userRoles;
+ return { isMember: roles.length > 0, roles };
+}
+
+export async function joinCommunity(
+ input: {
+ communityDid: string;
+ userDid: string;
+ membershipCid?: string;
+ },
+): Promise {
+ const body: JoinCommunityBody =
+ input.membershipCid === undefined
+ ? {
+ communityDid: did(input.communityDid, "communityDid"),
+ userDid: did(input.userDid, "userDid"),
+ }
+ : {
+ communityDid: did(input.communityDid, "communityDid"),
+ userDid: did(input.userDid, "userDid"),
+ membershipCid: input.membershipCid,
+ };
+
+ const res = await xrpc(() =>
+ createOpenSocialClient().xrpc(joinCommunityMethod.main, { body }),
+ );
+ return res.body;
+}
+
+export async function leaveCommunity(input: {
+ communityDid: string;
+ userDid: string;
+}): Promise {
+ await xrpc(() =>
+ createOpenSocialClient().xrpc(leaveCommunityMethod.main, {
+ body: {
+ communityDid: did(input.communityDid, "communityDid"),
+ userDid: did(input.userDid, "userDid"),
+ },
+ }),
+ );
+}
+
+export async function ensureUserMembershipRecord(
+ input: MembershipRecordInput,
+): Promise {
+ const { loggedInUser, communityDid } = input;
+ const agent = await requireLoggedInAgent(loggedInUser);
+ const existing = await findMembershipRecord(agent, input);
+ if (existing) return existing;
+
+ const record = membershipSchema.$build({
+ community: did(communityDid, "communityDid"),
+ joinedAt: asDatetimeString(new Date().toISOString()),
+ });
+
+ const response = await agent.com.atproto.repo.putRecord({
+ repo: loggedInUser.did,
+ collection: MEMBERSHIP_COLLECTION,
+ rkey: TID.nextStr(),
+ record,
+ validate: false,
+ });
+
+ return {
+ uri: response.data.uri,
+ cid: response.data.cid,
+ };
+}
+
+async function xrpc(call: () => Promise): Promise {
+ try {
+ return await call();
+ } catch (err) {
+ throw toOpenSocialCommunityError(err);
+ }
+}
+
+function toOpenSocialCommunityError(err: unknown): OpenSocialCommunityError {
+ if (err instanceof OpenSocialCommunityError) return err;
+ if (err instanceof XrpcError) {
+ const downstream = err.toDownstreamError();
+ return new OpenSocialCommunityError(
+ downstream.status,
+ downstream.body.error,
+ downstream.body.message || downstream.body.error,
+ );
+ }
+ throw err;
+}
diff --git a/src/middleware.ts b/src/middleware.ts
index 01fe20b..45c0260 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,13 +1,46 @@
import { defineMiddleware } from "astro:middleware";
+const LOGOUT_PATH = "/oauth/logout";
+
+function isAlreadyDeletedSessionError(error: unknown): boolean {
+ return (
+ error instanceof Error &&
+ error.message.includes("The session was deleted by another process")
+ );
+}
+
// Keep dev traffic on 127.0.0.1: ATProto OAuth callbacks always come back on
// 127.0.0.1, so a stray localhost session ends up with cookies on a different
// origin than the post-login session — login appears to silently fail.
-export const onRequest = defineMiddleware((context, next) => {
+export const onRequest = defineMiddleware(async (context, next) => {
if (import.meta.env.DEV && context.url.hostname === "localhost") {
const url = new URL(context.url);
url.hostname = "127.0.0.1";
return context.redirect(url.toString(), 307);
}
+
+ if (
+ context.request.method === "POST" &&
+ context.url.pathname === LOGOUT_PATH
+ ) {
+ const userDid = await context.session?.get("atproto-did");
+ if (!context.session || !userDid) return context.redirect("/");
+
+ context.session.delete("atproto-did");
+
+ try {
+ await context.locals.loggedInClient?.signOut();
+ } catch (error) {
+ if (!isAlreadyDeletedSessionError(error)) {
+ console.warn(
+ "[auth] failed to revoke OAuth session during logout",
+ error,
+ );
+ }
+ }
+
+ return context.redirect("/");
+ }
+
return next();
});
diff --git a/src/pages/community-content.astro b/src/pages/community-content.astro
index e3fdc61..ac1137e 100644
--- a/src/pages/community-content.astro
+++ b/src/pages/community-content.astro
@@ -6,11 +6,79 @@ import Header from '../components/layout/Header.astro';
import Footer from '../components/layout/Footer.astro';
import PostCard from '../components/content/PostCard.astro';
import { getLiveCollection } from 'astro:content';
+import { actions } from 'astro:actions';
+import { getMembership } from '../lib/opensocial/membership';
+import { getAtmosphereCommunityDid } from '../lib/community/atmosphere';
+
+const joinResult = Astro.getActionResult(actions.joinAtmosphereCommunity);
+if (joinResult?.data?.redirectUrl) {
+ return Astro.redirect(joinResult.data.redirectUrl, 303);
+}
+if (joinResult?.error) {
+ return Astro.redirect('/community-content?join=error', 303);
+}
+
+const leaveResult = Astro.getActionResult(actions.leaveAtmosphereCommunity);
+if (leaveResult?.data?.redirectUrl) {
+ return Astro.redirect(leaveResult.data.redirectUrl, 303);
+}
+if (leaveResult?.error) {
+ return Astro.redirect('/community-content?join=error', 303);
+}
const feedResult = await getLiveCollection('feed');
const mergedPosts = (feedResult.entries ?? [])
.map((entry) => entry.data)
.sort((a, b) => (b.publishedAt ?? b.sharedAt).getTime() - (a.publishedAt ?? a.sharedAt).getTime());
+
+const loggedInUser = Astro.locals.loggedInUser;
+
+// Resolve membership for the signed-in viewer so we can swap the join CTA out
+// once they're in. Failures here shouldn't take down the page — treat them as
+// "unknown" and fall back to showing the join button.
+let isMember = false;
+if (loggedInUser) {
+ try {
+ const communityDid = await getAtmosphereCommunityDid();
+ const membership = await getMembership({
+ communityDid,
+ userDid: loggedInUser.did,
+ });
+ isMember = membership.isMember;
+ } catch (err) {
+ console.warn('[community-content] membership lookup failed', err);
+ }
+}
+
+type JoinStatusKey =
+ | 'ok'
+ | 'pending'
+ | 'already'
+ | 'missing'
+ | 'error'
+ | 'signin'
+ | 'permission'
+ | 'left'
+ | 'not-member'
+ | 'admin-block';
+const JOIN_STATUS_COPY: Record = {
+ ok: { tone: 'success', message: "You're in — welcome to the community." },
+ pending: { tone: 'info', message: 'Request submitted — an admin will review it shortly.' },
+ already: { tone: 'info', message: "You're already a member." },
+ missing: { tone: 'error', message: "Couldn't find the community right now. Try again later." },
+ error: { tone: 'error', message: "Something went wrong. Try again later." },
+ signin: { tone: 'error', message: 'Sign in first, then try again.' },
+ permission: { tone: 'error', message: 'Your login needs community membership permission. Log out and back in, then try again.' },
+ left: { tone: 'info', message: "You've left the community." },
+ 'not-member': { tone: 'info', message: "You weren't a member of the community." },
+ 'admin-block': { tone: 'error', message: "Admins can't leave the community. Hand off the role first." },
+};
+
+const joinParam = Astro.url.searchParams.get('join');
+const joinStatus =
+ joinParam && joinParam in JOIN_STATUS_COPY
+ ? JOIN_STATUS_COPY[joinParam as JoinStatusKey]
+ : null;
---
@@ -33,6 +101,54 @@ const mergedPosts = (feedResult.entries ?? [])
>.
+ {joinStatus && (
+
+ {joinStatus.message}
+
+ )}
+
+ {!loggedInUser ? (
+
+ ) : isMember ? (
+
+ ) : (
+
+ )}
+
{mergedPosts.length > 0 ? (
{mergedPosts.map((post) =>
)}
@@ -52,6 +168,18 @@ const mergedPosts = (feedResult.entries ?? [])
+
+
--
2.51.2