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 ?? [])