diff --git a/app/config.ts b/app/config.ts index 7912577..d5ff02e 100644 --- a/app/config.ts +++ b/app/config.ts @@ -3,10 +3,21 @@ import type { ContrailConfig } from "../src/index"; export const config: ContrailConfig = { namespace: "rsvp.atmo", jetstreams: ["wss://jetstream1.us-east.bsky.network"], - // spaces: { - // type: "tools.atmo.event.space", - // serviceDid: "did:web:rsvp.atmo", - // }, + spaces: { + type: "tools.atmo.event.space", + serviceDid: "did:web:rsvp.atmo", + }, + community: { + // 32-byte master key for envelope-encrypting stored credentials (app + // passwords + minted signing/rotation keys). Provide via env: hex or + // base64 encoded. For Cloudflare Workers, wire from env.COMMUNITY_MASTER_KEY + // via a config factory pattern. The placeholder below is fine for `pnpm + // generate` (which never instantiates the cipher) but must be replaced + // before starting the server. + masterKey: + (typeof process !== "undefined" ? process.env.COMMUNITY_MASTER_KEY : undefined) ?? + "placeholder-set-me-before-running-server-not-at-build-time", + }, collections: { event: { collection: "community.lexicon.calendar.event", diff --git a/community-lexicon-templates/adopt.json b/community-lexicon-templates/adopt.json new file mode 100644 index 0000000..1a9441a --- /dev/null +++ b/community-lexicon-templates/adopt.json @@ -0,0 +1,36 @@ +{ + "lexicon": 1, + "id": "tools.atmo.community.adopt", + "defs": { + "main": { + "type": "procedure", + "description": "Adopt an existing ATProto account as a community identity. The provided app password is verified by creating a session, then stored encrypted. Creates the reserved `$admin` and `$publishers` spaces with the caller as `owner` in both.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["identifier", "appPassword"], + "properties": { + "identifier": { "type": "string", "description": "Handle or DID of the account to adopt." }, + "appPassword": { "type": "string", "description": "App password for the account; verified by createSession and stored encrypted." } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["communityDid"], + "properties": { + "communityDid": { "type": "string", "format": "did" } + } + } + }, + "errors": [ + { "name": "InvalidRequest" }, + { "name": "Unauthorized" }, + { "name": "AlreadyExists" } + ] + } + } +} diff --git a/community-lexicon-templates/defs.json b/community-lexicon-templates/defs.json new file mode 100644 index 0000000..680d394 --- /dev/null +++ b/community-lexicon-templates/defs.json @@ -0,0 +1,60 @@ +{ + "lexicon": 1, + "id": "tools.atmo.community.defs", + "description": "Shared types for community-module XRPC methods.", + "defs": { + "accessLevel": { + "type": "string", + "knownValues": ["member", "manager", "admin", "owner"], + "description": "Module-internal access levels that govern community operations on a given space. Ordered: member < manager < admin < owner." + }, + "communityView": { + "type": "object", + "required": ["did", "mode", "createdAt"], + "properties": { + "did": { "type": "string", "format": "did" }, + "mode": { "type": "string", "knownValues": ["adopt", "mint"] }, + "identifier": { "type": "string", "description": "Handle or DID at adoption time (adopt only)." }, + "createdAt": { "type": "integer" } + } + }, + "spaceView": { + "type": "object", + "required": ["uri", "ownerDid", "type", "key", "serviceDid", "createdAt"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "ownerDid": { "type": "string", "format": "did" }, + "type": { "type": "string", "format": "nsid" }, + "key": { "type": "string" }, + "serviceDid": { "type": "string" }, + "createdAt": { "type": "integer" } + } + }, + "subject": { + "type": "object", + "description": "Exactly one of `did` or `spaceUri` must be set.", + "properties": { + "did": { "type": "string", "format": "did" }, + "spaceUri": { "type": "string", "format": "at-uri" } + } + }, + "memberRow": { + "type": "object", + "required": ["subject", "accessLevel", "grantedBy", "grantedAt"], + "properties": { + "subject": { "type": "ref", "ref": "#subject" }, + "accessLevel": { "type": "ref", "ref": "#accessLevel" }, + "grantedBy": { "type": "string", "format": "did" }, + "grantedAt": { "type": "integer" } + } + }, + "flatMember": { + "type": "object", + "required": ["did", "addedAt"], + "properties": { + "did": { "type": "string", "format": "did" }, + "addedAt": { "type": "integer" } + } + } + } +} diff --git a/community-lexicon-templates/delete.json b/community-lexicon-templates/delete.json new file mode 100644 index 0000000..dffb31c --- /dev/null +++ b/community-lexicon-templates/delete.json @@ -0,0 +1,32 @@ +{ + "lexicon": 1, + "id": "tools.atmo.community.delete", + "defs": { + "main": { + "type": "procedure", + "description": "Soft-delete a community. Also soft-deletes all spaces owned by the community. Caller must have `owner` in the community's `$admin` space.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["communityDid"], + "properties": { + "communityDid": { "type": "string", "format": "did" } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["ok"], + "properties": { "ok": { "type": "boolean" } } + } + }, + "errors": [ + { "name": "NotFound" }, + { "name": "Forbidden" } + ] + } + } +} diff --git a/community-lexicon-templates/deleteRecord.json b/community-lexicon-templates/deleteRecord.json new file mode 100644 index 0000000..cd10cad --- /dev/null +++ b/community-lexicon-templates/deleteRecord.json @@ -0,0 +1,37 @@ +{ + "lexicon": 1, + "id": "tools.atmo.community.deleteRecord", + "defs": { + "main": { + "type": "procedure", + "description": "Delete a public record authored by the community from the community's PDS. Adopted communities only. Caller must be `member` or higher in the community's `$publishers` space.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["communityDid", "collection", "rkey"], + "properties": { + "communityDid": { "type": "string", "format": "did" }, + "collection": { "type": "string", "format": "nsid" }, + "rkey": { "type": "string" } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["ok"], + "properties": { "ok": { "type": "boolean" } } + } + }, + "errors": [ + { "name": "InvalidRequest" }, + { "name": "NotFound" }, + { "name": "Forbidden" }, + { "name": "NotSupported" }, + { "name": "UpstreamFailure" } + ] + } + } +} diff --git a/community-lexicon-templates/getHealth.json b/community-lexicon-templates/getHealth.json new file mode 100644 index 0000000..4994700 --- /dev/null +++ b/community-lexicon-templates/getHealth.json @@ -0,0 +1,34 @@ +{ + "lexicon": 1, + "id": "tools.atmo.community.getHealth", + "defs": { + "main": { + "type": "query", + "description": "Check whether stored credentials for the community are still usable. Adopted: attempts a live session creation. Minted: verifies the signing key can be decrypted. Caller must have at least `member` in the community's `$admin` space.", + "parameters": { + "type": "params", + "required": ["communityDid"], + "properties": { + "communityDid": { "type": "string", "format": "did" } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["status"], + "properties": { + "status": { + "type": "string", + "knownValues": ["healthy", "degraded", "expired"] + } + } + } + }, + "errors": [ + { "name": "NotFound" }, + { "name": "Forbidden" } + ] + } + } +} diff --git a/community-lexicon-templates/list.json b/community-lexicon-templates/list.json new file mode 100644 index 0000000..abff4f0 --- /dev/null +++ b/community-lexicon-templates/list.json @@ -0,0 +1,29 @@ +{ + "lexicon": 1, + "id": "tools.atmo.community.list", + "defs": { + "main": { + "type": "query", + "description": "List communities where the actor has any access level in any community-owned space. Defaults to the JWT issuer when `actor` is omitted.", + "parameters": { + "type": "params", + "properties": { + "actor": { "type": "string", "format": "did", "description": "DID to query (defaults to the JWT issuer)." } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["communities"], + "properties": { + "communities": { + "type": "array", + "items": { "type": "ref", "ref": "tools.atmo.community.defs#communityView" } + } + } + } + } + } + } +} diff --git a/community-lexicon-templates/mint.json b/community-lexicon-templates/mint.json new file mode 100644 index 0000000..303b703 --- /dev/null +++ b/community-lexicon-templates/mint.json @@ -0,0 +1,34 @@ +{ + "lexicon": 1, + "id": "tools.atmo.community.mint", + "defs": { + "main": { + "type": "procedure", + "description": "Mint a fresh did:plc for a new community. Contrail generates three P-256 keypairs (signing, contrail rotation, creator rotation), submits a genesis op to the PLC directory, and stores the signing + contrail-rotation keys encrypted. The creator's rotation key is returned once as a recovery secret and never stored.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "handle": { "type": "string", "description": "Optional handle to include in alsoKnownAs (as at://)." }, + "pdsEndpoint": { "type": "string", "description": "Optional PDS endpoint to include as the atproto_pds service." } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["communityDid", "recoveryKey"], + "properties": { + "communityDid": { "type": "string", "format": "did" }, + "recoveryKey": { "type": "unknown", "description": "Creator's rotation key as a private JWK. Shown once — cannot be retrieved later." } + } + } + }, + "errors": [ + { "name": "UpstreamFailure" } + ] + } + } +} diff --git a/community-lexicon-templates/putRecord.json b/community-lexicon-templates/putRecord.json new file mode 100644 index 0000000..4f8ae01 --- /dev/null +++ b/community-lexicon-templates/putRecord.json @@ -0,0 +1,41 @@ +{ + "lexicon": 1, + "id": "tools.atmo.community.putRecord", + "defs": { + "main": { + "type": "procedure", + "description": "Publish a public record authored by the community via the community's PDS. Adopted communities only. Caller must be `member` or higher in the community's `$publishers` space.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["communityDid", "collection", "record"], + "properties": { + "communityDid": { "type": "string", "format": "did" }, + "collection": { "type": "string", "format": "nsid" }, + "rkey": { "type": "string" }, + "record": { "type": "unknown" }, + "validate": { "type": "boolean" } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" } + } + } + }, + "errors": [ + { "name": "InvalidRequest" }, + { "name": "NotFound" }, + { "name": "Forbidden" }, + { "name": "NotSupported" }, + { "name": "UpstreamFailure" } + ] + } + } +} diff --git a/community-lexicon-templates/reauth.json b/community-lexicon-templates/reauth.json new file mode 100644 index 0000000..bc5d441 --- /dev/null +++ b/community-lexicon-templates/reauth.json @@ -0,0 +1,36 @@ +{ + "lexicon": 1, + "id": "tools.atmo.community.reauth", + "defs": { + "main": { + "type": "procedure", + "description": "Replace the stored app password for an adopted community. Caller must have `owner` access in the community's `$admin` space.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["communityDid", "appPassword"], + "properties": { + "communityDid": { "type": "string", "format": "did" }, + "appPassword": { "type": "string" } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["ok"], + "properties": { "ok": { "type": "boolean" } } + } + }, + "errors": [ + { "name": "InvalidRequest" }, + { "name": "NotFound" }, + { "name": "Forbidden" }, + { "name": "Unauthorized" }, + { "name": "NotSupported" } + ] + } + } +} diff --git a/community-lexicon-templates/space/create.json b/community-lexicon-templates/space/create.json new file mode 100644 index 0000000..1210cea --- /dev/null +++ b/community-lexicon-templates/space/create.json @@ -0,0 +1,37 @@ +{ + "lexicon": 1, + "id": "tools.atmo.community.space.create", + "defs": { + "main": { + "type": "procedure", + "description": "Create a community-owned space (group/role/channel). Caller needs `admin` or higher in the community's `$admin` space. Reserved keys (`$admin`, `$publishers`, …) are rejected here.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["communityDid"], + "properties": { + "communityDid": { "type": "string", "format": "did" }, + "key": { "type": "string", "description": "Space key. Auto-generated if omitted." } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["space"], + "properties": { + "space": { "type": "ref", "ref": "tools.atmo.community.defs#spaceView" } + } + } + }, + "errors": [ + { "name": "InvalidRequest" }, + { "name": "NotFound" }, + { "name": "Forbidden" }, + { "name": "AlreadyExists" } + ] + } + } +} diff --git a/community-lexicon-templates/space/delete.json b/community-lexicon-templates/space/delete.json new file mode 100644 index 0000000..6adc5f7 --- /dev/null +++ b/community-lexicon-templates/space/delete.json @@ -0,0 +1,32 @@ +{ + "lexicon": 1, + "id": "tools.atmo.community.space.delete", + "defs": { + "main": { + "type": "procedure", + "description": "Soft-delete a community-owned space. Caller must be `owner` on the space, or `admin`+ in the community's `$admin` space. Reserved spaces cannot be deleted.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["spaceUri"], + "properties": { + "spaceUri": { "type": "string", "format": "at-uri" } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["ok"], + "properties": { "ok": { "type": "boolean" } } + } + }, + "errors": [ + { "name": "NotFound" }, + { "name": "Forbidden" } + ] + } + } +} diff --git a/community-lexicon-templates/space/deleteRecord.json b/community-lexicon-templates/space/deleteRecord.json new file mode 100644 index 0000000..5f6da1f --- /dev/null +++ b/community-lexicon-templates/space/deleteRecord.json @@ -0,0 +1,35 @@ +{ + "lexicon": 1, + "id": "tools.atmo.community.space.deleteRecord", + "defs": { + "main": { + "type": "procedure", + "description": "Delete an in-space record authored by the community DID. Caller must be `admin` or higher in the target space.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["spaceUri", "collection", "rkey"], + "properties": { + "spaceUri": { "type": "string", "format": "at-uri" }, + "collection": { "type": "string", "format": "nsid" }, + "rkey": { "type": "string" } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["ok"], + "properties": { "ok": { "type": "boolean" } } + } + }, + "errors": [ + { "name": "InvalidRequest" }, + { "name": "NotFound" }, + { "name": "Forbidden" } + ] + } + } +} diff --git a/community-lexicon-templates/space/grant.json b/community-lexicon-templates/space/grant.json new file mode 100644 index 0000000..b8d1ffa --- /dev/null +++ b/community-lexicon-templates/space/grant.json @@ -0,0 +1,35 @@ +{ + "lexicon": 1, + "id": "tools.atmo.community.space.grant", + "defs": { + "main": { + "type": "procedure", + "description": "Grant or upsert a subject's access level on a space. Caller needs `manager` or higher; the granted level cannot exceed the caller's own. A subject can be a DID (member) or another space (delegated membership).", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["spaceUri", "subject", "accessLevel"], + "properties": { + "spaceUri": { "type": "string", "format": "at-uri" }, + "subject": { "type": "ref", "ref": "tools.atmo.community.defs#subject" }, + "accessLevel": { "type": "ref", "ref": "tools.atmo.community.defs#accessLevel" } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["ok"], + "properties": { "ok": { "type": "boolean" } } + } + }, + "errors": [ + { "name": "InvalidRequest" }, + { "name": "NotFound" }, + { "name": "Forbidden" } + ] + } + } +} diff --git a/community-lexicon-templates/space/listMembers.json b/community-lexicon-templates/space/listMembers.json new file mode 100644 index 0000000..26132e2 --- /dev/null +++ b/community-lexicon-templates/space/listMembers.json @@ -0,0 +1,40 @@ +{ + "lexicon": 1, + "id": "tools.atmo.community.space.listMembers", + "defs": { + "main": { + "type": "query", + "description": "List members of a community-owned space. By default returns the raw access-level rows; with `flatten=true` returns the flattened DID list (delegated memberships resolved).", + "parameters": { + "type": "params", + "required": ["spaceUri"], + "properties": { + "spaceUri": { "type": "string", "format": "at-uri" }, + "flatten": { "type": "boolean", "description": "If true, return the flat DID list instead of raw rows." } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { "type": "ref", "ref": "tools.atmo.community.defs#memberRow" }, + "description": "Present when flatten=false." + }, + "members": { + "type": "array", + "items": { "type": "ref", "ref": "tools.atmo.community.defs#flatMember" }, + "description": "Present when flatten=true." + } + } + } + }, + "errors": [ + { "name": "NotFound" }, + { "name": "Forbidden" } + ] + } + } +} diff --git a/community-lexicon-templates/space/putRecord.json b/community-lexicon-templates/space/putRecord.json new file mode 100644 index 0000000..aa2eb35 --- /dev/null +++ b/community-lexicon-templates/space/putRecord.json @@ -0,0 +1,40 @@ +{ + "lexicon": 1, + "id": "tools.atmo.community.space.putRecord", + "defs": { + "main": { + "type": "procedure", + "description": "Write an in-space record authored by the community DID (rather than the caller). Caller must be `admin` or higher in the target space.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["spaceUri", "collection", "record"], + "properties": { + "spaceUri": { "type": "string", "format": "at-uri" }, + "collection": { "type": "string", "format": "nsid" }, + "rkey": { "type": "string" }, + "record": { "type": "unknown" } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["rkey", "authorDid", "createdAt"], + "properties": { + "rkey": { "type": "string" }, + "authorDid": { "type": "string", "format": "did" }, + "createdAt": { "type": "integer" } + } + } + }, + "errors": [ + { "name": "InvalidRequest" }, + { "name": "NotFound" }, + { "name": "Forbidden" } + ] + } + } +} diff --git a/community-lexicon-templates/space/resync.json b/community-lexicon-templates/space/resync.json new file mode 100644 index 0000000..cf23c72 --- /dev/null +++ b/community-lexicon-templates/space/resync.json @@ -0,0 +1,32 @@ +{ + "lexicon": 1, + "id": "tools.atmo.community.space.resync", + "defs": { + "main": { + "type": "procedure", + "description": "Manually recompute `spaces_members` for a community-owned space. Useful after a crash mid-reconcile leaves stale membership. Caller must have `admin` or higher.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["spaceUri"], + "properties": { + "spaceUri": { "type": "string", "format": "at-uri" } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["ok"], + "properties": { "ok": { "type": "boolean" } } + } + }, + "errors": [ + { "name": "NotFound" }, + { "name": "Forbidden" } + ] + } + } +} diff --git a/community-lexicon-templates/space/revoke.json b/community-lexicon-templates/space/revoke.json new file mode 100644 index 0000000..e804ad3 --- /dev/null +++ b/community-lexicon-templates/space/revoke.json @@ -0,0 +1,34 @@ +{ + "lexicon": 1, + "id": "tools.atmo.community.space.revoke", + "defs": { + "main": { + "type": "procedure", + "description": "Remove a subject's access grant from a space. Caller must have `manager` or higher and outrank the subject's current level.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["spaceUri", "subject"], + "properties": { + "spaceUri": { "type": "string", "format": "at-uri" }, + "subject": { "type": "ref", "ref": "tools.atmo.community.defs#subject" } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["ok"], + "properties": { "ok": { "type": "boolean" } } + } + }, + "errors": [ + { "name": "InvalidRequest" }, + { "name": "NotFound" }, + { "name": "Forbidden" } + ] + } + } +} diff --git a/community-lexicon-templates/space/setAccessLevel.json b/community-lexicon-templates/space/setAccessLevel.json new file mode 100644 index 0000000..090d096 --- /dev/null +++ b/community-lexicon-templates/space/setAccessLevel.json @@ -0,0 +1,35 @@ +{ + "lexicon": 1, + "id": "tools.atmo.community.space.setAccessLevel", + "defs": { + "main": { + "type": "procedure", + "description": "Change the access level of an existing grant. Caller must outrank both the old and new level.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["spaceUri", "subject", "accessLevel"], + "properties": { + "spaceUri": { "type": "string", "format": "at-uri" }, + "subject": { "type": "ref", "ref": "tools.atmo.community.defs#subject" }, + "accessLevel": { "type": "ref", "ref": "tools.atmo.community.defs#accessLevel" } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["ok"], + "properties": { "ok": { "type": "boolean" } } + } + }, + "errors": [ + { "name": "InvalidRequest" }, + { "name": "NotFound" }, + { "name": "Forbidden" } + ] + } + } +} diff --git a/community-lexicon-templates/whoami.json b/community-lexicon-templates/whoami.json new file mode 100644 index 0000000..bf5e847 --- /dev/null +++ b/community-lexicon-templates/whoami.json @@ -0,0 +1,28 @@ +{ + "lexicon": 1, + "id": "tools.atmo.community.whoami", + "defs": { + "main": { + "type": "query", + "description": "Report the JWT issuer's effective access level in a space (resolving delegated memberships).", + "parameters": { + "type": "params", + "required": ["spaceUri"], + "properties": { + "spaceUri": { "type": "string", "format": "at-uri" } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["spaceUri"], + "properties": { + "spaceUri": { "type": "string", "format": "at-uri" }, + "accessLevel": { "type": "ref", "ref": "tools.atmo.community.defs#accessLevel", "description": "Null when caller has no resolvable access." } + } + } + } + } + } +} diff --git a/docs/community-spec-mapping.md b/docs/community-spec-mapping.md new file mode 100644 index 0000000..2b1fd41 --- /dev/null +++ b/docs/community-spec-mapping.md @@ -0,0 +1,135 @@ +# Communities: mapping to the Arbiter design post + +This is the map between contrail's community module and the Arbiter design +sketched at (zicklag / Roomy, +April 2026). The post is an early design note and will likely evolve — so +will this doc. The goal is to make it obvious, when the standard firms up, +where contrail already lines up and where it needs to change. + +Contrail's community module layers community-owned spaces and tiered +access-level management on top of the [spaces](./spaces-spec-mapping.md) +module. Ownership alone — spaces owned by a community DID — is the signal +that a space is community-managed. All the Arbiter's "group management" +complexity lives here; the spaces module stays close to the rough +permissioned-data spec with no knowledge of access levels or delegation. + +--- + +## Concept-by-concept alignment + +| Arbiter concept | Contrail | Alignment | Notes | +| ------------------------------------------- | ------------------------------------------------------------------- | --------- | -------------------------------------------------------------------------------------------------------- | +| Community as a DID | `communities.did` | ✅ | 1:1 | +| Mint a fresh did:plc for a community | `community.mint` → P-256 keypairs + genesis op + plc.directory POST | ✅ | Post uses secp256k1; we use P-256 (both spec-valid, we avoid the dep) | +| Adopt an existing account | `community.adopt` (app password) | ➕ | Not in the post; contrail addition. See [community.md](./community.md) for the rationale | +| Creator-held rotation key (recovery) | Returned once by `community.mint` as `recoveryKey` | ✅ | Never stored; caller must save it | +| Groups-are-spaces | Community-owned rows in `spaces` | ✅ | No separate groups table; every group is a space | +| `$admin` reserved space | Auto-created on community creation | ✅ | Keyed by the literal string `$admin`; cannot be deleted | +| `$publishers` reserved space | Auto-created on community creation | ➕ | Not in the post; contrail extension for the "publish public records as the community" capability | +| Delegated membership (space → space) | `community_access_levels.subject_kind = 'space'` | ✅ | Recursive resolution with cycle guard + depth cap | +| Access-level ladder | `member` / `manager` / `admin` / `owner` | ⚠️ | 4 levels vs the post's 8. See [community.md § Relationship to the Arbiter post](./community.md) | +| Read-Member-List (pre-member tier) | _none_ | ❌ | Post's level 1; skipped in v1 | +| Add-Members vs Remove-Members split | Bundled into `manager` | ⚠️ | Post treats them separately | +| Configure-Space | `admin` in that space | ✅ | Post's level 5 | +| Create-Spaces in `$admin` | `admin` in `$admin` | ✅ | Post's level 6 | +| Remove-Space | `owner` in target space OR `admin` in `$admin` | ✅ | Post's level 7 | +| Owner | `owner`; only meaningful in `$admin` for owner-management | ✅ | Post's level 8 | +| Push model for membership lists | Reconciler writes `spaces_members` after each change | ➕ | Post doesn't specify a sync direction; push keeps spaces read-path zero-overhead | +| Cross-community / cross-arbiter delegation | _same-contrail only_ | ❌ | v1 constraint. Private-membership federation is deferred — see [community.md § Deferred work] | +| Invites as a separate service | _not implemented_ | ❌ | Post proposes an optional add-on; contrail has spaces invites to draw from when this lands | +| Writing records under the arbiter's account | `community.space.putRecord` (in-space) + `community.putRecord` (public) | ✅ | In-space: `admin+`. Public: `member+` in `$publishers` — routes through adopted community's PDS | +| Public membership-list flag | _none_ | ❌ | Post allows spaces to expose membership publicly. Ruled out for cross-instance federation (privacy) | +| Space credential (from arbiter) | _none_ | ⚠️ | Covered by the spaces module's service-auth story, not the community module | + +Legend: ✅ aligned · ⚠️ partial / different granularity · ❌ missing · ➕ contrail extension (not in the post) + +--- + +## XRPC surface + +All endpoints emitted under `.community.*` from templates +in `community-lexicon-templates/`. Distinct from `.space.*` so the +user-managed spaces surface stays clean. Community-managed spaces live +under `.community.space.*` so the endpoint name reveals whether +membership is user-controlled or community-controlled. + +### Community lifecycle +- `community.adopt` · `community.mint` · `community.reauth` · `community.delete` +- `community.list` · `community.getHealth` · `community.whoami` + +### Space (group / role / channel) lifecycle +- `community.space.create` · `community.space.delete` + +### Membership +- `community.space.grant` · `community.space.revoke` · `community.space.setAccessLevel` +- `community.space.listMembers` (`?flatten=true` for the resolved DID list) · `community.space.resync` + +### Publishing +- `community.putRecord` · `community.deleteRecord` (public records via the community's PDS; adopted only) +- `community.space.putRecord` · `community.space.deleteRecord` (in-space records authored by the community DID) + +--- + +## Contrail extensions over the post + +Labelled ➕ in the alignment table, worth calling out explicitly so +future renames against a standard are easy: + +- **`adopt` mode** for communities backed by a regular ATProto account. + App passwords are the default because they don't have the periodic + re-auth ceremony OAuth does, which matters for machine-operated + community accounts. +- **`$publishers` reserved space.** The post doesn't model "publish public + records as the community" — we gate that capability via a second + reserved space so it's orthogonal to `$admin` governance without + widening the access ladder. +- **Push-based reconciliation into `spaces_members`.** The post doesn't + specify sync direction; push keeps the spaces read path O(1) and lets + the spaces module stay oblivious to delegation. + +--- + +## Migration readiness + +Hasn't shipped as a standard yet. When it does, likely churn areas: + +1. **Level renames / additions.** Our 4 are a coalesce of the post's 8. + If a standard lands with finer tiers, add rows to the enum and + migrate existing values — `community_access_levels.access_level` is + a plain text column. +2. **Cross-instance delegation.** Our v1 is local-only. When private + cross-contrail federation is figured out (encrypted membership + records, authenticated pull, something else), the `subject_space_uri` + field already supports remote URIs structurally — only the + resolution path needs to change. +3. **Invites.** When the post's invite service firms up, reuse the + spaces invite primitive or fork it. Adding an invite service as a + member of `$admin` with `manager` access is the model; no schema + change anticipated. +4. **Publishing for minted communities.** Currently returns + `NotSupported`. When contrail-as-PDS or PDS-less minted publishing + is defined, drop the check. + +--- + +## Design decisions worth preserving + +- **Spaces doesn't know the community module exists.** Dependency is + one-way: community writes to spaces tables; the spaces module never + imports from `src/core/community/`. Preserve this — it's what keeps + the spaces surface close to the permissioned-data spec. +- **Ownership is the signal.** Community-owned = owner DID is in + `communities`. No flag columns on `spaces`. Preserve this — adding a + delegation flag re-introduces a schema hook we deliberately dropped. +- **Access levels govern arbiter operations only.** Never leak them + into record-level ACLs or app-level role systems. App roles belong + as records, not as levels. +- **Orthogonal capabilities go in reserved `$`-spaces, not new + ladder rungs.** `$publishers` is the first; `$moderators`, + `$billing`, etc. can follow. This keeps the ladder small and + extensions additive. +- **Push, not pull, for `spaces_members`.** Read-path overhead has to + stay zero; complexity belongs in the writer. +- **Single-instance is a feature, not a gap.** Federation is the + hard problem; defer cleanly with a local-only assertion at grant + time until private-distribution shape is clear. diff --git a/docs/spaces-spec-mapping.md b/docs/spaces-spec-mapping.md index 890f193..36c9ccf 100644 --- a/docs/spaces-spec-mapping.md +++ b/docs/spaces-spec-mapping.md @@ -21,7 +21,7 @@ flow is shipped (same story we already have for public records via jetstream). | Space type (NSID) | `spaces.type` | ✅ | 1:1 | | Space key / skey | `spaces.key` | ✅ | TID-generated when caller omits it | | Record addressing 6-tuple | `(owner, type, key, author-did, collection, rkey)` | ✅ | Storage is keyed by `(space_uri, did, rkey)`; `space_uri` encodes the first 3 | -| Single ACL = member list | `spaces_members (did, perms)` | ✅ | Only `read`/`write` perms; owner is implicit write | +| Single ACL = member list | `spaces_members (did)` | ✅ | Membership is binary: you're in or you're out. Owner is implicit member. No read/write tiering — apps filter writes themselves | | Space credential (2–4h token) | _none; service-auth JWTs used directly_ | ❌ | Fine while contrail is a single appview. Add a shim when real PDS sync lands | | App allow/deny | `appPolicy {mode, apps[]}` | ✅ | Matches spec's default-allow / default-deny model. Visible only to the owner | | Permissioned repo per user | single DB (`spaces_records_`) | ⚠️ | Structurally compatible — keyed per `(space, author)`. Federation is future | @@ -99,6 +99,17 @@ the real spec lands: - Keep the member list as the single ACL. Don't add roles or per-collection policies just because it's easy — the spec is emphatic that the member list is _the_ ACL. +- Membership is binary, not tiered. Previously had `perms: "read" | "write"` + per member row; collapsed to plain membership because the rough spec is + moving toward "member = access, apps filter writes." Delete keeps the + owner / own-record rule, but that's about *which records you can affect*, + not a permission tier on the member row. +- Don't over-engineer the space row with pre-emptive extension columns. + Previously had `member_list_ref` as a hook for externally-managed + membership; dropped because the community-module case is handled via + ownership (community-owned spaces are managed by the community module, no + flag column needed). If a future need for external membership sources + shows up, add the column then. - Keep `space.whoami`, `space.leaveSpace`, and the invite endpoints clearly labeled as contrail extras in docs. If the spec ends up naming some of them, renaming is cheap; relying on them from the base spec isn't. diff --git a/lexicons-generated/rsvp/atmo/community/adopt.json b/lexicons-generated/rsvp/atmo/community/adopt.json new file mode 100644 index 0000000..9351b1e --- /dev/null +++ b/lexicons-generated/rsvp/atmo/community/adopt.json @@ -0,0 +1,56 @@ +{ + "lexicon": 1, + "id": "rsvp.atmo.community.adopt", + "defs": { + "main": { + "type": "procedure", + "description": "Adopt an existing ATProto account as a community identity. The provided app password is verified by creating a session, then stored encrypted. Creates the reserved `$admin` and `$publishers` spaces with the caller as `owner` in both.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "identifier", + "appPassword" + ], + "properties": { + "identifier": { + "type": "string", + "description": "Handle or DID of the account to adopt." + }, + "appPassword": { + "type": "string", + "description": "App password for the account; verified by createSession and stored encrypted." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "communityDid" + ], + "properties": { + "communityDid": { + "type": "string", + "format": "did" + } + } + } + }, + "errors": [ + { + "name": "InvalidRequest" + }, + { + "name": "Unauthorized" + }, + { + "name": "AlreadyExists" + } + ] + } + } +} diff --git a/lexicons-generated/rsvp/atmo/community/defs.json b/lexicons-generated/rsvp/atmo/community/defs.json new file mode 100644 index 0000000..1471660 --- /dev/null +++ b/lexicons-generated/rsvp/atmo/community/defs.json @@ -0,0 +1,135 @@ +{ + "lexicon": 1, + "id": "rsvp.atmo.community.defs", + "description": "Shared types for community-module XRPC methods.", + "defs": { + "accessLevel": { + "type": "string", + "knownValues": [ + "member", + "manager", + "admin", + "owner" + ], + "description": "Module-internal access levels that govern community operations on a given space. Ordered: member < manager < admin < owner." + }, + "communityView": { + "type": "object", + "required": [ + "did", + "mode", + "createdAt" + ], + "properties": { + "did": { + "type": "string", + "format": "did" + }, + "mode": { + "type": "string", + "knownValues": [ + "adopt", + "mint" + ] + }, + "identifier": { + "type": "string", + "description": "Handle or DID at adoption time (adopt only)." + }, + "createdAt": { + "type": "integer" + } + } + }, + "spaceView": { + "type": "object", + "required": [ + "uri", + "ownerDid", + "type", + "key", + "serviceDid", + "createdAt" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "ownerDid": { + "type": "string", + "format": "did" + }, + "type": { + "type": "string", + "format": "nsid" + }, + "key": { + "type": "string" + }, + "serviceDid": { + "type": "string" + }, + "createdAt": { + "type": "integer" + } + } + }, + "subject": { + "type": "object", + "description": "Exactly one of `did` or `spaceUri` must be set.", + "properties": { + "did": { + "type": "string", + "format": "did" + }, + "spaceUri": { + "type": "string", + "format": "at-uri" + } + } + }, + "memberRow": { + "type": "object", + "required": [ + "subject", + "accessLevel", + "grantedBy", + "grantedAt" + ], + "properties": { + "subject": { + "type": "ref", + "ref": "#subject" + }, + "accessLevel": { + "type": "ref", + "ref": "#accessLevel" + }, + "grantedBy": { + "type": "string", + "format": "did" + }, + "grantedAt": { + "type": "integer" + } + } + }, + "flatMember": { + "type": "object", + "required": [ + "did", + "addedAt" + ], + "properties": { + "did": { + "type": "string", + "format": "did" + }, + "addedAt": { + "type": "integer" + } + } + } + } +} diff --git a/lexicons-generated/rsvp/atmo/community/delete.json b/lexicons-generated/rsvp/atmo/community/delete.json new file mode 100644 index 0000000..85b158e --- /dev/null +++ b/lexicons-generated/rsvp/atmo/community/delete.json @@ -0,0 +1,47 @@ +{ + "lexicon": 1, + "id": "rsvp.atmo.community.delete", + "defs": { + "main": { + "type": "procedure", + "description": "Soft-delete a community. Also soft-deletes all spaces owned by the community. Caller must have `owner` in the community's `$admin` space.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "communityDid" + ], + "properties": { + "communityDid": { + "type": "string", + "format": "did" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "ok" + ], + "properties": { + "ok": { + "type": "boolean" + } + } + } + }, + "errors": [ + { + "name": "NotFound" + }, + { + "name": "Forbidden" + } + ] + } + } +} diff --git a/lexicons-generated/rsvp/atmo/community/deleteRecord.json b/lexicons-generated/rsvp/atmo/community/deleteRecord.json new file mode 100644 index 0000000..1b70e29 --- /dev/null +++ b/lexicons-generated/rsvp/atmo/community/deleteRecord.json @@ -0,0 +1,65 @@ +{ + "lexicon": 1, + "id": "rsvp.atmo.community.deleteRecord", + "defs": { + "main": { + "type": "procedure", + "description": "Delete a public record authored by the community from the community's PDS. Adopted communities only. Caller must be `member` or higher in the community's `$publishers` space.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "communityDid", + "collection", + "rkey" + ], + "properties": { + "communityDid": { + "type": "string", + "format": "did" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "type": "string" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "ok" + ], + "properties": { + "ok": { + "type": "boolean" + } + } + } + }, + "errors": [ + { + "name": "InvalidRequest" + }, + { + "name": "NotFound" + }, + { + "name": "Forbidden" + }, + { + "name": "NotSupported" + }, + { + "name": "UpstreamFailure" + } + ] + } + } +} diff --git a/lexicons-generated/rsvp/atmo/community/getHealth.json b/lexicons-generated/rsvp/atmo/community/getHealth.json new file mode 100644 index 0000000..f3c03e5 --- /dev/null +++ b/lexicons-generated/rsvp/atmo/community/getHealth.json @@ -0,0 +1,49 @@ +{ + "lexicon": 1, + "id": "rsvp.atmo.community.getHealth", + "defs": { + "main": { + "type": "query", + "description": "Check whether stored credentials for the community are still usable. Adopted: attempts a live session creation. Minted: verifies the signing key can be decrypted. Caller must have at least `member` in the community's `$admin` space.", + "parameters": { + "type": "params", + "required": [ + "communityDid" + ], + "properties": { + "communityDid": { + "type": "string", + "format": "did" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "knownValues": [ + "healthy", + "degraded", + "expired" + ] + } + } + } + }, + "errors": [ + { + "name": "NotFound" + }, + { + "name": "Forbidden" + } + ] + } + } +} diff --git a/lexicons-generated/rsvp/atmo/community/list.json b/lexicons-generated/rsvp/atmo/community/list.json new file mode 100644 index 0000000..520e45a --- /dev/null +++ b/lexicons-generated/rsvp/atmo/community/list.json @@ -0,0 +1,38 @@ +{ + "lexicon": 1, + "id": "rsvp.atmo.community.list", + "defs": { + "main": { + "type": "query", + "description": "List communities where the actor has any access level in any community-owned space. Defaults to the JWT issuer when `actor` is omitted.", + "parameters": { + "type": "params", + "properties": { + "actor": { + "type": "string", + "format": "did", + "description": "DID to query (defaults to the JWT issuer)." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "communities" + ], + "properties": { + "communities": { + "type": "array", + "items": { + "type": "ref", + "ref": "rsvp.atmo.community.defs#communityView" + } + } + } + } + } + } + } +} diff --git a/lexicons-generated/rsvp/atmo/community/mint.json b/lexicons-generated/rsvp/atmo/community/mint.json new file mode 100644 index 0000000..ab2aea0 --- /dev/null +++ b/lexicons-generated/rsvp/atmo/community/mint.json @@ -0,0 +1,51 @@ +{ + "lexicon": 1, + "id": "rsvp.atmo.community.mint", + "defs": { + "main": { + "type": "procedure", + "description": "Mint a fresh did:plc for a new community. Contrail generates three P-256 keypairs (signing, contrail rotation, creator rotation), submits a genesis op to the PLC directory, and stores the signing + contrail-rotation keys encrypted. The creator's rotation key is returned once as a recovery secret and never stored.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "handle": { + "type": "string", + "description": "Optional handle to include in alsoKnownAs (as at://)." + }, + "pdsEndpoint": { + "type": "string", + "description": "Optional PDS endpoint to include as the atproto_pds service." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "communityDid", + "recoveryKey" + ], + "properties": { + "communityDid": { + "type": "string", + "format": "did" + }, + "recoveryKey": { + "type": "unknown", + "description": "Creator's rotation key as a private JWK. Shown once — cannot be retrieved later." + } + } + } + }, + "errors": [ + { + "name": "UpstreamFailure" + } + ] + } + } +} diff --git a/lexicons-generated/rsvp/atmo/community/putRecord.json b/lexicons-generated/rsvp/atmo/community/putRecord.json new file mode 100644 index 0000000..43581a3 --- /dev/null +++ b/lexicons-generated/rsvp/atmo/community/putRecord.json @@ -0,0 +1,73 @@ +{ + "lexicon": 1, + "id": "rsvp.atmo.community.putRecord", + "defs": { + "main": { + "type": "procedure", + "description": "Publish a public record authored by the community via the community's PDS. Adopted communities only. Caller must be `member` or higher in the community's `$publishers` space.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "communityDid", + "collection", + "record" + ], + "properties": { + "communityDid": { + "type": "string", + "format": "did" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "type": "string" + }, + "record": { + "type": "unknown" + }, + "validate": { + "type": "boolean" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + } + } + } + }, + "errors": [ + { + "name": "InvalidRequest" + }, + { + "name": "NotFound" + }, + { + "name": "Forbidden" + }, + { + "name": "NotSupported" + }, + { + "name": "UpstreamFailure" + } + ] + } + } +} diff --git a/lexicons-generated/rsvp/atmo/community/reauth.json b/lexicons-generated/rsvp/atmo/community/reauth.json new file mode 100644 index 0000000..87a05d4 --- /dev/null +++ b/lexicons-generated/rsvp/atmo/community/reauth.json @@ -0,0 +1,60 @@ +{ + "lexicon": 1, + "id": "rsvp.atmo.community.reauth", + "defs": { + "main": { + "type": "procedure", + "description": "Replace the stored app password for an adopted community. Caller must have `owner` access in the community's `$admin` space.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "communityDid", + "appPassword" + ], + "properties": { + "communityDid": { + "type": "string", + "format": "did" + }, + "appPassword": { + "type": "string" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "ok" + ], + "properties": { + "ok": { + "type": "boolean" + } + } + } + }, + "errors": [ + { + "name": "InvalidRequest" + }, + { + "name": "NotFound" + }, + { + "name": "Forbidden" + }, + { + "name": "Unauthorized" + }, + { + "name": "NotSupported" + } + ] + } + } +} diff --git a/lexicons-generated/rsvp/atmo/community/space/create.json b/lexicons-generated/rsvp/atmo/community/space/create.json new file mode 100644 index 0000000..8c3bbaa --- /dev/null +++ b/lexicons-generated/rsvp/atmo/community/space/create.json @@ -0,0 +1,58 @@ +{ + "lexicon": 1, + "id": "rsvp.atmo.community.space.create", + "defs": { + "main": { + "type": "procedure", + "description": "Create a community-owned space (group/role/channel). Caller needs `admin` or higher in the community's `$admin` space. Reserved keys (`$admin`, `$publishers`, …) are rejected here.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "communityDid" + ], + "properties": { + "communityDid": { + "type": "string", + "format": "did" + }, + "key": { + "type": "string", + "description": "Space key. Auto-generated if omitted." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "space" + ], + "properties": { + "space": { + "type": "ref", + "ref": "rsvp.atmo.community.defs#spaceView" + } + } + } + }, + "errors": [ + { + "name": "InvalidRequest" + }, + { + "name": "NotFound" + }, + { + "name": "Forbidden" + }, + { + "name": "AlreadyExists" + } + ] + } + } +} diff --git a/lexicons-generated/rsvp/atmo/community/space/delete.json b/lexicons-generated/rsvp/atmo/community/space/delete.json new file mode 100644 index 0000000..17fae52 --- /dev/null +++ b/lexicons-generated/rsvp/atmo/community/space/delete.json @@ -0,0 +1,47 @@ +{ + "lexicon": 1, + "id": "rsvp.atmo.community.space.delete", + "defs": { + "main": { + "type": "procedure", + "description": "Soft-delete a community-owned space. Caller must be `owner` on the space, or `admin`+ in the community's `$admin` space. Reserved spaces cannot be deleted.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "spaceUri" + ], + "properties": { + "spaceUri": { + "type": "string", + "format": "at-uri" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "ok" + ], + "properties": { + "ok": { + "type": "boolean" + } + } + } + }, + "errors": [ + { + "name": "NotFound" + }, + { + "name": "Forbidden" + } + ] + } + } +} diff --git a/lexicons-generated/rsvp/atmo/community/space/deleteRecord.json b/lexicons-generated/rsvp/atmo/community/space/deleteRecord.json new file mode 100644 index 0000000..abe2a68 --- /dev/null +++ b/lexicons-generated/rsvp/atmo/community/space/deleteRecord.json @@ -0,0 +1,59 @@ +{ + "lexicon": 1, + "id": "rsvp.atmo.community.space.deleteRecord", + "defs": { + "main": { + "type": "procedure", + "description": "Delete an in-space record authored by the community DID. Caller must be `admin` or higher in the target space.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "spaceUri", + "collection", + "rkey" + ], + "properties": { + "spaceUri": { + "type": "string", + "format": "at-uri" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "type": "string" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "ok" + ], + "properties": { + "ok": { + "type": "boolean" + } + } + } + }, + "errors": [ + { + "name": "InvalidRequest" + }, + { + "name": "NotFound" + }, + { + "name": "Forbidden" + } + ] + } + } +} diff --git a/lexicons-generated/rsvp/atmo/community/space/grant.json b/lexicons-generated/rsvp/atmo/community/space/grant.json new file mode 100644 index 0000000..acca868 --- /dev/null +++ b/lexicons-generated/rsvp/atmo/community/space/grant.json @@ -0,0 +1,60 @@ +{ + "lexicon": 1, + "id": "rsvp.atmo.community.space.grant", + "defs": { + "main": { + "type": "procedure", + "description": "Grant or upsert a subject's access level on a space. Caller needs `manager` or higher; the granted level cannot exceed the caller's own. A subject can be a DID (member) or another space (delegated membership).", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "spaceUri", + "subject", + "accessLevel" + ], + "properties": { + "spaceUri": { + "type": "string", + "format": "at-uri" + }, + "subject": { + "type": "ref", + "ref": "rsvp.atmo.community.defs#subject" + }, + "accessLevel": { + "type": "ref", + "ref": "rsvp.atmo.community.defs#accessLevel" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "ok" + ], + "properties": { + "ok": { + "type": "boolean" + } + } + } + }, + "errors": [ + { + "name": "InvalidRequest" + }, + { + "name": "NotFound" + }, + { + "name": "Forbidden" + } + ] + } + } +} diff --git a/lexicons-generated/rsvp/atmo/community/space/listMembers.json b/lexicons-generated/rsvp/atmo/community/space/listMembers.json new file mode 100644 index 0000000..130d72b --- /dev/null +++ b/lexicons-generated/rsvp/atmo/community/space/listMembers.json @@ -0,0 +1,58 @@ +{ + "lexicon": 1, + "id": "rsvp.atmo.community.space.listMembers", + "defs": { + "main": { + "type": "query", + "description": "List members of a community-owned space. By default returns the raw access-level rows; with `flatten=true` returns the flattened DID list (delegated memberships resolved).", + "parameters": { + "type": "params", + "required": [ + "spaceUri" + ], + "properties": { + "spaceUri": { + "type": "string", + "format": "at-uri" + }, + "flatten": { + "type": "boolean", + "description": "If true, return the flat DID list instead of raw rows." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "ref", + "ref": "rsvp.atmo.community.defs#memberRow" + }, + "description": "Present when flatten=false." + }, + "members": { + "type": "array", + "items": { + "type": "ref", + "ref": "rsvp.atmo.community.defs#flatMember" + }, + "description": "Present when flatten=true." + } + } + } + }, + "errors": [ + { + "name": "NotFound" + }, + { + "name": "Forbidden" + } + ] + } + } +} diff --git a/lexicons-generated/rsvp/atmo/community/space/putRecord.json b/lexicons-generated/rsvp/atmo/community/space/putRecord.json new file mode 100644 index 0000000..eca1cfe --- /dev/null +++ b/lexicons-generated/rsvp/atmo/community/space/putRecord.json @@ -0,0 +1,71 @@ +{ + "lexicon": 1, + "id": "rsvp.atmo.community.space.putRecord", + "defs": { + "main": { + "type": "procedure", + "description": "Write an in-space record authored by the community DID (rather than the caller). Caller must be `admin` or higher in the target space.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "spaceUri", + "collection", + "record" + ], + "properties": { + "spaceUri": { + "type": "string", + "format": "at-uri" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "type": "string" + }, + "record": { + "type": "unknown" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "rkey", + "authorDid", + "createdAt" + ], + "properties": { + "rkey": { + "type": "string" + }, + "authorDid": { + "type": "string", + "format": "did" + }, + "createdAt": { + "type": "integer" + } + } + } + }, + "errors": [ + { + "name": "InvalidRequest" + }, + { + "name": "NotFound" + }, + { + "name": "Forbidden" + } + ] + } + } +} diff --git a/lexicons-generated/rsvp/atmo/community/space/resync.json b/lexicons-generated/rsvp/atmo/community/space/resync.json new file mode 100644 index 0000000..f0bba3b --- /dev/null +++ b/lexicons-generated/rsvp/atmo/community/space/resync.json @@ -0,0 +1,47 @@ +{ + "lexicon": 1, + "id": "rsvp.atmo.community.space.resync", + "defs": { + "main": { + "type": "procedure", + "description": "Manually recompute `spaces_members` for a community-owned space. Useful after a crash mid-reconcile leaves stale membership. Caller must have `admin` or higher.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "spaceUri" + ], + "properties": { + "spaceUri": { + "type": "string", + "format": "at-uri" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "ok" + ], + "properties": { + "ok": { + "type": "boolean" + } + } + } + }, + "errors": [ + { + "name": "NotFound" + }, + { + "name": "Forbidden" + } + ] + } + } +} diff --git a/lexicons-generated/rsvp/atmo/community/space/revoke.json b/lexicons-generated/rsvp/atmo/community/space/revoke.json new file mode 100644 index 0000000..cff2c18 --- /dev/null +++ b/lexicons-generated/rsvp/atmo/community/space/revoke.json @@ -0,0 +1,55 @@ +{ + "lexicon": 1, + "id": "rsvp.atmo.community.space.revoke", + "defs": { + "main": { + "type": "procedure", + "description": "Remove a subject's access grant from a space. Caller must have `manager` or higher and outrank the subject's current level.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "spaceUri", + "subject" + ], + "properties": { + "spaceUri": { + "type": "string", + "format": "at-uri" + }, + "subject": { + "type": "ref", + "ref": "rsvp.atmo.community.defs#subject" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "ok" + ], + "properties": { + "ok": { + "type": "boolean" + } + } + } + }, + "errors": [ + { + "name": "InvalidRequest" + }, + { + "name": "NotFound" + }, + { + "name": "Forbidden" + } + ] + } + } +} diff --git a/lexicons-generated/rsvp/atmo/community/space/setAccessLevel.json b/lexicons-generated/rsvp/atmo/community/space/setAccessLevel.json new file mode 100644 index 0000000..1e41342 --- /dev/null +++ b/lexicons-generated/rsvp/atmo/community/space/setAccessLevel.json @@ -0,0 +1,60 @@ +{ + "lexicon": 1, + "id": "rsvp.atmo.community.space.setAccessLevel", + "defs": { + "main": { + "type": "procedure", + "description": "Change the access level of an existing grant. Caller must outrank both the old and new level.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "spaceUri", + "subject", + "accessLevel" + ], + "properties": { + "spaceUri": { + "type": "string", + "format": "at-uri" + }, + "subject": { + "type": "ref", + "ref": "rsvp.atmo.community.defs#subject" + }, + "accessLevel": { + "type": "ref", + "ref": "rsvp.atmo.community.defs#accessLevel" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "ok" + ], + "properties": { + "ok": { + "type": "boolean" + } + } + } + }, + "errors": [ + { + "name": "InvalidRequest" + }, + { + "name": "NotFound" + }, + { + "name": "Forbidden" + } + ] + } + } +} diff --git a/lexicons-generated/rsvp/atmo/community/whoami.json b/lexicons-generated/rsvp/atmo/community/whoami.json new file mode 100644 index 0000000..691d361 --- /dev/null +++ b/lexicons-generated/rsvp/atmo/community/whoami.json @@ -0,0 +1,42 @@ +{ + "lexicon": 1, + "id": "rsvp.atmo.community.whoami", + "defs": { + "main": { + "type": "query", + "description": "Report the JWT issuer's effective access level in a space (resolving delegated memberships).", + "parameters": { + "type": "params", + "required": [ + "spaceUri" + ], + "properties": { + "spaceUri": { + "type": "string", + "format": "at-uri" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "spaceUri" + ], + "properties": { + "spaceUri": { + "type": "string", + "format": "at-uri" + }, + "accessLevel": { + "type": "ref", + "ref": "rsvp.atmo.community.defs#accessLevel", + "description": "Null when caller has no resolvable access." + } + } + } + } + } + } +} diff --git a/lexicons-generated/rsvp/atmo/event/getRecord.json b/lexicons-generated/rsvp/atmo/event/getRecord.json index d0343b2..d2dc8a6 100644 --- a/lexicons-generated/rsvp/atmo/event/getRecord.json +++ b/lexicons-generated/rsvp/atmo/event/getRecord.json @@ -310,6 +310,13 @@ "description": "Name of the publication.", "maxGraphemes": 500 }, + "labels": { + "refs": [ + "com.atproto.label.defs#selfLabels" + ], + "type": "union", + "description": "Self-label values for this publication. Effectively content warnings." + }, "basicTheme": { "ref": "site.standard.theme.basic", "type": "ref", diff --git a/lexicons-generated/rsvp/atmo/event/listRecords.json b/lexicons-generated/rsvp/atmo/event/listRecords.json index e0bba74..3f938e0 100644 --- a/lexicons-generated/rsvp/atmo/event/listRecords.json +++ b/lexicons-generated/rsvp/atmo/event/listRecords.json @@ -424,6 +424,13 @@ "description": "Name of the publication.", "maxGraphemes": 500 }, + "labels": { + "refs": [ + "com.atproto.label.defs#selfLabels" + ], + "type": "union", + "description": "Self-label values for this publication. Effectively content warnings." + }, "basicTheme": { "ref": "site.standard.theme.basic", "type": "ref", diff --git a/lexicons-generated/rsvp/atmo/getProfile.json b/lexicons-generated/rsvp/atmo/getProfile.json index 622dd6a..d6c6191 100644 --- a/lexicons-generated/rsvp/atmo/getProfile.json +++ b/lexicons-generated/rsvp/atmo/getProfile.json @@ -162,6 +162,13 @@ "description": "Name of the publication.", "maxGraphemes": 500 }, + "labels": { + "refs": [ + "com.atproto.label.defs#selfLabels" + ], + "type": "union", + "description": "Self-label values for this publication. Effectively content warnings." + }, "basicTheme": { "ref": "site.standard.theme.basic", "type": "ref", diff --git a/lexicons-generated/rsvp/atmo/permissionSet.json b/lexicons-generated/rsvp/atmo/permissionSet.json index c1ab89e..9ac2975 100644 --- a/lexicons-generated/rsvp/atmo/permissionSet.json +++ b/lexicons-generated/rsvp/atmo/permissionSet.json @@ -12,6 +12,24 @@ "resource": "rpc", "aud": "*", "lxm": [ + "rsvp.atmo.community.adopt", + "rsvp.atmo.community.delete", + "rsvp.atmo.community.deleteRecord", + "rsvp.atmo.community.getHealth", + "rsvp.atmo.community.list", + "rsvp.atmo.community.mint", + "rsvp.atmo.community.putRecord", + "rsvp.atmo.community.reauth", + "rsvp.atmo.community.space.create", + "rsvp.atmo.community.space.delete", + "rsvp.atmo.community.space.deleteRecord", + "rsvp.atmo.community.space.grant", + "rsvp.atmo.community.space.listMembers", + "rsvp.atmo.community.space.putRecord", + "rsvp.atmo.community.space.resync", + "rsvp.atmo.community.space.revoke", + "rsvp.atmo.community.space.setAccessLevel", + "rsvp.atmo.community.whoami", "rsvp.atmo.event.getRecord", "rsvp.atmo.event.listRecords", "rsvp.atmo.getCursor", diff --git a/lexicons-generated/rsvp/atmo/rsvp/getRecord.json b/lexicons-generated/rsvp/atmo/rsvp/getRecord.json index 3d6c1c9..d63df47 100644 --- a/lexicons-generated/rsvp/atmo/rsvp/getRecord.json +++ b/lexicons-generated/rsvp/atmo/rsvp/getRecord.json @@ -259,6 +259,13 @@ "description": "Name of the publication.", "maxGraphemes": 500 }, + "labels": { + "refs": [ + "com.atproto.label.defs#selfLabels" + ], + "type": "union", + "description": "Self-label values for this publication. Effectively content warnings." + }, "basicTheme": { "ref": "site.standard.theme.basic", "type": "ref", diff --git a/lexicons-generated/rsvp/atmo/rsvp/listRecords.json b/lexicons-generated/rsvp/atmo/rsvp/listRecords.json index fb3cb58..d216597 100644 --- a/lexicons-generated/rsvp/atmo/rsvp/listRecords.json +++ b/lexicons-generated/rsvp/atmo/rsvp/listRecords.json @@ -312,6 +312,13 @@ "description": "Name of the publication.", "maxGraphemes": 500 }, + "labels": { + "refs": [ + "com.atproto.label.defs#selfLabels" + ], + "type": "union", + "description": "Self-label values for this publication. Effectively content warnings." + }, "basicTheme": { "ref": "site.standard.theme.basic", "type": "ref", diff --git a/lexicons-generated/rsvp/atmo/space/addMember.json b/lexicons-generated/rsvp/atmo/space/addMember.json index 3c69ac2..d6308ff 100644 --- a/lexicons-generated/rsvp/atmo/space/addMember.json +++ b/lexicons-generated/rsvp/atmo/space/addMember.json @@ -21,14 +21,6 @@ "did": { "type": "string", "format": "did" - }, - "perms": { - "type": "string", - "knownValues": [ - "read", - "write" - ], - "default": "write" } } } diff --git a/lexicons-generated/rsvp/atmo/space/createSpace.json b/lexicons-generated/rsvp/atmo/space/createSpace.json index f2d6dec..f17d161 100644 --- a/lexicons-generated/rsvp/atmo/space/createSpace.json +++ b/lexicons-generated/rsvp/atmo/space/createSpace.json @@ -19,10 +19,6 @@ "type": "string", "description": "Space key. Auto-generated (TID) if omitted." }, - "memberListRef": { - "type": "string", - "format": "at-uri" - }, "appPolicyRef": { "type": "string", "format": "at-uri" diff --git a/lexicons-generated/rsvp/atmo/space/defs.json b/lexicons-generated/rsvp/atmo/space/defs.json index 1df906e..01f50b6 100644 --- a/lexicons-generated/rsvp/atmo/space/defs.json +++ b/lexicons-generated/rsvp/atmo/space/defs.json @@ -32,10 +32,6 @@ "serviceDid": { "type": "string" }, - "memberListRef": { - "type": "string", - "format": "at-uri" - }, "appPolicyRef": { "type": "string", "format": "at-uri" @@ -54,7 +50,6 @@ "type": "object", "required": [ "did", - "perms", "addedAt" ], "properties": { @@ -62,14 +57,6 @@ "type": "string", "format": "did" }, - "perms": { - "type": "string", - "knownValues": [ - "read", - "write" - ], - "description": "'write' implies 'read'. Space owner is always implicit write." - }, "addedAt": { "type": "integer" }, @@ -146,7 +133,6 @@ "tokenHash", "spaceUri", "kind", - "perms", "usedCount", "createdBy", "createdAt" @@ -167,13 +153,6 @@ "read-join" ] }, - "perms": { - "type": "string", - "knownValues": [ - "read", - "write" - ] - }, "expiresAt": { "type": "integer" }, diff --git a/lexicons-generated/rsvp/atmo/space/invite/create.json b/lexicons-generated/rsvp/atmo/space/invite/create.json index 3eb9e8b..c60f793 100644 --- a/lexicons-generated/rsvp/atmo/space/invite/create.json +++ b/lexicons-generated/rsvp/atmo/space/invite/create.json @@ -27,14 +27,6 @@ "default": "join", "description": "join: redeem to become a member. read: bearer-only read access, no membership. read-join: anonymous read + signed-in redeem to join." }, - "perms": { - "type": "string", - "knownValues": [ - "read", - "write" - ], - "default": "write" - }, "expiresAt": { "type": "integer", "description": "Unix ms timestamp. Omit for no expiry." diff --git a/lexicons-generated/rsvp/atmo/space/invite/redeem.json b/lexicons-generated/rsvp/atmo/space/invite/redeem.json index 9a41ce0..06261e9 100644 --- a/lexicons-generated/rsvp/atmo/space/invite/redeem.json +++ b/lexicons-generated/rsvp/atmo/space/invite/redeem.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Redeem an invite token. The JWT issuer becomes a member of the space with the invite's perms.", + "description": "Redeem an invite token. The JWT issuer becomes a member of the space.", "input": { "encoding": "application/json", "schema": { @@ -24,20 +24,12 @@ "schema": { "type": "object", "required": [ - "spaceUri", - "perms" + "spaceUri" ], "properties": { "spaceUri": { "type": "string", "format": "at-uri" - }, - "perms": { - "type": "string", - "knownValues": [ - "read", - "write" - ] } } } diff --git a/lexicons-generated/rsvp/atmo/space/whoami.json b/lexicons-generated/rsvp/atmo/space/whoami.json index e3737c7..8eb98a2 100644 --- a/lexicons-generated/rsvp/atmo/space/whoami.json +++ b/lexicons-generated/rsvp/atmo/space/whoami.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Report the caller's relationship to a space: whether they are the owner, a member, and at what permission level. Useful for clients to avoid a listMembers roundtrip.", + "description": "Report the caller's relationship to a space: whether they are the owner and/or a member. Useful for clients to avoid a listMembers roundtrip.", "parameters": { "type": "params", "required": [ @@ -31,14 +31,6 @@ }, "isMember": { "type": "boolean" - }, - "perms": { - "type": "string", - "knownValues": [ - "read", - "write" - ], - "description": "Present only when the caller is a member or the owner." } } } diff --git a/lexicons-pulled/site/standard/publication.json b/lexicons-pulled/site/standard/publication.json index 456f730..9656716 100644 --- a/lexicons-pulled/site/standard/publication.json +++ b/lexicons-pulled/site/standard/publication.json @@ -25,6 +25,11 @@ "description": "Name of the publication.", "maxGraphemes": 500 }, + "labels": { + "refs": ["com.atproto.label.defs#selfLabels"], + "type": "union", + "description": "Self-label values for this publication. Effectively content warnings." + }, "basicTheme": { "ref": "site.standard.theme.basic", "type": "ref", @@ -53,8 +58,7 @@ "default": true, "description": "Boolean which decides whether the publication should appear in discovery feeds." } - }, - "description": "Platform-specific preferences for the publication, including discovery and visibility settings." + } } }, "$type": "com.atproto.lexicon.schema", diff --git a/lexicons-pulled/site/standard/theme/basic.json b/lexicons-pulled/site/standard/theme/basic.json index 88c7cba..88ff587 100644 --- a/lexicons-pulled/site/standard/theme/basic.json +++ b/lexicons-pulled/site/standard/theme/basic.json @@ -2,28 +2,32 @@ "id": "site.standard.theme.basic", "defs": { "main": { - "type": "object", - "required": ["background", "foreground", "accent", "accentForeground"], - "properties": { - "accent": { - "refs": ["site.standard.theme.color#rgb"], - "type": "union", - "description": "Color used for links and button backgrounds." - }, - "background": { - "refs": ["site.standard.theme.color#rgb"], - "type": "union", - "description": "Color used for content background." - }, - "foreground": { - "refs": ["site.standard.theme.color#rgb"], - "type": "union", - "description": "Color used for content text." - }, - "accentForeground": { - "refs": ["site.standard.theme.color#rgb"], - "type": "union", - "description": "Color used for button text." + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": ["background", "foreground", "accent", "accentForeground"], + "properties": { + "accent": { + "refs": ["site.standard.theme.color#rgb"], + "type": "union", + "description": "Color used for links and button backgrounds." + }, + "background": { + "refs": ["site.standard.theme.color#rgb"], + "type": "union", + "description": "Color used for content background." + }, + "foreground": { + "refs": ["site.standard.theme.color#rgb"], + "type": "union", + "description": "Color used for content text." + }, + "accentForeground": { + "refs": ["site.standard.theme.color#rgb"], + "type": "union", + "description": "Color used for button text." + } } }, "description": "A simplified theme definition for publications, providing basic color customization for content display across different platforms and applications." diff --git a/package.json b/package.json index 0370a23..a17637d 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "sideEffects": false, "files": [ "dist", - "spaces-lexicon-templates/**/*.json" + "spaces-lexicon-templates/**/*.json", + "community-lexicon-templates/**/*.json" ], "exports": { ".": { @@ -33,7 +34,8 @@ "types": "./dist/publish.d.ts", "import": "./dist/publish.js" }, - "./spaces-lexicon-templates/*.json": "./spaces-lexicon-templates/*.json" + "./spaces-lexicon-templates/*.json": "./spaces-lexicon-templates/*.json", + "./community-lexicon-templates/*.json": "./community-lexicon-templates/*.json" }, "repository": { "type": "git", diff --git a/spaces-lexicon-templates/addMember.json b/spaces-lexicon-templates/addMember.json index e591863..71149a9 100644 --- a/spaces-lexicon-templates/addMember.json +++ b/spaces-lexicon-templates/addMember.json @@ -12,8 +12,7 @@ "required": ["spaceUri", "did"], "properties": { "spaceUri": { "type": "string", "format": "at-uri" }, - "did": { "type": "string", "format": "did" }, - "perms": { "type": "string", "knownValues": ["read", "write"], "default": "write" } + "did": { "type": "string", "format": "did" } } } }, diff --git a/spaces-lexicon-templates/createSpace.json b/spaces-lexicon-templates/createSpace.json index 73a131d..a3f516b 100644 --- a/spaces-lexicon-templates/createSpace.json +++ b/spaces-lexicon-templates/createSpace.json @@ -12,7 +12,6 @@ "properties": { "type": { "type": "string", "format": "nsid", "description": "Space type NSID. Defaults to the service's configured type." }, "key": { "type": "string", "description": "Space key. Auto-generated (TID) if omitted." }, - "memberListRef": { "type": "string", "format": "at-uri" }, "appPolicyRef": { "type": "string", "format": "at-uri" }, "appPolicy": { "type": "ref", "ref": "tools.atmo.space.defs#appPolicy" } } diff --git a/spaces-lexicon-templates/defs.json b/spaces-lexicon-templates/defs.json index 57a5342..4eaee52 100644 --- a/spaces-lexicon-templates/defs.json +++ b/spaces-lexicon-templates/defs.json @@ -12,7 +12,6 @@ "type": { "type": "string", "format": "nsid" }, "key": { "type": "string" }, "serviceDid": { "type": "string" }, - "memberListRef": { "type": "string", "format": "at-uri" }, "appPolicyRef": { "type": "string", "format": "at-uri" }, "createdAt": { "type": "integer" }, "appPolicy": { "type": "ref", "ref": "#appPolicy", "description": "Owner-only" } @@ -20,10 +19,9 @@ }, "memberView": { "type": "object", - "required": ["did", "perms", "addedAt"], + "required": ["did", "addedAt"], "properties": { "did": { "type": "string", "format": "did" }, - "perms": { "type": "string", "knownValues": ["read", "write"], "description": "'write' implies 'read'. Space owner is always implicit write." }, "addedAt": { "type": "integer" }, "addedBy": { "type": "string", "format": "did" } } @@ -51,12 +49,11 @@ }, "inviteView": { "type": "object", - "required": ["tokenHash", "spaceUri", "kind", "perms", "usedCount", "createdBy", "createdAt"], + "required": ["tokenHash", "spaceUri", "kind", "usedCount", "createdBy", "createdAt"], "properties": { "tokenHash": { "type": "string" }, "spaceUri": { "type": "string", "format": "at-uri" }, "kind": { "type": "string", "knownValues": ["join", "read", "read-join"] }, - "perms": { "type": "string", "knownValues": ["read", "write"] }, "expiresAt": { "type": "integer" }, "maxUses": { "type": "integer" }, "usedCount": { "type": "integer" }, diff --git a/spaces-lexicon-templates/invite/create.json b/spaces-lexicon-templates/invite/create.json index 5fa0f55..e3cdf75 100644 --- a/spaces-lexicon-templates/invite/create.json +++ b/spaces-lexicon-templates/invite/create.json @@ -13,7 +13,6 @@ "properties": { "spaceUri": { "type": "string", "format": "at-uri" }, "kind": { "type": "string", "knownValues": ["join", "read", "read-join"], "default": "join", "description": "join: redeem to become a member. read: bearer-only read access, no membership. read-join: anonymous read + signed-in redeem to join." }, - "perms": { "type": "string", "knownValues": ["read", "write"], "default": "write" }, "expiresAt": { "type": "integer", "description": "Unix ms timestamp. Omit for no expiry." }, "maxUses": { "type": "integer", "minimum": 1, "description": "Caps join redemptions only — read-token reads are unlimited. Omit for unlimited joins." }, "note": { "type": "string", "maxLength": 500 } diff --git a/spaces-lexicon-templates/invite/redeem.json b/spaces-lexicon-templates/invite/redeem.json index d3369fa..398127d 100644 --- a/spaces-lexicon-templates/invite/redeem.json +++ b/spaces-lexicon-templates/invite/redeem.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Redeem an invite token. The JWT issuer becomes a member of the space with the invite's perms.", + "description": "Redeem an invite token. The JWT issuer becomes a member of the space.", "input": { "encoding": "application/json", "schema": { @@ -19,10 +19,9 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["spaceUri", "perms"], + "required": ["spaceUri"], "properties": { - "spaceUri": { "type": "string", "format": "at-uri" }, - "perms": { "type": "string", "knownValues": ["read", "write"] } + "spaceUri": { "type": "string", "format": "at-uri" } } } }, diff --git a/spaces-lexicon-templates/whoami.json b/spaces-lexicon-templates/whoami.json index e30f2ef..c0e8f9d 100644 --- a/spaces-lexicon-templates/whoami.json +++ b/spaces-lexicon-templates/whoami.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Report the caller's relationship to a space: whether they are the owner, a member, and at what permission level. Useful for clients to avoid a listMembers roundtrip.", + "description": "Report the caller's relationship to a space: whether they are the owner and/or a member. Useful for clients to avoid a listMembers roundtrip.", "parameters": { "type": "params", "required": ["spaceUri"], @@ -19,8 +19,7 @@ "required": ["isOwner", "isMember"], "properties": { "isOwner": { "type": "boolean" }, - "isMember": { "type": "boolean" }, - "perms": { "type": "string", "knownValues": ["read", "write"], "description": "Present only when the caller is a member or the owner." } + "isMember": { "type": "boolean" } } } }, diff --git a/src/core/community/acl.ts b/src/core/community/acl.ts new file mode 100644 index 0000000..b043f30 --- /dev/null +++ b/src/core/community/acl.ts @@ -0,0 +1,107 @@ +import type { AccessLevel } from "./types"; +import { ACCESS_LEVELS, rankOf } from "./types"; +import type { CommunityAdapter } from "./adapter"; + +export interface EffectiveLevelOptions { + /** Hard cap on recursion depth when walking group-of-groups. */ + maxDepth?: number; +} + +const DEFAULT_MAX_DEPTH = 8; + +/** Resolve the effective access level a DID has in `spaceUri`. + * + * - Direct grants: rows where subject = did. + * - Indirect grants: rows where subject is another space the DID is + * (transitively) a member of. The path-level is the MIN of every level + * traversed, since delegation caps access at each hop. + * + * Returns `null` if the caller has no resolvable access. */ +export async function resolveEffectiveLevel( + adapter: CommunityAdapter, + spaceUri: string, + callerDid: string, + opts: EffectiveLevelOptions = {} +): Promise { + const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH; + // Walk: start with spaceUri. At each step we see who is a direct member; + // if the caller is present, record their level (capped by the path minimum). + let best: number = -1; + const visited = new Set(); + + async function walk(targetSpace: string, pathMin: number, depth: number): Promise { + if (depth > maxDepth) return; + if (visited.has(targetSpace)) return; + visited.add(targetSpace); + const rows = await adapter.listAccessRows(targetSpace); + for (const row of rows) { + const levelRank = rankOf(row.accessLevel); + const capped = pathMin < 0 ? levelRank : Math.min(pathMin, levelRank); + if (row.subjectDid === callerDid) { + if (capped > best) best = capped; + } else if (row.subjectSpaceUri) { + // Walk into the delegated space with the capped path level. + await walk(row.subjectSpaceUri, capped, depth + 1); + } + } + } + + await walk(spaceUri, -1, 0); + return best >= 0 ? ACCESS_LEVELS[best]! : null; +} + +/** Flatten the effective membership (DIDs with level ≥ `member`) for a space. + * Used by the reconciler to write `spaces_members`. */ +export async function flattenEffectiveMembers( + adapter: CommunityAdapter, + spaceUri: string, + opts: EffectiveLevelOptions = {} +): Promise> { + const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH; + const dids = new Set(); + const visited = new Set(); + + async function walk(targetSpace: string, depth: number): Promise { + if (depth > maxDepth) return; + if (visited.has(targetSpace)) return; + visited.add(targetSpace); + const rows = await adapter.listAccessRows(targetSpace); + for (const row of rows) { + if (row.subjectDid) { + dids.add(row.subjectDid); + } else if (row.subjectSpaceUri) { + await walk(row.subjectSpaceUri, depth + 1); + } + } + } + + await walk(spaceUri, 0); + return dids; +} + +/** Check whether the actor would cause a cycle if added as a subject-space of `spaceUri`. */ +export async function wouldCycle( + adapter: CommunityAdapter, + spaceUri: string, + subjectSpaceUri: string +): Promise { + // A cycle exists if spaceUri is reachable from subjectSpaceUri via the + // subject-space graph. + const visited = new Set(); + const stack: string[] = [subjectSpaceUri]; + while (stack.length) { + const s = stack.pop()!; + if (s === spaceUri) return true; + if (visited.has(s)) continue; + visited.add(s); + const rows = await adapter.listAccessRows(s); + for (const row of rows) { + if (row.subjectSpaceUri && !visited.has(row.subjectSpaceUri)) { + stack.push(row.subjectSpaceUri); + } + } + } + return false; +} + +export { ACCESS_LEVELS, rankOf }; diff --git a/src/core/community/adapter.ts b/src/core/community/adapter.ts new file mode 100644 index 0000000..8ef88c3 --- /dev/null +++ b/src/core/community/adapter.ts @@ -0,0 +1,302 @@ +import type { Database } from "../types"; +import type { AccessLevel, AccessLevelRow, CommunityMode, CommunityRow } from "./types"; + +function toNum(v: unknown): number { + return typeof v === "string" ? Number(v) : (v as number); +} + +function mapCommunityRow(row: any): CommunityRow { + return { + did: row.did, + mode: row.mode as CommunityMode, + pdsEndpoint: row.pds_endpoint ?? null, + appPasswordEncrypted: row.app_password_encrypted + ? toBytes(row.app_password_encrypted) + : null, + identifier: row.identifier ?? null, + signingKeyEncrypted: row.signing_key_encrypted + ? toBytes(row.signing_key_encrypted) + : null, + rotationKeyEncrypted: row.rotation_key_encrypted + ? toBytes(row.rotation_key_encrypted) + : null, + createdBy: row.created_by, + createdAt: toNum(row.created_at), + deletedAt: row.deleted_at == null ? null : toNum(row.deleted_at), + }; +} + +function toBytes(v: unknown): Uint8Array { + // Encrypted fields are stored base64 as TEXT; passthrough for other code paths. + if (v instanceof Uint8Array) return v; + if (typeof v === "string") { + // Treat as base64 for storage. Caller's cipher handles further decoding. + return new TextEncoder().encode(v); + } + throw new Error("unexpected blob column type"); +} + +function mapAccessRow(row: any): AccessLevelRow { + const isSpace = row.subject_kind === "space"; + return { + spaceUri: row.space_uri, + subjectDid: isSpace ? null : row.subject, + subjectSpaceUri: isSpace ? row.subject : null, + accessLevel: row.access_level as AccessLevel, + grantedBy: row.granted_by, + grantedAt: toNum(row.granted_at), + }; +} + +export interface CreateAdoptedCommunityInput { + did: string; + pdsEndpoint: string; + appPasswordEncrypted: string; // base64 envelope + identifier: string; + createdBy: string; +} + +export interface CreateMintedCommunityInput { + did: string; + signingKeyEncrypted: string; // base64 envelope + rotationKeyEncrypted: string; // base64 envelope + createdBy: string; +} + +export interface GrantInput { + spaceUri: string; + subjectDid?: string; + subjectSpaceUri?: string; + accessLevel: AccessLevel; + grantedBy: string; +} + +export class CommunityAdapter { + constructor(private readonly db: Database) {} + + // ---- Communities ------------------------------------------------------- + + async createAdoptedCommunity(input: CreateAdoptedCommunityInput): Promise { + const now = Date.now(); + await this.db + .prepare( + `INSERT INTO communities (did, mode, pds_endpoint, app_password_encrypted, identifier, created_by, created_at) + VALUES (?, 'adopt', ?, ?, ?, ?, ?)` + ) + .bind( + input.did, + input.pdsEndpoint, + input.appPasswordEncrypted, + input.identifier, + input.createdBy, + now + ) + .run(); + return { + did: input.did, + mode: "adopt", + pdsEndpoint: input.pdsEndpoint, + appPasswordEncrypted: new TextEncoder().encode(input.appPasswordEncrypted), + identifier: input.identifier, + signingKeyEncrypted: null, + rotationKeyEncrypted: null, + createdBy: input.createdBy, + createdAt: now, + deletedAt: null, + }; + } + + async createMintedCommunity(input: CreateMintedCommunityInput): Promise { + const now = Date.now(); + await this.db + .prepare( + `INSERT INTO communities (did, mode, signing_key_encrypted, rotation_key_encrypted, created_by, created_at) + VALUES (?, 'mint', ?, ?, ?, ?)` + ) + .bind( + input.did, + input.signingKeyEncrypted, + input.rotationKeyEncrypted, + input.createdBy, + now + ) + .run(); + return { + did: input.did, + mode: "mint", + pdsEndpoint: null, + appPasswordEncrypted: null, + identifier: null, + signingKeyEncrypted: new TextEncoder().encode(input.signingKeyEncrypted), + rotationKeyEncrypted: new TextEncoder().encode(input.rotationKeyEncrypted), + createdBy: input.createdBy, + createdAt: now, + deletedAt: null, + }; + } + + async getCommunity(did: string): Promise { + const row = await this.db + .prepare(`SELECT * FROM communities WHERE did = ? AND deleted_at IS NULL`) + .bind(did) + .first(); + return row ? mapCommunityRow(row) : null; + } + + /** Look up the raw encrypted credential strings for the community. */ + async getRawCredentials(did: string): Promise<{ + pdsEndpoint: string | null; + appPasswordEncrypted: string | null; + signingKeyEncrypted: string | null; + rotationKeyEncrypted: string | null; + identifier: string | null; + mode: CommunityMode; + } | null> { + const row = await this.db + .prepare( + `SELECT mode, pds_endpoint, app_password_encrypted, signing_key_encrypted, + rotation_key_encrypted, identifier + FROM communities WHERE did = ? AND deleted_at IS NULL` + ) + .bind(did) + .first(); + if (!row) return null; + return { + mode: row.mode as CommunityMode, + pdsEndpoint: row.pds_endpoint ?? null, + appPasswordEncrypted: row.app_password_encrypted ?? null, + signingKeyEncrypted: row.signing_key_encrypted ?? null, + rotationKeyEncrypted: row.rotation_key_encrypted ?? null, + identifier: row.identifier ?? null, + }; + } + + async updateAdoptedCredentials(input: { + did: string; + pdsEndpoint: string; + appPasswordEncrypted: string; + identifier: string; + }): Promise { + await this.db + .prepare( + `UPDATE communities + SET pds_endpoint = ?, app_password_encrypted = ?, identifier = ? + WHERE did = ? AND mode = 'adopt'` + ) + .bind(input.pdsEndpoint, input.appPasswordEncrypted, input.identifier, input.did) + .run(); + } + + async listCommunitiesForActor(actor: string): Promise { + // Communities where the actor has any access level in any community-owned space. + const { results } = await this.db + .prepare( + `SELECT DISTINCT c.* FROM communities c + JOIN spaces s ON s.owner_did = c.did AND s.deleted_at IS NULL + JOIN community_access_levels cal + ON cal.space_uri = s.uri + AND cal.subject_kind = 'did' AND cal.subject = ? + WHERE c.deleted_at IS NULL + ORDER BY c.created_at DESC` + ) + .bind(actor) + .all(); + return results.map(mapCommunityRow); + } + + async softDeleteCommunity(did: string): Promise { + const now = Date.now(); + await this.db + .prepare(`UPDATE communities SET deleted_at = ? WHERE did = ?`) + .bind(now, did) + .run(); + // Cascade soft-delete all spaces owned by the community. + await this.db + .prepare( + `UPDATE spaces SET deleted_at = ? WHERE owner_did = ? AND deleted_at IS NULL` + ) + .bind(now, did) + .run(); + } + + // ---- Access levels ----------------------------------------------------- + + async grant(input: GrantInput): Promise { + if ((input.subjectDid ? 1 : 0) + (input.subjectSpaceUri ? 1 : 0) !== 1) { + throw new Error("grant requires exactly one of subjectDid or subjectSpaceUri"); + } + const kind = input.subjectDid ? "did" : "space"; + const subject = (input.subjectDid ?? input.subjectSpaceUri)!; + const now = Date.now(); + await this.db + .prepare( + `INSERT INTO community_access_levels (space_uri, subject, subject_kind, access_level, granted_by, granted_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (space_uri, subject) DO UPDATE SET + access_level = excluded.access_level, + granted_by = excluded.granted_by, + granted_at = excluded.granted_at` + ) + .bind(input.spaceUri, subject, kind, input.accessLevel, input.grantedBy, now) + .run(); + } + + async revoke(input: { + spaceUri: string; + subjectDid?: string; + subjectSpaceUri?: string; + }): Promise { + if ((input.subjectDid ? 1 : 0) + (input.subjectSpaceUri ? 1 : 0) !== 1) { + throw new Error("revoke requires exactly one of subjectDid or subjectSpaceUri"); + } + const subject = (input.subjectDid ?? input.subjectSpaceUri)!; + await this.db + .prepare( + `DELETE FROM community_access_levels WHERE space_uri = ? AND subject = ?` + ) + .bind(input.spaceUri, subject) + .run(); + } + + async getAccessRow( + spaceUri: string, + subject: string + ): Promise { + const row = await this.db + .prepare( + `SELECT * FROM community_access_levels WHERE space_uri = ? AND subject = ?` + ) + .bind(spaceUri, subject) + .first(); + return row ? mapAccessRow(row) : null; + } + + async listAccessRows(spaceUri: string): Promise { + const { results } = await this.db + .prepare( + `SELECT * FROM community_access_levels WHERE space_uri = ? ORDER BY granted_at ASC` + ) + .bind(spaceUri) + .all(); + return results.map(mapAccessRow); + } + + /** Spaces that reference `subjectSpaceUri` as a member — used for reverse-graph reconciliation. */ + async listSpacesDelegatingTo(subjectSpaceUri: string): Promise { + const { results } = await this.db + .prepare( + `SELECT DISTINCT space_uri FROM community_access_levels + WHERE subject_kind = 'space' AND subject = ?` + ) + .bind(subjectSpaceUri) + .all(); + return results.map((r: any) => r.space_uri); + } + + async deleteAllAccessForSpace(spaceUri: string): Promise { + await this.db + .prepare(`DELETE FROM community_access_levels WHERE space_uri = ?`) + .bind(spaceUri) + .run(); + } +} diff --git a/src/core/community/credentials.ts b/src/core/community/credentials.ts new file mode 100644 index 0000000..3acb5c2 --- /dev/null +++ b/src/core/community/credentials.ts @@ -0,0 +1,117 @@ +/** Envelope-encryption helpers for community credentials. + * AES-GCM with a 32-byte master key provided via config. */ + +const IV_LEN = 12; + +function normalizeKey(key: Uint8Array | string): Uint8Array { + if (typeof key !== "string") { + if (key.length !== 32) { + throw new Error(`community master key must be 32 bytes, got ${key.length}`); + } + return key; + } + // Try base64 first, fall back to hex. + const bytes = tryBase64(key) ?? tryHex(key); + if (!bytes) { + throw new Error( + "community master key must be a 32-byte Uint8Array or base64/hex string" + ); + } + if (bytes.length !== 32) { + throw new Error(`community master key must decode to 32 bytes, got ${bytes.length}`); + } + return bytes; +} + +function tryBase64(s: string): Uint8Array | null { + try { + const normal = s.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normal + "=".repeat((4 - (normal.length % 4)) % 4); + if (!/^[A-Za-z0-9+/]*=*$/.test(padded)) return null; + const bin = atob(padded); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } catch { + return null; + } +} + +function tryHex(s: string): Uint8Array | null { + if (!/^[0-9a-fA-F]+$/.test(s) || s.length % 2 !== 0) return null; + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +function bytesToBase64(bytes: Uint8Array): string { + let bin = ""; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin); +} + +function base64ToBytes(s: string): Uint8Array { + const bin = atob(s); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +async function importKey(raw: Uint8Array): Promise { + return crypto.subtle.importKey( + "raw", + raw as BufferSource, + { name: "AES-GCM" }, + false, + ["encrypt", "decrypt"] + ); +} + +export class CredentialCipher { + private readonly keyPromise: Promise; + + constructor(masterKey: Uint8Array | string) { + this.keyPromise = importKey(normalizeKey(masterKey)); + } + + /** Encrypts `plaintext` and returns a base64 string containing iv || ciphertext. */ + async encrypt(plaintext: string | Uint8Array): Promise { + const key = await this.keyPromise; + const iv = crypto.getRandomValues(new Uint8Array(IV_LEN)); + const bytes = + typeof plaintext === "string" + ? new TextEncoder().encode(plaintext) + : plaintext; + const ct = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: iv as BufferSource }, + key, + bytes as BufferSource + ); + const combined = new Uint8Array(iv.length + ct.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(ct), iv.length); + return bytesToBase64(combined); + } + + async decrypt(encoded: string): Promise { + const key = await this.keyPromise; + const combined = base64ToBytes(encoded); + if (combined.length <= IV_LEN) { + throw new Error("ciphertext too short"); + } + const iv = combined.subarray(0, IV_LEN); + const ct = combined.subarray(IV_LEN); + const pt = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: iv as BufferSource }, + key, + ct as BufferSource + ); + return new Uint8Array(pt); + } + + async decryptString(encoded: string): Promise { + return new TextDecoder().decode(await this.decrypt(encoded)); + } +} diff --git a/src/core/community/index.ts b/src/core/community/index.ts new file mode 100644 index 0000000..5d3852e --- /dev/null +++ b/src/core/community/index.ts @@ -0,0 +1,33 @@ +export { registerCommunityRoutes } from "./router"; +export type { CommunityRoutesOptions } from "./router"; +export { CommunityAdapter } from "./adapter"; +export type { + AccessLevel, + AccessLevelRow, + CommunityConfig, + CommunityMode, + CommunityRow, + ReservedKey, +} from "./types"; +export { + ACCESS_LEVELS, + RESERVED_KEYS, + isAccessLevel, + isReservedKey, + rankOf, +} from "./types"; +export { CredentialCipher } from "./credentials"; +export { resolveEffectiveLevel, flattenEffectiveMembers, wouldCycle } from "./acl"; +export { reconcile } from "./reconcile"; +export { initCommunitySchema, buildCommunitySchema } from "./schema"; +export { resolveIdentity, createPdsSession } from "./pds"; +export { + generateKeyPair, + buildGenesisOp, + signGenesisOp, + computeDidPlc, + submitGenesisOp, + encodeDagCbor, + jwkToDidKey, +} from "./plc"; +export type { KeyPair, GenesisOpInput, UnsignedGenesisOp, SignedGenesisOp } from "./plc"; diff --git a/src/core/community/pds.ts b/src/core/community/pds.ts new file mode 100644 index 0000000..2f73181 --- /dev/null +++ b/src/core/community/pds.ts @@ -0,0 +1,137 @@ +/** Helpers for interacting with a community's PDS account — resolving identity + * and creating sessions from stored app passwords. */ + +import { + CompositeDidDocumentResolver, + PlcDidDocumentResolver, + WebDidDocumentResolver, + type DidDocumentResolver, +} from "@atcute/identity-resolver"; + +export interface ResolvedIdentity { + did: string; + handle: string | null; + pdsEndpoint: string; +} + +export interface PdsSession { + accessJwt: string; + refreshJwt: string; + did: string; +} + +function defaultResolver(): DidDocumentResolver { + return new CompositeDidDocumentResolver({ + methods: { + plc: new PlcDidDocumentResolver(), + web: new WebDidDocumentResolver(), + }, + }) as unknown as DidDocumentResolver; +} + +/** Given a handle or DID, resolve to { did, pdsEndpoint } using the DID doc's + * `atproto_pds` service entry. */ +export async function resolveIdentity( + identifier: string, + opts: { resolver?: DidDocumentResolver; fetch?: typeof fetch } = {} +): Promise { + const f = opts.fetch ?? fetch; + let did = identifier; + let handle: string | null = null; + + if (!identifier.startsWith("did:")) { + // Handle → DID via /.well-known or _atproto DNS. + did = await resolveHandleToDid(identifier, f); + handle = identifier; + } + + const resolver = opts.resolver ?? defaultResolver(); + const doc = await (resolver as any).resolve(did); + if (!doc) throw new Error(`could not resolve DID document for ${did}`); + + const services: Array<{ id?: string; type?: string; serviceEndpoint?: string }> = + (doc as any).service ?? []; + const pds = services.find( + (s) => + s.type === "AtprotoPersonalDataServer" || + s.id === "#atproto_pds" || + s.id?.endsWith("#atproto_pds") + ); + if (!pds?.serviceEndpoint) { + throw new Error(`DID document for ${did} has no atproto_pds service`); + } + + return { did, handle, pdsEndpoint: pds.serviceEndpoint }; +} + +async function resolveHandleToDid(handle: string, f: typeof fetch): Promise { + // Try /.well-known/atproto-did first (cheaper, no DNS). + try { + const res = await f(`https://${handle}/.well-known/atproto-did`, { + redirect: "follow", + }); + if (res.ok) { + const did = (await res.text()).trim(); + if (did.startsWith("did:")) return did; + } + } catch { + /* fall through */ + } + // Fallback: DNS TXT _atproto.. Not available in Workers without + // a DNS-over-HTTPS provider; use Cloudflare's 1.1.1.1 as a default. + try { + const res = await f( + `https://cloudflare-dns.com/dns-query?name=_atproto.${handle}&type=TXT`, + { headers: { accept: "application/dns-json" } } + ); + if (res.ok) { + const body = (await res.json()) as { + Answer?: Array<{ data?: string; type?: number }>; + }; + for (const ans of body.Answer ?? []) { + if (ans.type === 16 && ans.data) { + const trimmed = ans.data.replace(/^"|"$/g, ""); + const m = /^did=(did:[^"\s]+)$/.exec(trimmed); + if (m) return m[1]!; + } + } + } + } catch { + /* fall through */ + } + throw new Error(`could not resolve handle ${handle}`); +} + +/** Create an atproto session on the given PDS using identifier + app password. + * Returns the access/refresh JWTs and the session's DID. */ +export async function createPdsSession( + pdsEndpoint: string, + identifier: string, + appPassword: string, + opts: { fetch?: typeof fetch } = {} +): Promise { + const f = opts.fetch ?? fetch; + const url = `${pdsEndpoint.replace(/\/$/, "")}/xrpc/com.atproto.server.createSession`; + const res = await f(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ identifier, password: appPassword }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`createSession failed (${res.status}): ${text}`); + } + const body = (await res.json()) as { + accessJwt?: string; + refreshJwt?: string; + did?: string; + }; + if (!body.accessJwt || !body.refreshJwt || !body.did) { + throw new Error("createSession response missing expected fields"); + } + return { + accessJwt: body.accessJwt, + refreshJwt: body.refreshJwt, + did: body.did, + }; +} diff --git a/src/core/community/plc.ts b/src/core/community/plc.ts new file mode 100644 index 0000000..b5dcc1c --- /dev/null +++ b/src/core/community/plc.ts @@ -0,0 +1,312 @@ +/** did:plc minting: key generation, genesis op construction, signing, + * submission. Zero external deps — uses Web Crypto for P-256 and a + * minimal hand-rolled DAG-CBOR encoder for the specific op shape. */ + +// ============================================================================ +// Key handling (P-256 / ES256) +// ============================================================================ + +export interface KeyPair { + /** JWK of the private key; stored encrypted. */ + privateJwk: JsonWebKey; + /** did:key multibase encoding of the public key. */ + publicDidKey: string; +} + +export async function generateKeyPair(): Promise { + const pair = (await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"] + )) as CryptoKeyPair; + const privateJwk = (await crypto.subtle.exportKey("jwk", pair.privateKey)) as JsonWebKey; + const publicJwk = (await crypto.subtle.exportKey("jwk", pair.publicKey)) as JsonWebKey; + return { privateJwk, publicDidKey: jwkToDidKey(publicJwk) }; +} + +/** Convert a P-256 JWK public key to did:key format. + * multicodec for P-256 pub: 0x1200 (varint: [0x80, 0x24]). + * did:key:zBase58btc(multicodec || compressed-pub-key). */ +export function jwkToDidKey(jwk: JsonWebKey): string { + if (jwk.kty !== "EC" || jwk.crv !== "P-256" || !jwk.x || !jwk.y) { + throw new Error("expected EC P-256 JWK"); + } + const x = b64urlToBytes(jwk.x); + const y = b64urlToBytes(jwk.y); + if (x.length !== 32 || y.length !== 32) { + throw new Error("malformed P-256 JWK"); + } + // Compressed form: 0x02 if y is even, 0x03 if odd, then x. + const prefix = (y[31]! & 1) === 0 ? 0x02 : 0x03; + const compressed = new Uint8Array(33); + compressed[0] = prefix; + compressed.set(x, 1); + const multicodec = new Uint8Array([0x80, 0x24]); + const combined = new Uint8Array(multicodec.length + compressed.length); + combined.set(multicodec, 0); + combined.set(compressed, multicodec.length); + return "did:key:z" + base58btcEncode(combined); +} + +async function signBytes(privateJwk: JsonWebKey, bytes: Uint8Array): Promise { + const key = await crypto.subtle.importKey( + "jwk", + privateJwk, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign"] + ); + const sig = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + key, + bytes as BufferSource + ); + return new Uint8Array(sig); +} + +// ============================================================================ +// Genesis op construction +// ============================================================================ + +export interface GenesisOpInput { + rotationKeys: string[]; // did:key strings + verificationMethodAtproto: string; // did:key + services?: Record; + alsoKnownAs?: string[]; +} + +export interface UnsignedGenesisOp { + type: "plc_operation"; + rotationKeys: string[]; + verificationMethods: Record; + alsoKnownAs: string[]; + services: Record; + prev: null; +} + +export interface SignedGenesisOp extends UnsignedGenesisOp { + sig: string; // base64url, unpadded +} + +export function buildGenesisOp(input: GenesisOpInput): UnsignedGenesisOp { + return { + type: "plc_operation", + rotationKeys: input.rotationKeys, + verificationMethods: { atproto: input.verificationMethodAtproto }, + alsoKnownAs: input.alsoKnownAs ?? [], + services: input.services ?? {}, + prev: null, + }; +} + +/** Sign a genesis op with a rotation key's private JWK. */ +export async function signGenesisOp( + op: UnsignedGenesisOp, + signerPrivateJwk: JsonWebKey +): Promise { + const encoded = encodeDagCbor(op); + const sigBytes = await signBytes(signerPrivateJwk, encoded); + return { ...op, sig: bytesToB64url(sigBytes) }; +} + +/** Compute the did:plc from a signed genesis op. + * The DID is `did:plc:` + base32-lower-unpadded(sha256(cbor(signedOp)))[:24]. */ +export async function computeDidPlc(signedOp: SignedGenesisOp): Promise { + const encoded = encodeDagCbor(signedOp); + const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", encoded as BufferSource)); + return "did:plc:" + base32Lower(hash).slice(0, 24); +} + +/** Submit a signed genesis op to the PLC directory. */ +export async function submitGenesisOp( + plcDirectory: string, + did: string, + signedOp: SignedGenesisOp, + opts: { fetch?: typeof fetch } = {} +): Promise { + const f = opts.fetch ?? fetch; + const url = `${plcDirectory.replace(/\/$/, "")}/${did}`; + const res = await f(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(signedOp), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`PLC submit failed (${res.status}): ${text}`); + } +} + +// ============================================================================ +// Minimal DAG-CBOR encoder +// Only supports the types needed for PLC genesis ops: +// null, text strings, arrays, maps with string keys. +// Maps are canonicalized: keys sorted by their CBOR byte encoding, +// ascending lexicographically. Integers use the smallest encoding. +// See RFC 8949 + https://ipld.io/specs/codecs/dag-cbor/spec/ +// ============================================================================ + +export function encodeDagCbor(value: unknown): Uint8Array { + const chunks: Uint8Array[] = []; + encode(value, chunks); + return concat(chunks); +} + +function encode(value: unknown, out: Uint8Array[]): void { + if (value === null) { + out.push(new Uint8Array([0xf6])); + return; + } + if (value === false) { + out.push(new Uint8Array([0xf4])); + return; + } + if (value === true) { + out.push(new Uint8Array([0xf5])); + return; + } + if (typeof value === "string") { + const bytes = new TextEncoder().encode(value); + encodeHead(3, bytes.length, out); + out.push(bytes); + return; + } + if (typeof value === "number") { + if (!Number.isInteger(value)) { + throw new Error("DAG-CBOR: floats not supported in PLC op encoder"); + } + if (value >= 0) { + encodeHead(0, value, out); + } else { + encodeHead(1, -value - 1, out); + } + return; + } + if (Array.isArray(value)) { + encodeHead(4, value.length, out); + for (const v of value) encode(v, out); + return; + } + if (typeof value === "object") { + // Map with string keys, canonicalized. + const entries = Object.entries(value as Record).filter( + ([, v]) => v !== undefined + ); + // Sort keys by their CBOR-encoded byte form. + // For DAG-CBOR with text-string keys, this equals: + // (a) shorter UTF-8 byte length first, (b) lexicographic byte order within same length. + entries.sort(([a], [b]) => { + const ab = new TextEncoder().encode(a); + const bb = new TextEncoder().encode(b); + if (ab.length !== bb.length) return ab.length - bb.length; + for (let i = 0; i < ab.length; i++) { + if (ab[i] !== bb[i]) return ab[i]! - bb[i]!; + } + return 0; + }); + encodeHead(5, entries.length, out); + for (const [k, v] of entries) { + encode(k, out); + encode(v, out); + } + return; + } + throw new Error(`DAG-CBOR: unsupported value type ${typeof value}`); +} + +function encodeHead(majorType: number, n: number, out: Uint8Array[]): void { + const mt = majorType << 5; + if (n < 24) { + out.push(new Uint8Array([mt | n])); + } else if (n < 0x100) { + out.push(new Uint8Array([mt | 24, n])); + } else if (n < 0x10000) { + out.push(new Uint8Array([mt | 25, (n >> 8) & 0xff, n & 0xff])); + } else if (n < 0x100000000) { + const b = new Uint8Array(5); + b[0] = mt | 26; + b[1] = (n >>> 24) & 0xff; + b[2] = (n >>> 16) & 0xff; + b[3] = (n >>> 8) & 0xff; + b[4] = n & 0xff; + out.push(b); + } else { + throw new Error("DAG-CBOR: integer too large"); + } +} + +function concat(parts: Uint8Array[]): Uint8Array { + let total = 0; + for (const p of parts) total += p.length; + const out = new Uint8Array(total); + let off = 0; + for (const p of parts) { + out.set(p, off); + off += p.length; + } + return out; +} + +// ============================================================================ +// Base encoding helpers (base58btc, base32-lower-unpadded, base64url) +// ============================================================================ + +const B58_ALPHA = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +function base58btcEncode(bytes: Uint8Array): string { + if (bytes.length === 0) return ""; + // Count leading zeros. + let zeros = 0; + while (zeros < bytes.length && bytes[zeros] === 0) zeros++; + // Repeated divmod by 58 using big-endian byte buffer. + const input = Array.from(bytes); + let encoded = ""; + let start = zeros; + while (start < input.length) { + let carry = 0; + for (let i = start; i < input.length; i++) { + const v = (carry << 8) + input[i]!; + input[i] = Math.floor(v / 58); + carry = v % 58; + } + encoded = B58_ALPHA[carry]! + encoded; + while (start < input.length && input[start] === 0) start++; + } + return "1".repeat(zeros) + encoded; +} + +const B32_ALPHA = "abcdefghijklmnopqrstuvwxyz234567"; + +function base32Lower(bytes: Uint8Array): string { + let bits = 0; + let value = 0; + let out = ""; + for (let i = 0; i < bytes.length; i++) { + value = (value << 8) | bytes[i]!; + bits += 8; + while (bits >= 5) { + bits -= 5; + out += B32_ALPHA[(value >>> bits) & 31]; + } + } + if (bits > 0) { + out += B32_ALPHA[(value << (5 - bits)) & 31]; + } + return out; +} + +function bytesToB64url(bytes: Uint8Array): string { + let bin = ""; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function b64urlToBytes(s: string): Uint8Array { + const normal = s.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normal + "=".repeat((4 - (normal.length % 4)) % 4); + const bin = atob(padded); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} diff --git a/src/core/community/reconcile.ts b/src/core/community/reconcile.ts new file mode 100644 index 0000000..b9d7e35 --- /dev/null +++ b/src/core/community/reconcile.ts @@ -0,0 +1,42 @@ +import type { StorageAdapter as SpacesAdapter } from "../spaces/types"; +import type { CommunityAdapter } from "./adapter"; +import { flattenEffectiveMembers } from "./acl"; + +/** Reconcile `spaces_members` for `spaceUri` to match the flattened effective + * member set derived from `community_access_levels`. Also re-reconciles any + * spaces that delegate to this one (reverse-graph). */ +export async function reconcile( + community: CommunityAdapter, + spaces: SpacesAdapter, + spaceUri: string, + byDid: string, + opts: { depth?: number; maxReverseDepth?: number } = {} +): Promise { + const maxReverse = opts.maxReverseDepth ?? 8; + const startDepth = opts.depth ?? 0; + if (startDepth > maxReverse) return; + + const effective = await flattenEffectiveMembers(community, spaceUri); + const current = new Set( + (await spaces.listMembers(spaceUri)).map((m) => m.did) + ); + + const adds: string[] = []; + const removes: string[] = []; + for (const did of effective) if (!current.has(did)) adds.push(did); + for (const did of current) if (!effective.has(did)) removes.push(did); + + if (adds.length || removes.length) { + await spaces.applyMembershipDiff(spaceUri, adds, removes, byDid); + } + + // Reverse-graph: if this space is a subject of other spaces' access rows, + // their effective membership may have changed too. + const parents = await community.listSpacesDelegatingTo(spaceUri); + for (const parent of parents) { + await reconcile(community, spaces, parent, byDid, { + depth: startDepth + 1, + maxReverseDepth: maxReverse, + }); + } +} diff --git a/src/core/community/router.ts b/src/core/community/router.ts new file mode 100644 index 0000000..07be342 --- /dev/null +++ b/src/core/community/router.ts @@ -0,0 +1,1008 @@ +import type { Context, Hono, MiddlewareHandler } from "hono"; +import type { ContrailConfig, Database } from "../types"; +import type { ServiceAuth } from "../spaces/auth"; +import type { StorageAdapter as SpacesAdapter } from "../spaces/types"; +import { buildSpaceUri } from "../spaces/uri"; +import { HostedAdapter } from "../spaces/adapter"; +import { CommunityAdapter } from "./adapter"; +import { CredentialCipher } from "./credentials"; +import { resolveIdentity, createPdsSession } from "./pds"; +import { + generateKeyPair, + buildGenesisOp, + signGenesisOp, + computeDidPlc, + submitGenesisOp, +} from "./plc"; +import { resolveEffectiveLevel, wouldCycle } from "./acl"; +import { reconcile } from "./reconcile"; +import type { AccessLevel } from "./types"; +import { + ACCESS_LEVELS, + rankOf, + isAccessLevel, + isReservedKey, + RESERVED_KEYS, +} from "./types"; +import type { ServiceJwtVerifier } from "@atcute/xrpc-server/auth"; + +export interface CommunityRoutesOptions { + /** Override auth middleware for tests. */ + authMiddleware?: MiddlewareHandler; + /** Storage adapter overrides (defaults: HostedAdapter on the given db). */ + communityAdapter?: CommunityAdapter; + spacesAdapter?: SpacesAdapter; +} + +export function registerCommunityRoutes( + app: Hono, + db: Database, + config: ContrailConfig, + options: CommunityRoutesOptions = {}, + ctx?: { + spacesAdapter: SpacesAdapter; + verifier: ServiceJwtVerifier; + } | null +): void { + const cfg = config.community; + if (!cfg) return; + if (!config.spaces) { + throw new Error("community module requires spaces to be enabled in config"); + } + + const community = + options.communityAdapter ?? new CommunityAdapter(db); + const spaces = + options.spacesAdapter ?? ctx?.spacesAdapter ?? new HostedAdapter(db, config); + const cipher = new CredentialCipher(cfg.masterKey); + + const auth = + options.authMiddleware ?? + (() => { + throw new Error( + "community routes require an authMiddleware. Pass options.authMiddleware or ensure spaces.authMiddleware is configured." + ); + })(); + + const NS = `${config.namespace}.community`; + const spaceType = config.spaces.type; + const spaceServiceDid = cfg.serviceDid ?? config.spaces.serviceDid; + + // ========================================================================== + // Community lifecycle + // ========================================================================== + + app.post(`/xrpc/${NS}.adopt`, auth, async (c) => { + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as + | { identifier?: string; appPassword?: string } + | null; + if (!body?.identifier || !body.appPassword) { + return c.json( + { error: "InvalidRequest", message: "identifier and appPassword required" }, + 400 + ); + } + + // 1. Resolve identifier → DID + PDS endpoint. + let resolved; + try { + resolved = await resolveIdentity(body.identifier, { + resolver: cfg.resolver, + fetch: cfg.fetch, + }); + } catch (err: any) { + return c.json( + { error: "InvalidRequest", message: `could not resolve: ${err.message}` }, + 400 + ); + } + + // 2. Verify the credentials by creating a session. + try { + await createPdsSession( + resolved.pdsEndpoint, + body.identifier, + body.appPassword, + { fetch: cfg.fetch } + ); + } catch (err: any) { + return c.json( + { error: "Unauthorized", message: `credential check failed: ${err.message}` }, + 401 + ); + } + + // 3. Check not already adopted. + const existing = await community.getCommunity(resolved.did); + if (existing) { + return c.json({ error: "AlreadyExists", did: resolved.did }, 409); + } + + // 4. Encrypt + store. + const encrypted = await cipher.encrypt(body.appPassword); + await community.createAdoptedCommunity({ + did: resolved.did, + pdsEndpoint: resolved.pdsEndpoint, + appPasswordEncrypted: encrypted, + identifier: body.identifier, + createdBy: sa.issuer, + }); + + // 5. Bootstrap reserved spaces with caller as owner. + await bootstrapReservedSpaces({ + communityDid: resolved.did, + creatorDid: sa.issuer, + spaces, + community, + type: spaceType, + serviceDid: spaceServiceDid, + }); + + return c.json({ communityDid: resolved.did }); + }); + + app.post(`/xrpc/${NS}.mint`, auth, async (c) => { + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as + | { handle?: string; pdsEndpoint?: string } + | null; + + // Generate three keypairs: signing (atproto verificationMethod), + // contrail-held rotation, creator-held rotation (recovery). + const signingKey = await generateKeyPair(); + const contrailRotation = await generateKeyPair(); + const creatorRotation = await generateKeyPair(); + + // Build + sign genesis op using contrail's rotation key. + const unsigned = buildGenesisOp({ + rotationKeys: [contrailRotation.publicDidKey, creatorRotation.publicDidKey], + verificationMethodAtproto: signingKey.publicDidKey, + alsoKnownAs: body?.handle ? [`at://${body.handle}`] : [], + services: body?.pdsEndpoint + ? { + atproto_pds: { + type: "AtprotoPersonalDataServer", + endpoint: body.pdsEndpoint, + }, + } + : {}, + }); + const signed = await signGenesisOp(unsigned, contrailRotation.privateJwk); + const did = await computeDidPlc(signed); + + // Submit to PLC directory. + const plcDir = cfg.plcDirectory ?? "https://plc.directory"; + try { + await submitGenesisOp(plcDir, did, signed, { fetch: cfg.fetch }); + } catch (err: any) { + return c.json( + { error: "UpstreamFailure", message: err.message }, + 502 + ); + } + + // Encrypt stored keys (signing + contrail rotation). The creator's + // rotation key is returned once and never stored. + const signingEncrypted = await cipher.encrypt(JSON.stringify(signingKey.privateJwk)); + const contrailRotEncrypted = await cipher.encrypt( + JSON.stringify(contrailRotation.privateJwk) + ); + + await community.createMintedCommunity({ + did, + signingKeyEncrypted: signingEncrypted, + rotationKeyEncrypted: contrailRotEncrypted, + createdBy: sa.issuer, + }); + + await bootstrapReservedSpaces({ + communityDid: did, + creatorDid: sa.issuer, + spaces, + community, + type: spaceType, + serviceDid: spaceServiceDid, + }); + + return c.json({ + communityDid: did, + recoveryKey: creatorRotation.privateJwk, + }); + }); + + app.post(`/xrpc/${NS}.delete`, auth, async (c) => { + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as + | { communityDid?: string } + | null; + if (!body?.communityDid) { + return c.json({ error: "InvalidRequest", message: "communityDid required" }, 400); + } + const row = await community.getCommunity(body.communityDid); + if (!row) return c.json({ error: "NotFound" }, 404); + + const adminUri = buildSpaceUri({ + ownerDid: body.communityDid, + type: spaceType, + key: "$admin", + }); + const level = await resolveEffectiveLevel(community, adminUri, sa.issuer); + if (level !== "owner") { + return c.json({ error: "Forbidden", reason: "owner-required" }, 403); + } + + await community.softDeleteCommunity(body.communityDid); + return c.json({ ok: true }); + }); + + app.get(`/xrpc/${NS}.list`, auth, async (c) => { + const sa = getAuth(c); + const actor = c.req.query("actor") ?? sa.issuer; + const rows = await community.listCommunitiesForActor(actor); + return c.json({ + communities: rows.map((r) => ({ + did: r.did, + mode: r.mode, + identifier: r.identifier, + createdAt: r.createdAt, + })), + }); + }); + + app.get(`/xrpc/${NS}.whoami`, auth, async (c) => { + const sa = getAuth(c); + const spaceUri = c.req.query("spaceUri"); + if (!spaceUri) { + return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); + } + const level = await resolveEffectiveLevel(community, spaceUri, sa.issuer); + return c.json({ spaceUri, accessLevel: level }); + }); + + // ========================================================================== + // Space lifecycle + // ========================================================================== + + app.post(`/xrpc/${NS}.space.create`, auth, async (c) => { + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as + | { communityDid?: string; key?: string } + | null; + if (!body?.communityDid) { + return c.json({ error: "InvalidRequest", message: "communityDid required" }, 400); + } + + const communityRow = await community.getCommunity(body.communityDid); + if (!communityRow) return c.json({ error: "NotFound", reason: "community-not-found" }, 404); + + if (body.key && isReservedKey(body.key)) { + return c.json( + { error: "InvalidRequest", reason: "reserved-key", message: `reserved keys cannot be created manually` }, + 400 + ); + } + + // Caller must have admin+ in $admin. + const adminUri = buildSpaceUri({ + ownerDid: body.communityDid, + type: spaceType, + key: "$admin", + }); + const level = await resolveEffectiveLevel(community, adminUri, sa.issuer); + if (!level || rankOf(level) < rankOf("admin")) { + return c.json({ error: "Forbidden", reason: "admin-required-in-admin" }, 403); + } + + const key = body.key ?? generateKey(); + const uri = buildSpaceUri({ ownerDid: body.communityDid, type: spaceType, key }); + + const existing = await spaces.getSpace(uri); + if (existing) return c.json({ error: "AlreadyExists", uri }, 409); + + await spaces.createSpace({ + uri, + ownerDid: body.communityDid, + type: spaceType, + key, + serviceDid: spaceServiceDid, + appPolicyRef: null, + appPolicy: null, + }); + + // Creator becomes owner of the new space. + await community.grant({ + spaceUri: uri, + subjectDid: sa.issuer, + accessLevel: "owner", + grantedBy: sa.issuer, + }); + await reconcile(community, spaces, uri, sa.issuer); + + return c.json({ + space: { + uri, + ownerDid: body.communityDid, + type: spaceType, + key, + serviceDid: spaceServiceDid, + createdAt: Date.now(), + }, + }); + }); + + app.post(`/xrpc/${NS}.space.delete`, auth, async (c) => { + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as { spaceUri?: string } | null; + if (!body?.spaceUri) { + return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); + } + const space = await spaces.getSpace(body.spaceUri); + if (!space) return c.json({ error: "NotFound" }, 404); + + const communityRow = await community.getCommunity(space.ownerDid); + if (!communityRow) { + return c.json({ error: "InvalidRequest", reason: "not-community-owned" }, 400); + } + + // Block deletion of reserved spaces. + if (isReservedKey(space.key)) { + return c.json({ error: "Forbidden", reason: "reserved-space-cannot-be-deleted" }, 403); + } + + // Caller must be owner of this space OR admin+ in $admin. + const onSpace = await resolveEffectiveLevel(community, body.spaceUri, sa.issuer); + const onAdmin = await resolveEffectiveLevel( + community, + buildSpaceUri({ ownerDid: space.ownerDid, type: spaceType, key: "$admin" }), + sa.issuer + ); + const allowed = + onSpace === "owner" || (onAdmin != null && rankOf(onAdmin) >= rankOf("admin")); + if (!allowed) { + return c.json({ error: "Forbidden", reason: "owner-or-admin-required" }, 403); + } + + await spaces.deleteSpace(body.spaceUri); + await community.deleteAllAccessForSpace(body.spaceUri); + // Drop the materialized membership too. + const members = await spaces.listMembers(body.spaceUri); + if (members.length) { + await spaces.applyMembershipDiff( + body.spaceUri, + [], + members.map((m) => m.did), + sa.issuer + ); + } + return c.json({ ok: true }); + }); + + // ========================================================================== + // Membership + // ========================================================================== + + app.post(`/xrpc/${NS}.space.grant`, auth, async (c) => { + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as + | { + spaceUri?: string; + subject?: { did?: string; spaceUri?: string }; + accessLevel?: string; + } + | null; + if (!body?.spaceUri || !body.subject || !body.accessLevel) { + return c.json( + { error: "InvalidRequest", message: "spaceUri, subject, accessLevel required" }, + 400 + ); + } + if (!isAccessLevel(body.accessLevel)) { + return c.json( + { error: "InvalidRequest", message: `invalid accessLevel; one of ${ACCESS_LEVELS.join(", ")}` }, + 400 + ); + } + const subjectDid = body.subject.did; + const subjectSpaceUri = body.subject.spaceUri; + if ((subjectDid ? 1 : 0) + (subjectSpaceUri ? 1 : 0) !== 1) { + return c.json( + { error: "InvalidRequest", message: "subject must have exactly one of did or spaceUri" }, + 400 + ); + } + + const space = await spaces.getSpace(body.spaceUri); + if (!space) return c.json({ error: "NotFound" }, 404); + + const communityRow = await community.getCommunity(space.ownerDid); + if (!communityRow) { + return c.json({ error: "InvalidRequest", reason: "not-community-owned" }, 400); + } + + // Caller must have at least manager. + const callerLevel = await resolveEffectiveLevel(community, body.spaceUri, sa.issuer); + if (!callerLevel || rankOf(callerLevel) < rankOf("manager")) { + return c.json({ error: "Forbidden", reason: "manager-required" }, 403); + } + // Cannot grant higher than own level. + if (rankOf(body.accessLevel) > rankOf(callerLevel)) { + return c.json({ error: "Forbidden", reason: "cannot-grant-higher-than-self" }, 403); + } + + // Cycle check when delegating to another space. + if (subjectSpaceUri) { + if (subjectSpaceUri === body.spaceUri) { + return c.json({ error: "InvalidRequest", reason: "self-reference" }, 400); + } + if (await wouldCycle(community, body.spaceUri, subjectSpaceUri)) { + return c.json({ error: "InvalidRequest", reason: "cycle-detected" }, 400); + } + } + + await community.grant({ + spaceUri: body.spaceUri, + subjectDid, + subjectSpaceUri, + accessLevel: body.accessLevel, + grantedBy: sa.issuer, + }); + await reconcile(community, spaces, body.spaceUri, sa.issuer); + + return c.json({ ok: true }); + }); + + app.post(`/xrpc/${NS}.space.revoke`, auth, async (c) => { + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as + | { + spaceUri?: string; + subject?: { did?: string; spaceUri?: string }; + } + | null; + if (!body?.spaceUri || !body.subject) { + return c.json( + { error: "InvalidRequest", message: "spaceUri and subject required" }, + 400 + ); + } + const subjectDid = body.subject.did; + const subjectSpaceUri = body.subject.spaceUri; + if ((subjectDid ? 1 : 0) + (subjectSpaceUri ? 1 : 0) !== 1) { + return c.json( + { error: "InvalidRequest", message: "subject must have exactly one of did or spaceUri" }, + 400 + ); + } + + const space = await spaces.getSpace(body.spaceUri); + if (!space) return c.json({ error: "NotFound" }, 404); + + // Caller must outrank the target's current level. + const subject = subjectDid ?? subjectSpaceUri!; + const existing = await community.getAccessRow(body.spaceUri, subject); + if (!existing) { + return c.json({ error: "NotFound", reason: "no-such-grant" }, 404); + } + const callerLevel = await resolveEffectiveLevel(community, body.spaceUri, sa.issuer); + if (!callerLevel || rankOf(callerLevel) < rankOf("manager")) { + return c.json({ error: "Forbidden", reason: "manager-required" }, 403); + } + if (rankOf(existing.accessLevel) > rankOf(callerLevel)) { + return c.json({ error: "Forbidden", reason: "cannot-revoke-higher-than-self" }, 403); + } + + await community.revoke({ spaceUri: body.spaceUri, subjectDid, subjectSpaceUri }); + await reconcile(community, spaces, body.spaceUri, sa.issuer); + + return c.json({ ok: true }); + }); + + app.get(`/xrpc/${NS}.space.listMembers`, auth, async (c) => { + const sa = getAuth(c); + const spaceUri = c.req.query("spaceUri"); + if (!spaceUri) { + return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); + } + const flatten = c.req.query("flatten") === "true"; + const space = await spaces.getSpace(spaceUri); + if (!space) return c.json({ error: "NotFound" }, 404); + + // Caller must have at least member. + const callerLevel = await resolveEffectiveLevel(community, spaceUri, sa.issuer); + if (!callerLevel) { + return c.json({ error: "Forbidden", reason: "not-member" }, 403); + } + + if (flatten) { + const members = await spaces.listMembers(spaceUri); + return c.json({ members: members.map((m) => ({ did: m.did, addedAt: m.addedAt })) }); + } + + const rows = await community.listAccessRows(spaceUri); + return c.json({ + rows: rows.map((r) => ({ + subject: r.subjectDid + ? { did: r.subjectDid } + : { spaceUri: r.subjectSpaceUri }, + accessLevel: r.accessLevel, + grantedBy: r.grantedBy, + grantedAt: r.grantedAt, + })), + }); + }); + + app.post(`/xrpc/${NS}.space.setAccessLevel`, auth, async (c) => { + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as + | { + spaceUri?: string; + subject?: { did?: string; spaceUri?: string }; + accessLevel?: string; + } + | null; + if (!body?.spaceUri || !body.subject || !body.accessLevel) { + return c.json( + { error: "InvalidRequest", message: "spaceUri, subject, accessLevel required" }, + 400 + ); + } + if (!isAccessLevel(body.accessLevel)) { + return c.json({ error: "InvalidRequest", message: "invalid accessLevel" }, 400); + } + const subjectDid = body.subject.did; + const subjectSpaceUri = body.subject.spaceUri; + if ((subjectDid ? 1 : 0) + (subjectSpaceUri ? 1 : 0) !== 1) { + return c.json( + { error: "InvalidRequest", message: "subject must have exactly one of did or spaceUri" }, + 400 + ); + } + + const space = await spaces.getSpace(body.spaceUri); + if (!space) return c.json({ error: "NotFound" }, 404); + + const subject = subjectDid ?? subjectSpaceUri!; + const existing = await community.getAccessRow(body.spaceUri, subject); + if (!existing) return c.json({ error: "NotFound", reason: "no-such-grant" }, 404); + + const callerLevel = await resolveEffectiveLevel(community, body.spaceUri, sa.issuer); + if (!callerLevel || rankOf(callerLevel) < rankOf("manager")) { + return c.json({ error: "Forbidden", reason: "manager-required" }, 403); + } + // Caller must outrank both the old level AND the new one. + if (rankOf(existing.accessLevel) > rankOf(callerLevel)) { + return c.json({ error: "Forbidden", reason: "cannot-modify-higher-than-self" }, 403); + } + if (rankOf(body.accessLevel) > rankOf(callerLevel)) { + return c.json({ error: "Forbidden", reason: "cannot-grant-higher-than-self" }, 403); + } + + await community.grant({ + spaceUri: body.spaceUri, + subjectDid, + subjectSpaceUri, + accessLevel: body.accessLevel, + grantedBy: sa.issuer, + }); + await reconcile(community, spaces, body.spaceUri, sa.issuer); + return c.json({ ok: true }); + }); + + app.post(`/xrpc/${NS}.space.resync`, auth, async (c) => { + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as { spaceUri?: string } | null; + if (!body?.spaceUri) { + return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); + } + const space = await spaces.getSpace(body.spaceUri); + if (!space) return c.json({ error: "NotFound" }, 404); + + const level = await resolveEffectiveLevel(community, body.spaceUri, sa.issuer); + if (!level || rankOf(level) < rankOf("admin")) { + return c.json({ error: "Forbidden", reason: "admin-required" }, 403); + } + + await reconcile(community, spaces, body.spaceUri, sa.issuer); + return c.json({ ok: true }); + }); + + // ========================================================================== + // Publishing — public records (via community PDS) and in-space records + // (authored by the community DID) + // ========================================================================== + + app.post(`/xrpc/${NS}.putRecord`, auth, async (c) => { + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as + | { + communityDid?: string; + collection?: string; + rkey?: string; + record?: Record; + validate?: boolean; + } + | null; + if (!body?.communityDid || !body.collection || !body.record) { + return c.json( + { error: "InvalidRequest", message: "communityDid, collection, record required" }, + 400 + ); + } + + const row = await community.getCommunity(body.communityDid); + if (!row) return c.json({ error: "NotFound" }, 404); + + if (row.mode === "mint") { + return c.json( + { error: "NotSupported", reason: "publishing-not-supported-for-minted-communities" }, + 400 + ); + } + + // Caller must be member+ in $publishers. + const publishersUri = buildSpaceUri({ + ownerDid: body.communityDid, + type: spaceType, + key: "$publishers", + }); + const level = await resolveEffectiveLevel(community, publishersUri, sa.issuer); + if (!level) { + return c.json({ error: "Forbidden", reason: "not-in-publishers" }, 403); + } + + // Decrypt the stored app password and create a session. + const raw = await community.getRawCredentials(body.communityDid); + if (!raw?.appPasswordEncrypted || !raw.pdsEndpoint || !raw.identifier) { + return c.json( + { error: "InvalidState", reason: "missing-credentials" }, + 500 + ); + } + let session; + try { + const appPassword = await cipher.decryptString(raw.appPasswordEncrypted); + session = await createPdsSession(raw.pdsEndpoint, raw.identifier, appPassword, { + fetch: cfg.fetch, + }); + } catch (err: any) { + return c.json( + { error: "UpstreamFailure", reason: "session-creation-failed", message: err.message }, + 502 + ); + } + + // Proxy createRecord. + const f = cfg.fetch ?? fetch; + const res = await f( + `${raw.pdsEndpoint.replace(/\/$/, "")}/xrpc/com.atproto.repo.createRecord`, + { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${session.accessJwt}`, + }, + body: JSON.stringify({ + repo: body.communityDid, + collection: body.collection, + rkey: body.rkey, + record: body.record, + validate: body.validate, + }), + } + ); + if (!res.ok) { + const text = await res.text().catch(() => ""); + return c.json( + { error: "UpstreamFailure", message: `createRecord failed (${res.status}): ${text}` }, + 502 + ); + } + const out = (await res.json()) as { uri?: string; cid?: string }; + return c.json({ uri: out.uri, cid: out.cid }); + }); + + app.post(`/xrpc/${NS}.deleteRecord`, auth, async (c) => { + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as + | { communityDid?: string; collection?: string; rkey?: string } + | null; + if (!body?.communityDid || !body.collection || !body.rkey) { + return c.json( + { error: "InvalidRequest", message: "communityDid, collection, rkey required" }, + 400 + ); + } + const row = await community.getCommunity(body.communityDid); + if (!row) return c.json({ error: "NotFound" }, 404); + if (row.mode === "mint") { + return c.json({ error: "NotSupported" }, 400); + } + + const publishersUri = buildSpaceUri({ + ownerDid: body.communityDid, + type: spaceType, + key: "$publishers", + }); + const level = await resolveEffectiveLevel(community, publishersUri, sa.issuer); + if (!level) { + return c.json({ error: "Forbidden", reason: "not-in-publishers" }, 403); + } + + const raw = await community.getRawCredentials(body.communityDid); + if (!raw?.appPasswordEncrypted || !raw.pdsEndpoint || !raw.identifier) { + return c.json({ error: "InvalidState" }, 500); + } + let session; + try { + const appPassword = await cipher.decryptString(raw.appPasswordEncrypted); + session = await createPdsSession(raw.pdsEndpoint, raw.identifier, appPassword, { + fetch: cfg.fetch, + }); + } catch (err: any) { + return c.json( + { error: "UpstreamFailure", message: err.message }, + 502 + ); + } + + const f = cfg.fetch ?? fetch; + const res = await f( + `${raw.pdsEndpoint.replace(/\/$/, "")}/xrpc/com.atproto.repo.deleteRecord`, + { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${session.accessJwt}`, + }, + body: JSON.stringify({ + repo: body.communityDid, + collection: body.collection, + rkey: body.rkey, + }), + } + ); + if (!res.ok) { + const text = await res.text().catch(() => ""); + return c.json( + { error: "UpstreamFailure", message: `deleteRecord failed (${res.status}): ${text}` }, + 502 + ); + } + return c.json({ ok: true }); + }); + + app.post(`/xrpc/${NS}.space.putRecord`, auth, async (c) => { + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as + | { + spaceUri?: string; + collection?: string; + rkey?: string; + record?: Record; + } + | null; + if (!body?.spaceUri || !body.collection || !body.record) { + return c.json( + { error: "InvalidRequest", message: "spaceUri, collection, record required" }, + 400 + ); + } + + const space = await spaces.getSpace(body.spaceUri); + if (!space) return c.json({ error: "NotFound" }, 404); + const row = await community.getCommunity(space.ownerDid); + if (!row) return c.json({ error: "InvalidRequest", reason: "not-community-owned" }, 400); + + const level = await resolveEffectiveLevel(community, body.spaceUri, sa.issuer); + if (!level || rankOf(level) < rankOf("admin")) { + return c.json({ error: "Forbidden", reason: "admin-required" }, 403); + } + + const rkey = body.rkey ?? generateKey(); + const now = Date.now(); + await spaces.putRecord({ + spaceUri: body.spaceUri, + collection: body.collection, + authorDid: space.ownerDid, // community DID + rkey, + cid: null, + record: body.record, + createdAt: now, + }); + return c.json({ rkey, authorDid: space.ownerDid, createdAt: now }); + }); + + app.post(`/xrpc/${NS}.space.deleteRecord`, auth, async (c) => { + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as + | { spaceUri?: string; collection?: string; rkey?: string } + | null; + if (!body?.spaceUri || !body.collection || !body.rkey) { + return c.json( + { error: "InvalidRequest", message: "spaceUri, collection, rkey required" }, + 400 + ); + } + const space = await spaces.getSpace(body.spaceUri); + if (!space) return c.json({ error: "NotFound" }, 404); + const row = await community.getCommunity(space.ownerDid); + if (!row) return c.json({ error: "InvalidRequest", reason: "not-community-owned" }, 400); + + const level = await resolveEffectiveLevel(community, body.spaceUri, sa.issuer); + if (!level || rankOf(level) < rankOf("admin")) { + return c.json({ error: "Forbidden", reason: "admin-required" }, 403); + } + + await spaces.deleteRecord(body.spaceUri, body.collection, space.ownerDid, body.rkey); + return c.json({ ok: true }); + }); + + // ========================================================================== + // Credential health + reauth + // ========================================================================== + + app.get(`/xrpc/${NS}.getHealth`, auth, async (c) => { + const sa = getAuth(c); + const communityDid = c.req.query("communityDid"); + if (!communityDid) { + return c.json({ error: "InvalidRequest", message: "communityDid required" }, 400); + } + const row = await community.getCommunity(communityDid); + if (!row) return c.json({ error: "NotFound" }, 404); + + // Any member of $admin can ask. + const adminUri = buildSpaceUri({ + ownerDid: communityDid, + type: spaceType, + key: "$admin", + }); + const level = await resolveEffectiveLevel(community, adminUri, sa.issuer); + if (!level) { + return c.json({ error: "Forbidden", reason: "not-member-of-admin" }, 403); + } + + if (row.mode === "mint") { + // Minted communities: we hold the signing key directly; if decryption works, healthy. + try { + const raw = await community.getRawCredentials(communityDid); + if (raw?.signingKeyEncrypted) await cipher.decrypt(raw.signingKeyEncrypted); + return c.json({ status: "healthy" }); + } catch { + return c.json({ status: "expired" }); + } + } + + // Adopted: attempt a session creation. + const raw = await community.getRawCredentials(communityDid); + if (!raw?.appPasswordEncrypted || !raw.pdsEndpoint || !raw.identifier) { + return c.json({ status: "expired" }); + } + try { + const appPassword = await cipher.decryptString(raw.appPasswordEncrypted); + await createPdsSession(raw.pdsEndpoint, raw.identifier, appPassword, { + fetch: cfg.fetch, + }); + return c.json({ status: "healthy" }); + } catch { + return c.json({ status: "expired" }); + } + }); + + app.post(`/xrpc/${NS}.reauth`, auth, async (c) => { + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as + | { communityDid?: string; appPassword?: string } + | null; + if (!body?.communityDid || !body.appPassword) { + return c.json( + { error: "InvalidRequest", message: "communityDid and appPassword required" }, + 400 + ); + } + const row = await community.getCommunity(body.communityDid); + if (!row) return c.json({ error: "NotFound" }, 404); + if (row.mode !== "adopt") { + return c.json({ error: "NotSupported", reason: "reauth-only-for-adopted" }, 400); + } + + // Caller must have owner in $admin. + const adminUri = buildSpaceUri({ + ownerDid: body.communityDid, + type: spaceType, + key: "$admin", + }); + const level = await resolveEffectiveLevel(community, adminUri, sa.issuer); + if (level !== "owner") { + return c.json({ error: "Forbidden", reason: "owner-required" }, 403); + } + + // Re-resolve in case the PDS moved, and verify the new password. + const identifier = row.identifier ?? body.communityDid; + let resolved; + try { + resolved = await resolveIdentity(identifier, { + resolver: cfg.resolver, + fetch: cfg.fetch, + }); + } catch (err: any) { + return c.json( + { error: "InvalidRequest", message: `could not resolve: ${err.message}` }, + 400 + ); + } + try { + await createPdsSession(resolved.pdsEndpoint, identifier, body.appPassword, { + fetch: cfg.fetch, + }); + } catch (err: any) { + return c.json( + { error: "Unauthorized", message: `credential check failed: ${err.message}` }, + 401 + ); + } + + const encrypted = await cipher.encrypt(body.appPassword); + await community.updateAdoptedCredentials({ + did: body.communityDid, + pdsEndpoint: resolved.pdsEndpoint, + appPasswordEncrypted: encrypted, + identifier, + }); + return c.json({ ok: true }); + }); +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function getAuth(c: Context): ServiceAuth { + const a = c.get("serviceAuth") as ServiceAuth | undefined; + if (!a) throw new Error("service auth not set"); + return a; +} + +/** TID-ish ASCII lowercase base32-like identifier; good enough for space keys + * alongside the reserved `$`-prefixed ones. Mirrors the approach in spaces/tid.ts. */ +function generateKey(): string { + const chars = "234567abcdefghijklmnopqrstuvwxyz"; + const bytes = crypto.getRandomValues(new Uint8Array(13)); + let out = ""; + for (let i = 0; i < 13; i++) out += chars[bytes[i]! % 32]; + return out; +} + +async function bootstrapReservedSpaces(args: { + communityDid: string; + creatorDid: string; + spaces: SpacesAdapter; + community: CommunityAdapter; + type: string; + serviceDid: string; +}): Promise { + for (const key of RESERVED_KEYS) { + const uri = buildSpaceUri({ + ownerDid: args.communityDid, + type: args.type, + key, + }); + await args.spaces.createSpace({ + uri, + ownerDid: args.communityDid, + type: args.type, + key, + serviceDid: args.serviceDid, + appPolicyRef: null, + appPolicy: null, + }); + await args.community.grant({ + spaceUri: uri, + subjectDid: args.creatorDid, + accessLevel: "owner", + grantedBy: args.creatorDid, + }); + // Materialize membership: creator is in the space. + await args.spaces.applyMembershipDiff(uri, [args.creatorDid], [], args.creatorDid); + } +} diff --git a/src/core/community/schema.ts b/src/core/community/schema.ts new file mode 100644 index 0000000..ef5e16d --- /dev/null +++ b/src/core/community/schema.ts @@ -0,0 +1,40 @@ +import type { Database } from "../types"; +import { getDialect } from "../dialect"; +import type { SqlDialect } from "../dialect"; + +export function buildCommunitySchema(dialect: SqlDialect): string[] { + return [ + `CREATE TABLE IF NOT EXISTS communities ( + did TEXT PRIMARY KEY, + mode TEXT NOT NULL, + pds_endpoint TEXT, + app_password_encrypted TEXT, + identifier TEXT, + signing_key_encrypted TEXT, + rotation_key_encrypted TEXT, + created_by TEXT NOT NULL, + created_at ${dialect.bigintType} NOT NULL, + deleted_at ${dialect.bigintType} + )`, + `CREATE INDEX IF NOT EXISTS idx_communities_created_at ON communities(created_at DESC)`, + + `CREATE TABLE IF NOT EXISTS community_access_levels ( + space_uri TEXT NOT NULL, + subject TEXT NOT NULL, + subject_kind TEXT NOT NULL CHECK (subject_kind IN ('did', 'space')), + access_level TEXT NOT NULL, + granted_by TEXT NOT NULL, + granted_at ${dialect.bigintType} NOT NULL, + PRIMARY KEY (space_uri, subject) + )`, + `CREATE INDEX IF NOT EXISTS idx_cal_subject ON community_access_levels(subject)`, + `CREATE INDEX IF NOT EXISTS idx_cal_subject_space ON community_access_levels(subject) + WHERE subject_kind = 'space'`, + ]; +} + +export async function initCommunitySchema(db: Database): Promise { + const dialect = getDialect(db); + const stmts = buildCommunitySchema(dialect); + await db.batch(stmts.map((s) => db.prepare(s))); +} diff --git a/src/core/community/types.ts b/src/core/community/types.ts new file mode 100644 index 0000000..5b7bfe1 --- /dev/null +++ b/src/core/community/types.ts @@ -0,0 +1,75 @@ +import type { Database } from "../types"; +import type { DidDocumentResolver } from "@atcute/identity-resolver"; + +/** Access level a subject (did or group-space) has on a given space. + * Levels are totally ordered. Higher levels include lower levels' powers. + * See docs/community.md for the exact semantics. */ +export type AccessLevel = "member" | "manager" | "admin" | "owner"; + +export const ACCESS_LEVELS: readonly AccessLevel[] = [ + "member", + "manager", + "admin", + "owner", +] as const; + +export function rankOf(level: AccessLevel): number { + return ACCESS_LEVELS.indexOf(level); +} + +export function isAccessLevel(v: unknown): v is AccessLevel { + return typeof v === "string" && ACCESS_LEVELS.includes(v as AccessLevel); +} + +export type CommunityMode = "adopt" | "mint"; + +export interface CommunityConfig { + /** Service DID for JWT verification. Falls back to spaces.serviceDid when both modules are enabled. */ + serviceDid?: string; + /** PLC directory host for minted communities. */ + plcDirectory?: string; + /** Master key for envelope-encrypting stored credentials (app passwords, signing keys, rotation keys). + * Accepts a raw Uint8Array (preferred) or a base64/hex string that decodes to 32 bytes. */ + masterKey: Uint8Array | string; + /** Optional override for DID resolution (used during adopt to resolve identifier → DID → PDS). */ + resolver?: DidDocumentResolver; + /** Optional override for the fetch implementation (useful for tests). */ + fetch?: typeof fetch; +} + +export interface CommunityRow { + did: string; + mode: CommunityMode; + // adopt-mode fields + pdsEndpoint: string | null; + appPasswordEncrypted: Uint8Array | null; + identifier: string | null; + // mint-mode fields (Stage 4) + signingKeyEncrypted: Uint8Array | null; + rotationKeyEncrypted: Uint8Array | null; + // common + createdBy: string; + createdAt: number; + deletedAt: number | null; +} + +export interface AccessLevelRow { + spaceUri: string; + subjectDid: string | null; + subjectSpaceUri: string | null; + accessLevel: AccessLevel; + grantedBy: string; + grantedAt: number; +} + +/** Key prefix for reserved community-owned spaces. */ +export const RESERVED_KEYS = ["$admin", "$publishers"] as const; +export type ReservedKey = (typeof RESERVED_KEYS)[number]; + +export function isReservedKey(key: string): key is ReservedKey { + return (RESERVED_KEYS as readonly string[]).includes(key); +} + +export interface AdapterContext { + db: Database; +} diff --git a/src/core/db/schema.ts b/src/core/db/schema.ts index 38305f9..7bf06d7 100644 --- a/src/core/db/schema.ts +++ b/src/core/db/schema.ts @@ -11,6 +11,7 @@ import { } from "../types"; import { getSearchableFields } from "../search"; import { buildSpacesBaseSchema } from "../spaces/schema"; +import { buildCommunitySchema } from "../community/schema"; function getResolved(config: ContrailConfig): ResolvedMaps { return (config as ResolvedContrailConfig)._resolved ?? resolveConfig(config)._resolved; @@ -309,6 +310,13 @@ export async function initSchema( await applySpacesSchema(spacesSharesMainDb ? db : spacesDb!, config, dialect); } + if (config.community) { + // Community tables live on the same DB as spaces (they reference space_uri). + const target = spacesSharesMainDb ? db : spacesDb!; + const communityStmts = buildCommunitySchema(dialect); + await target.batch(communityStmts.map((s) => target.prepare(s))); + } + // FTS5 may not be available (e.g. node:sqlite) — skip gracefully for (const stmt of ftsStatements) { try { diff --git a/src/core/router/index.ts b/src/core/router/index.ts index cf54ad6..7ab98fa 100644 --- a/src/core/router/index.ts +++ b/src/core/router/index.ts @@ -8,10 +8,12 @@ import { registerFeedRoutes } from "./feed"; import { registerNotifyRoute } from "./notify"; import { registerSpacesRoutes } from "../spaces/router"; import type { SpacesRoutesOptions } from "../spaces/router"; -import { buildVerifier } from "../spaces/auth"; +import { buildVerifier, createServiceAuthMiddleware } from "../spaces/auth"; import { HostedAdapter } from "../spaces/adapter"; import type { StorageAdapter } from "../spaces/types"; import type { ServiceJwtVerifier } from "@atcute/xrpc-server/auth"; +import { registerCommunityRoutes } from "../community/router"; +import type { CommunityRoutesOptions } from "../community/router"; import { resolveActor } from "../identity"; import { resolveProfiles } from "./profiles"; import { backfillUser } from "../backfill"; @@ -23,6 +25,7 @@ export interface SpacesContext { export interface CreateAppOptions { spaces?: SpacesRoutesOptions; + community?: CommunityRoutesOptions; /** Separate DB for the spaces tables. Defaults to `db`. */ spacesDb?: Database; /** Full spaces context override (escape hatch for tests). */ @@ -85,5 +88,20 @@ export function createApp( registerNotifyRoute(app, db, config); registerSpacesRoutes(app, spacesDb, config, options.spaces, spacesCtx); + if (config.community && spacesCtx) { + // Community routes reuse the spaces service-auth middleware (same JWT verifier). + const authMiddleware = + options.community?.authMiddleware ?? + options.spaces?.authMiddleware ?? + createServiceAuthMiddleware(spacesCtx.verifier); + registerCommunityRoutes( + app, + spacesDb, + config, + { ...options.community, authMiddleware }, + { spacesAdapter: spacesCtx.adapter, verifier: spacesCtx.verifier } + ); + } + return app; } diff --git a/src/core/spaces/acl.ts b/src/core/spaces/acl.ts index 05adc40..0beafb0 100644 --- a/src/core/spaces/acl.ts +++ b/src/core/spaces/acl.ts @@ -1,4 +1,4 @@ -import type { AppPolicy, MemberPerm, SpaceMemberRow, SpaceRow } from "./types"; +import type { AppPolicy, SpaceMemberRow, SpaceRow } from "./types"; export type AclOp = "read" | "write" | "delete"; @@ -20,7 +20,6 @@ export type AclResult = export type AclDenyReason = | "not-member" - | "not-writer" | "not-own-record" | "app-not-allowed" | "unknown-op"; @@ -39,34 +38,26 @@ export function checkAppPolicy( const isOwner = (space: SpaceRow, did: string) => space.ownerDid === did; const hasMember = (space: SpaceRow, member: SpaceMemberRow | null, did: string) => isOwner(space, did) || member != null; -const hasWrite = (space: SpaceRow, member: SpaceMemberRow | null, did: string) => - isOwner(space, did) || member?.perms === "write"; /** Space-level access check. - * Model matches the proposal: member list is a (DID, perm) tuple set per space; - * write implies read; owner is always implicit write. No per-collection - * policies — all records in a space share the same access rule. */ + * Membership = access. Any member can read and write; the app filters + * writes it doesn't want on its own side. Delete keeps the owner/own-record + * rule so a random member can't nuke other people's records. */ export function checkAccess(input: AclInput): AclResult { if (!checkAppPolicy(input.space.appPolicy, input.clientId)) { return { allow: false, reason: "app-not-allowed" }; } - if (input.op === "read") { + if (input.op === "read" || input.op === "write") { return hasMember(input.space, input.member, input.callerDid) ? { allow: true } : { allow: false, reason: "not-member" }; } - if (input.op === "write") { - return hasWrite(input.space, input.member, input.callerDid) - ? { allow: true } - : { allow: false, reason: "not-writer" }; - } - if (input.op === "delete") { if (isOwner(input.space, input.callerDid)) return { allow: true }; - if (!hasWrite(input.space, input.member, input.callerDid)) { - return { allow: false, reason: "not-writer" }; + if (!hasMember(input.space, input.member, input.callerDid)) { + return { allow: false, reason: "not-member" }; } if (input.targetAuthorDid && input.targetAuthorDid !== input.callerDid) { return { allow: false, reason: "not-own-record" }; @@ -76,5 +67,3 @@ export function checkAccess(input: AclInput): AclResult { return { allow: false, reason: "unknown-op" }; } - -export type MemberPermExport = MemberPerm; diff --git a/src/core/spaces/adapter.ts b/src/core/spaces/adapter.ts index 67a0b8c..8c839d3 100644 --- a/src/core/spaces/adapter.ts +++ b/src/core/spaces/adapter.ts @@ -17,7 +17,6 @@ import type { ListOptions, ListResult, ListSpacesOptions, - MemberPerm, SpaceMemberRow, SpaceRow, StorageAdapter, @@ -48,7 +47,6 @@ function mapSpaceRow(row: any): SpaceRow { type: row.type, key: row.key, serviceDid: row.service_did, - memberListRef: row.member_list_ref ?? null, appPolicyRef: row.app_policy_ref ?? null, appPolicy: parseJson(row.app_policy), createdAt: toNum(row.created_at), @@ -60,7 +58,6 @@ function mapMemberRow(row: any): SpaceMemberRow { return { spaceUri: row.space_uri, did: row.did, - perms: row.perms as MemberPerm, addedAt: toNum(row.added_at), addedBy: row.added_by ?? null, }; @@ -71,7 +68,6 @@ function mapInviteRow(row: any): InviteRow { tokenHash: row.token_hash, spaceUri: row.space_uri, kind: (row.kind ?? "join") as InviteKind, - perms: row.perms, expiresAt: row.expires_at == null ? null : toNum(row.expires_at), maxUses: row.max_uses == null ? null : Number(row.max_uses), usedCount: Number(row.used_count), @@ -123,8 +119,8 @@ export class HostedAdapter implements StorageAdapter { const now = Date.now(); await this.db .prepare( - `INSERT INTO spaces (uri, owner_did, type, key, service_did, member_list_ref, app_policy_ref, app_policy, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + `INSERT INTO spaces (uri, owner_did, type, key, service_did, app_policy_ref, app_policy, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ) .bind( space.uri, @@ -132,7 +128,6 @@ export class HostedAdapter implements StorageAdapter { space.type, space.key, space.serviceDid, - space.memberListRef, space.appPolicyRef, space.appPolicy ? JSON.stringify(space.appPolicy) : null, now @@ -203,14 +198,14 @@ export class HostedAdapter implements StorageAdapter { .run(); } - async addMember(spaceUri: string, did: string, perms: MemberPerm, addedBy: string | null): Promise { + async addMember(spaceUri: string, did: string, addedBy: string | null): Promise { await this.db .prepare( - `INSERT INTO spaces_members (space_uri, did, perms, added_at, added_by) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT (space_uri, did) DO UPDATE SET perms = excluded.perms` + `INSERT INTO spaces_members (space_uri, did, added_at, added_by) + VALUES (?, ?, ?, ?) + ON CONFLICT (space_uri, did) DO NOTHING` ) - .bind(spaceUri, did, perms, Date.now(), addedBy) + .bind(spaceUri, did, Date.now(), addedBy) .run(); } @@ -237,18 +232,48 @@ export class HostedAdapter implements StorageAdapter { return results.map(mapMemberRow); } + async applyMembershipDiff( + spaceUri: string, + adds: string[], + removes: string[], + addedBy: string | null + ): Promise { + const now = Date.now(); + const stmts: any[] = []; + for (const did of adds) { + stmts.push( + this.db + .prepare( + `INSERT INTO spaces_members (space_uri, did, added_at, added_by) + VALUES (?, ?, ?, ?) + ON CONFLICT (space_uri, did) DO NOTHING` + ) + .bind(spaceUri, did, now, addedBy) + ); + } + for (const did of removes) { + stmts.push( + this.db + .prepare(`DELETE FROM spaces_members WHERE space_uri = ? AND did = ?`) + .bind(spaceUri, did) + ); + } + if (stmts.length > 0) { + await this.db.batch(stmts); + } + } + async createInvite(input: CreateInviteInput): Promise { const now = Date.now(); await this.db .prepare( - `INSERT INTO spaces_invites (token_hash, space_uri, kind, perms, expires_at, max_uses, used_count, created_by, created_at, note) - VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)` + `INSERT INTO spaces_invites (token_hash, space_uri, kind, expires_at, max_uses, used_count, created_by, created_at, note) + VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)` ) .bind( input.tokenHash, input.spaceUri, input.kind, - input.perms, input.expiresAt, input.maxUses, input.createdBy, @@ -260,7 +285,6 @@ export class HostedAdapter implements StorageAdapter { tokenHash: input.tokenHash, spaceUri: input.spaceUri, kind: input.kind, - perms: input.perms, expiresAt: input.expiresAt, maxUses: input.maxUses, usedCount: 0, diff --git a/src/core/spaces/router.ts b/src/core/spaces/router.ts index ef2c93f..0b2e188 100644 --- a/src/core/spaces/router.ts +++ b/src/core/spaces/router.ts @@ -13,7 +13,7 @@ import { import { nextTid } from "./tid"; import { generateInviteToken, hashInviteToken } from "./invite-token"; import { buildSpaceUri } from "./uri"; -import type { InviteKind, InviteRow, MemberPerm, SpaceRow, SpacesConfig, StorageAdapter } from "./types"; +import type { InviteKind, InviteRow, SpaceRow, SpacesConfig, StorageAdapter } from "./types"; import type { Did } from "@atcute/lexicons"; export interface SpacesRoutesOptions { @@ -273,7 +273,6 @@ export function registerSpacesRoutes( type?: string; key?: string; appPolicy?: SpaceRow["appPolicy"]; - memberListRef?: string; appPolicyRef?: string; }; @@ -290,12 +289,11 @@ export function registerSpacesRoutes( type, key, serviceDid: spacesConfig.serviceDid, - memberListRef: body.memberListRef ?? null, appPolicyRef: body.appPolicyRef ?? null, appPolicy: body.appPolicy ?? spacesConfig.defaultAppPolicy ?? null, }); // Owner is implicit; we still write a row so membership queries are uniform. - await adapter.addMember(uri, sa.issuer, "write", sa.issuer); + await adapter.addMember(uri, sa.issuer, sa.issuer); return c.json({ space: publicSpaceView(space, true) }); }); @@ -307,7 +305,6 @@ export function registerSpacesRoutes( | { spaceUri?: string; kind?: InviteKind; - perms?: MemberPerm; expiresAt?: number; maxUses?: number; note?: string; @@ -332,7 +329,6 @@ export function registerSpacesRoutes( spaceUri: body.spaceUri, tokenHash, kind, - perms: body.perms ?? "write", expiresAt: body.expiresAt ?? null, maxUses: body.maxUses ?? null, createdBy: sa.issuer, @@ -352,8 +348,8 @@ export function registerSpacesRoutes( if (!invite) { return c.json({ error: "InvalidInvite", reason: "expired-revoked-or-exhausted" }, 400); } - await adapter.addMember(invite.spaceUri, sa.issuer, invite.perms, invite.createdBy); - return c.json({ spaceUri: invite.spaceUri, perms: invite.perms }); + await adapter.addMember(invite.spaceUri, sa.issuer, invite.createdBy); + return c.json({ spaceUri: invite.spaceUri }); }); app.get(`/xrpc/${SPACE}.invite.list`, auth, async (c) => { @@ -390,7 +386,7 @@ export function registerSpacesRoutes( app.post(`/xrpc/${SPACE}.addMember`, auth, async (c) => { const sa = getAuth(c); const body = (await c.req.json().catch(() => null)) as - | { spaceUri?: string; did?: string; perms?: MemberPerm } + | { spaceUri?: string; did?: string } | null; if (!body?.spaceUri || !body.did) { return c.json({ error: "InvalidRequest", message: "spaceUri and did required" }, 400); @@ -400,7 +396,7 @@ export function registerSpacesRoutes( if (space.ownerDid !== sa.issuer) { return c.json({ error: "Forbidden", reason: "not-owner" }, 403); } - await adapter.addMember(body.spaceUri, body.did, body.perms ?? "write", sa.issuer); + await adapter.addMember(body.spaceUri, body.did, sa.issuer); return c.json({ ok: true }); }); @@ -451,11 +447,11 @@ export function registerSpacesRoutes( const isOwner = space.ownerDid === sa.issuer; if (isOwner) { - return c.json({ isOwner: true, isMember: true, perms: "write" as const }); + return c.json({ isOwner: true, isMember: true }); } const member = await adapter.getMember(spaceUri, sa.issuer); if (!member) return c.json({ isOwner: false, isMember: false }); - return c.json({ isOwner: false, isMember: true, perms: member.perms }); + return c.json({ isOwner: false, isMember: true }); }); } @@ -475,7 +471,6 @@ function publicInviteView(invite: InviteRow) { tokenHash: invite.tokenHash, spaceUri: invite.spaceUri, kind: invite.kind, - perms: invite.perms, expiresAt: invite.expiresAt, maxUses: invite.maxUses, usedCount: invite.usedCount, @@ -493,7 +488,6 @@ function publicSpaceView(space: SpaceRow, forOwner: boolean) { type: space.type, key: space.key, serviceDid: space.serviceDid, - memberListRef: space.memberListRef, appPolicyRef: space.appPolicyRef, createdAt: space.createdAt, ...(forOwner ? { appPolicy: space.appPolicy } : {}), diff --git a/src/core/spaces/schema.ts b/src/core/spaces/schema.ts index f46c909..d6cf4a1 100644 --- a/src/core/spaces/schema.ts +++ b/src/core/spaces/schema.ts @@ -17,7 +17,6 @@ export function buildSpacesBaseSchema(dialect: SqlDialect): string[] { type TEXT NOT NULL, key TEXT NOT NULL, service_did TEXT NOT NULL, - member_list_ref TEXT, app_policy_ref TEXT, app_policy ${dialect.recordColumnType}, created_at ${dialect.bigintType} NOT NULL, @@ -29,7 +28,6 @@ export function buildSpacesBaseSchema(dialect: SqlDialect): string[] { `CREATE TABLE IF NOT EXISTS spaces_members ( space_uri TEXT NOT NULL, did TEXT NOT NULL, - perms TEXT NOT NULL, added_at ${dialect.bigintType} NOT NULL, added_by TEXT, PRIMARY KEY (space_uri, did) @@ -40,7 +38,6 @@ export function buildSpacesBaseSchema(dialect: SqlDialect): string[] { token_hash TEXT PRIMARY KEY, space_uri TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'join', - perms TEXT NOT NULL, expires_at ${dialect.bigintType}, max_uses INTEGER, used_count INTEGER NOT NULL DEFAULT 0, diff --git a/src/core/spaces/types.ts b/src/core/spaces/types.ts index cb166e2..ff5584f 100644 --- a/src/core/spaces/types.ts +++ b/src/core/spaces/types.ts @@ -1,9 +1,6 @@ import type { Database } from "../types"; import type { DidDocumentResolver } from "@atcute/identity-resolver"; -/** Member permission in a space. "write" implies "read"; owner is always implicit write. */ -export type MemberPerm = "read" | "write"; - export type AppPolicyMode = "allow" | "deny"; export interface AppPolicy { @@ -29,7 +26,6 @@ export interface SpaceRow { type: string; key: string; serviceDid: string; - memberListRef: string | null; appPolicyRef: string | null; appPolicy: AppPolicy | null; createdAt: number; @@ -39,7 +35,6 @@ export interface SpaceRow { export interface SpaceMemberRow { spaceUri: string; did: string; - perms: MemberPerm; addedAt: number; addedBy: string | null; } @@ -79,7 +74,7 @@ export interface CollectionCount { } /** What a token holder can do with this invite. - * - `'join'`: must be redeemed while signed in; becomes a member with `perms`. + * - `'join'`: must be redeemed while signed in; redeemer becomes a member. * - `'read'`: bearer-only — token itself grants read access to the space; cannot be redeemed. * - `'read-join'`: both — anonymous holders read; signed-in holders may also redeem to join. */ export type InviteKind = "join" | "read" | "read-join"; @@ -88,7 +83,6 @@ export interface InviteRow { tokenHash: string; spaceUri: string; kind: InviteKind; - perms: MemberPerm; expiresAt: number | null; maxUses: number | null; usedCount: number; @@ -102,7 +96,6 @@ export interface CreateInviteInput { spaceUri: string; tokenHash: string; kind: InviteKind; - perms: MemberPerm; expiresAt: number | null; maxUses: number | null; createdBy: string; @@ -111,7 +104,6 @@ export interface CreateInviteInput { export interface RedeemInviteResult { spaceUri: string; - perms: MemberPerm; } export interface StorageAdapter { @@ -123,10 +115,18 @@ export interface StorageAdapter { updateSpaceAppPolicy(spaceUri: string, appPolicy: AppPolicy): Promise; // Members - addMember(spaceUri: string, did: string, perms: MemberPerm, addedBy: string | null): Promise; + addMember(spaceUri: string, did: string, addedBy: string | null): Promise; removeMember(spaceUri: string, did: string): Promise; getMember(spaceUri: string, did: string): Promise; listMembers(spaceUri: string): Promise; + /** Bulk-apply a membership diff. Used only by the community module's reconciler; + * not exposed as an XRPC endpoint. */ + applyMembershipDiff( + spaceUri: string, + adds: string[], + removes: string[], + addedBy: string | null + ): Promise; // Invites createInvite(input: CreateInviteInput): Promise; diff --git a/src/core/types.ts b/src/core/types.ts index 56ee37d..bbdeef2 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -155,6 +155,9 @@ export interface ContrailConfig { notify?: boolean | string; /** Permissioned spaces configuration. When set, the service exposes space XRPCs. */ spaces?: import("./spaces/types").SpacesConfig; + /** Community module configuration. When set, the service exposes community XRPCs + * for managing community-owned spaces and tiered access levels. Requires `spaces`. */ + community?: import("./community/types").CommunityConfig; /** Customize the auto-generated `.permissionSet` lexicon. */ permissionSet?: PermissionSetConfig; } diff --git a/src/generate.ts b/src/generate.ts index 5a77c8a..6ecf411 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -40,6 +40,17 @@ function findSpaceTemplatesDir(rootDir: string): string | null { return null; } +function findCommunityTemplatesDir(rootDir: string): string | null { + const candidates = [ + join(rootDir, "community-lexicon-templates"), + join(rootDir, "node_modules/@atmo-dev/contrail/community-lexicon-templates"), + ]; + for (const p of candidates) { + if (existsSync(p)) return p; + } + return null; +} + /** Yield all JSON files under a directory (recursive). */ function* walkJson(dir: string): Generator { for (const entry of readdirSync(dir, { withFileTypes: true })) { @@ -721,6 +732,48 @@ export function generateLexicons(options: GenerateOptions): Record.community.* --- + + if (config.community) { + log("Generating community endpoints..."); + const templatesDir = findCommunityTemplatesDir(rootDir); + if (!templatesDir) { + log(" (community templates not found — skipping)"); + } else { + const templateIdRe = /^tools\.atmo\.community(\.[A-Za-z0-9.]+)?$/; + const idReplace = (id: string) => + id.startsWith("tools.atmo.community") + ? id.replace(/^tools\.atmo\.community/, `${ns}.community`) + : id; + + const rewriteRefs = (obj: any): any => { + if (Array.isArray(obj)) return obj.map(rewriteRefs); + if (obj && typeof obj === "object") { + const out: any = {}; + for (const [k, v] of Object.entries(obj)) { + if (k === "ref" && typeof v === "string" && v.startsWith("tools.atmo.community")) { + out[k] = v.replace(/^tools\.atmo\.community/, `${ns}.community`); + } else if (k === "id" && typeof v === "string" && templateIdRe.test(v)) { + out[k] = idReplace(v); + } else { + out[k] = rewriteRefs(v); + } + } + return out; + } + return obj; + }; + + for (const file of walkJson(templatesDir)) { + const doc = JSON.parse(readFileSync(file, "utf-8")); + if (typeof doc.id !== "string" || !templateIdRe.test(doc.id)) continue; + const newId = idReplace(doc.id); + const rewritten = rewriteRefs({ ...doc, id: newId }); + writeLexicon(newId, rewritten); + } + } + } + // --- Permission set --- // Permission-set lexicons (https://atproto.com/guides/permission-sets) can // only reference NSIDs under the same namespace as the set itself, which diff --git a/src/index.ts b/src/index.ts index 7eebb87..1311579 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,7 +33,6 @@ export type { PersistentIngestOptions } from "./core/persistent"; // Spaces export type { SpacesConfig, - MemberPerm, AppPolicy, AppPolicyMode, SpaceRow, diff --git a/tests/community-delegation.test.ts b/tests/community-delegation.test.ts new file mode 100644 index 0000000..e6d366b --- /dev/null +++ b/tests/community-delegation.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, beforeAll, beforeEach } from "vitest"; +import { Hono } from "hono"; +import type { MiddlewareHandler } from "hono"; +import { createSqliteDatabase } from "../src/adapters/sqlite"; +import { initSchema } from "../src/core/db/schema"; +import { createApp } from "../src/core/router"; +import { resolveConfig } from "../src/core/types"; +import type { ContrailConfig } from "../src/core/types"; + +const ALICE = "did:plc:alice"; +const BOB = "did:plc:bob"; +const CHARLIE = "did:plc:charlie"; +const DIANA = "did:plc:diana"; +const COMMUNITY_DID = "did:plc:acme"; +const PDS_ENDPOINT = "https://pds.example"; + +const MASTER_KEY = new Uint8Array(32).fill(11); + +const CONFIG: ContrailConfig = { + namespace: "test.comm", + collections: { message: { collection: "app.event.message" } }, + spaces: { + type: "tools.atmo.event.space", + serviceDid: "did:web:test.example#svc", + }, + community: { + masterKey: MASTER_KEY, + fetch: mockFetch, + resolver: mockResolver(), + }, +}; + +function mockResolver(): any { + return { + resolve: async (did: string) => { + if (did !== COMMUNITY_DID) throw new Error("unknown did"); + return { + id: did, + service: [ + { + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: PDS_ENDPOINT, + }, + ], + }; + }, + }; +} + +async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/xrpc/com.atproto.server.createSession") && init?.method === "POST") { + return new Response( + JSON.stringify({ accessJwt: "a.b.c", refreshJwt: "r.r.r", did: COMMUNITY_DID }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + return new Response("not found", { status: 404 }); +} + +function fakeAuth(): MiddlewareHandler { + return async (c, next) => { + const did = c.req.header("X-Test-Did"); + if (!did) return c.json({ error: "AuthRequired" }, 401); + c.set("serviceAuth", { issuer: did, audience: CONFIG.spaces!.serviceDid, lxm: undefined }); + await next(); + }; +} + +async function makeApp(): Promise { + const db = createSqliteDatabase(":memory:"); + const resolved = resolveConfig(CONFIG); + await initSchema(db, resolved); + return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); +} + +function call( + app: Hono, + method: string, + path: string, + did: string, + body?: any +): Promise { + const headers: Record = { "X-Test-Did": did }; + if (body !== undefined) headers["Content-Type"] = "application/json"; + return app.fetch( + new Request(`http://localhost${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + ); +} + +async function adopt(app: Hono, caller: string) { + const res = await call(app, "POST", "/xrpc/test.comm.community.adopt", caller, { + identifier: COMMUNITY_DID, + appPassword: "ok", + }); + expect(res.status).toBe(200); +} + +async function createSpace(app: Hono, caller: string, key: string): Promise { + const res = await call(app, "POST", "/xrpc/test.comm.community.space.create", caller, { + communityDid: COMMUNITY_DID, + key, + }); + expect(res.status).toBe(200); + return ((await res.json()) as any).space.uri; +} + +async function grant(app: Hono, caller: string, spaceUri: string, subject: any, accessLevel: string) { + const res = await call(app, "POST", "/xrpc/test.comm.community.space.grant", caller, { + spaceUri, + subject, + accessLevel, + }); + return res; +} + +async function whoamiLevel(app: Hono, caller: string, spaceUri: string): Promise { + const res = await call(app, "GET", `/xrpc/test.comm.community.whoami?spaceUri=${encodeURIComponent(spaceUri)}`, caller); + expect(res.status).toBe(200); + return ((await res.json()) as any).accessLevel; +} + +async function flatMembers(app: Hono, caller: string, spaceUri: string): Promise { + const res = await call( + app, + "GET", + `/xrpc/test.comm.community.space.listMembers?spaceUri=${encodeURIComponent(spaceUri)}&flatten=true`, + caller + ); + expect(res.status).toBe(200); + return ((await res.json()) as any).members.map((m: any) => m.did); +} + +describe("community delegation — stage 2", () => { + let app: Hono; + + beforeAll(async () => { + app = await makeApp(); + await adopt(app, ALICE); + }); + + it("subject_space_uri delegates membership from one space to another", async () => { + const mods = await createSpace(app, ALICE, "mods"); + const chat = await createSpace(app, ALICE, "mod-chat"); + + // Bob is a member of mods. + expect((await grant(app, ALICE, mods, { did: BOB }, "member")).status).toBe(200); + // mods is a member of mod-chat. + expect((await grant(app, ALICE, chat, { spaceUri: mods }, "member")).status).toBe(200); + + // Bob is transitively a member of mod-chat. + expect(await whoamiLevel(app, BOB, chat)).toBe("member"); + const flat = await flatMembers(app, ALICE, chat); + expect(flat).toContain(BOB); + expect(flat).toContain(ALICE); // owner + }); + + it("access is capped at the path minimum (delegation can only reduce)", async () => { + const mods = await createSpace(app, ALICE, "mods2"); + const target = await createSpace(app, ALICE, "target2"); + + // Bob is an owner of mods2. + expect((await grant(app, ALICE, mods, { did: BOB }, "owner")).status).toBe(200); + // mods2 is a `member` of target2 (capped path). + expect((await grant(app, ALICE, target, { spaceUri: mods }, "member")).status).toBe(200); + + // Bob's effective level in target2 is `member`, not `owner`. + expect(await whoamiLevel(app, BOB, target)).toBe("member"); + }); + + it("cycle detection rejects A → B → A", async () => { + const a = await createSpace(app, ALICE, "cycle-a"); + const b = await createSpace(app, ALICE, "cycle-b"); + + expect((await grant(app, ALICE, a, { spaceUri: b }, "member")).status).toBe(200); + // Now creating b → a would close the cycle. + const res = await grant(app, ALICE, b, { spaceUri: a }, "member"); + expect(res.status).toBe(400); + expect(((await res.json()) as any).reason).toBe("cycle-detected"); + }); + + it("rejects self-reference", async () => { + const s = await createSpace(app, ALICE, "self-ref"); + const res = await grant(app, ALICE, s, { spaceUri: s }, "member"); + expect(res.status).toBe(400); + expect(((await res.json()) as any).reason).toBe("self-reference"); + }); + + it("reverse-graph reconcile: adding to source propagates to delegated spaces", async () => { + const mods = await createSpace(app, ALICE, "mods3"); + const chat1 = await createSpace(app, ALICE, "chat-a"); + const chat2 = await createSpace(app, ALICE, "chat-b"); + + // Both chats delegate to mods3. + expect((await grant(app, ALICE, chat1, { spaceUri: mods }, "member")).status).toBe(200); + expect((await grant(app, ALICE, chat2, { spaceUri: mods }, "member")).status).toBe(200); + + // Add Charlie to mods3 — should propagate to both chats. + expect((await grant(app, ALICE, mods, { did: CHARLIE }, "member")).status).toBe(200); + + expect(await flatMembers(app, ALICE, chat1)).toContain(CHARLIE); + expect(await flatMembers(app, ALICE, chat2)).toContain(CHARLIE); + }); + + it("reverse-graph reconcile: removing from source propagates to delegated spaces", async () => { + const mods = await createSpace(app, ALICE, "mods4"); + const chat = await createSpace(app, ALICE, "chat-c"); + + expect((await grant(app, ALICE, chat, { spaceUri: mods }, "member")).status).toBe(200); + expect((await grant(app, ALICE, mods, { did: DIANA }, "member")).status).toBe(200); + expect(await flatMembers(app, ALICE, chat)).toContain(DIANA); + + // Revoke from mods4 — should disappear from chat-c. + const res = await call(app, "POST", "/xrpc/test.comm.community.space.revoke", ALICE, { + spaceUri: mods, + subject: { did: DIANA }, + }); + expect(res.status).toBe(200); + + expect(await flatMembers(app, ALICE, chat)).not.toContain(DIANA); + }); + + it("resync endpoint requires admin+", async () => { + const s = await createSpace(app, ALICE, "resync-space"); + // Bob is a plain member + expect((await grant(app, ALICE, s, { did: BOB }, "member")).status).toBe(200); + + const res = await call(app, "POST", "/xrpc/test.comm.community.space.resync", BOB, { + spaceUri: s, + }); + expect(res.status).toBe(403); + }); + + it("resync endpoint works for owner", async () => { + const s = await createSpace(app, ALICE, "resync-ok"); + const res = await call(app, "POST", "/xrpc/test.comm.community.space.resync", ALICE, { + spaceUri: s, + }); + expect(res.status).toBe(200); + }); +}); diff --git a/tests/community-e2e.test.ts b/tests/community-e2e.test.ts new file mode 100644 index 0000000..3acb71e --- /dev/null +++ b/tests/community-e2e.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { Hono } from "hono"; +import type { MiddlewareHandler } from "hono"; +import { createSqliteDatabase } from "../src/adapters/sqlite"; +import { initSchema } from "../src/core/db/schema"; +import { createApp } from "../src/core/router"; +import { resolveConfig } from "../src/core/types"; +import type { ContrailConfig } from "../src/core/types"; + +const ALICE = "did:plc:alice"; +const BOB = "did:plc:bob"; +const CHARLIE = "did:plc:charlie"; +const COMMUNITY_DID = "did:plc:acmecommunity"; +const PDS_ENDPOINT = "https://pds.example"; + +// 32-byte test master key (deterministic; not a real secret). +const MASTER_KEY = new Uint8Array(32).fill(7); + +const CONFIG: ContrailConfig = { + namespace: "test.comm", + collections: { + message: { collection: "app.event.message" }, + }, + spaces: { + type: "tools.atmo.event.space", + serviceDid: "did:web:test.example#svc", + }, + community: { + masterKey: MASTER_KEY, + // Fake network: hand-rolled below. + fetch: mockFetch, + resolver: mockResolver(), + }, +}; + +function mockResolver(): any { + return { + resolve: async (did: string) => { + if (did !== COMMUNITY_DID) throw new Error("unknown did"); + return { + id: did, + service: [ + { + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: PDS_ENDPOINT, + }, + ], + }; + }, + }; +} + +async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + // Handle resolution paths are handled by the mock resolver, not fetch. + if (url.endsWith("/xrpc/com.atproto.server.createSession") && init?.method === "POST") { + const body = JSON.parse((init.body as string) ?? "{}"); + if (body.password === "app-password-ok") { + return new Response( + JSON.stringify({ + accessJwt: "a.b.c", + refreshJwt: "r.r.r", + did: COMMUNITY_DID, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + return new Response(JSON.stringify({ error: "AuthFactorTokenRequired" }), { status: 401 }); + } + return new Response("not found", { status: 404 }); +} + +function fakeAuth(): MiddlewareHandler { + return async (c, next) => { + const did = c.req.header("X-Test-Did"); + if (!did) return c.json({ error: "AuthRequired" }, 401); + c.set("serviceAuth", { + issuer: did, + audience: CONFIG.spaces!.serviceDid, + lxm: undefined, + }); + await next(); + }; +} + +async function makeApp(): Promise { + const db = createSqliteDatabase(":memory:"); + const resolved = resolveConfig(CONFIG); + await initSchema(db, resolved); + return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); +} + +function call( + app: Hono, + method: string, + path: string, + did: string | null, + body?: any +): Promise { + const headers: Record = {}; + if (did) headers["X-Test-Did"] = did; + if (body !== undefined) headers["Content-Type"] = "application/json"; + return app.fetch( + new Request(`http://localhost${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + ); +} + +async function adopt(app: Hono, caller: string) { + const res = await call(app, "POST", "/xrpc/test.comm.community.adopt", caller, { + identifier: COMMUNITY_DID, + appPassword: "app-password-ok", + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { communityDid: string }; + expect(body.communityDid).toBe(COMMUNITY_DID); + return body.communityDid; +} + +describe("community e2e — stage 1", () => { + let app: Hono; + + beforeAll(async () => { + app = await makeApp(); + }); + + it("adopts a community and creates reserved spaces with creator as owner", async () => { + const did = await adopt(app, ALICE); + + const adminUri = `at://${did}/tools.atmo.event.space/$admin`; + const publishersUri = `at://${did}/tools.atmo.event.space/$publishers`; + + // whoami in both reserved spaces → owner + for (const uri of [adminUri, publishersUri]) { + const r = await call(app, "GET", `/xrpc/test.comm.community.whoami?spaceUri=${encodeURIComponent(uri)}`, ALICE); + expect(r.status).toBe(200); + expect(((await r.json()) as any).accessLevel).toBe("owner"); + } + + // Bob isn't in either + const r = await call(app, "GET", `/xrpc/test.comm.community.whoami?spaceUri=${encodeURIComponent(adminUri)}`, BOB); + expect(r.status).toBe(200); + expect(((await r.json()) as any).accessLevel).toBe(null); + }); + + it("rejects bad credentials", async () => { + const app2 = await makeApp(); + const res = await call(app2, "POST", "/xrpc/test.comm.community.adopt", ALICE, { + identifier: COMMUNITY_DID, + appPassword: "wrong", + }); + expect(res.status).toBe(401); + }); + + it("rejects duplicate adoption", async () => { + const res = await call(app, "POST", "/xrpc/test.comm.community.adopt", BOB, { + identifier: COMMUNITY_DID, + appPassword: "app-password-ok", + }); + expect(res.status).toBe(409); + }); + + it("creates a non-reserved space via community.space.create", async () => { + const res = await call(app, "POST", "/xrpc/test.comm.community.space.create", ALICE, { + communityDid: COMMUNITY_DID, + key: "general", + }); + expect(res.status).toBe(200); + const body = (await res.json()) as any; + expect(body.space.uri).toBe(`at://${COMMUNITY_DID}/tools.atmo.event.space/general`); + expect(body.space.ownerDid).toBe(COMMUNITY_DID); + }); + + it("rejects reserved keys in space.create", async () => { + const res = await call(app, "POST", "/xrpc/test.comm.community.space.create", ALICE, { + communityDid: COMMUNITY_DID, + key: "$admin", + }); + expect(res.status).toBe(400); + expect(((await res.json()) as any).reason).toBe("reserved-key"); + }); + + it("non-admin cannot create a space", async () => { + const res = await call(app, "POST", "/xrpc/test.comm.community.space.create", BOB, { + communityDid: COMMUNITY_DID, + key: "random", + }); + expect(res.status).toBe(403); + }); + + it("owner grants Bob member access to #general; reconciler populates spaces_members", async () => { + const spaceUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/general`; + const res = await call(app, "POST", "/xrpc/test.comm.community.space.grant", ALICE, { + spaceUri, + subject: { did: BOB }, + accessLevel: "member", + }); + expect(res.status).toBe(200); + + // Bob can now see the members list + const list = await call( + app, + "GET", + `/xrpc/test.comm.community.space.listMembers?spaceUri=${encodeURIComponent(spaceUri)}`, + BOB + ); + expect(list.status).toBe(200); + const body = (await list.json()) as any; + expect(body.rows.map((r: any) => r.subject.did)).toContain(BOB); + }); + + it("manager cannot grant higher than own level", async () => { + const spaceUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/general`; + // Promote Bob to manager + await call(app, "POST", "/xrpc/test.comm.community.space.grant", ALICE, { + spaceUri, + subject: { did: BOB }, + accessLevel: "manager", + }); + // Bob tries to grant Charlie owner — should fail + const res = await call(app, "POST", "/xrpc/test.comm.community.space.grant", BOB, { + spaceUri, + subject: { did: CHARLIE }, + accessLevel: "owner", + }); + expect(res.status).toBe(403); + expect(((await res.json()) as any).reason).toBe("cannot-grant-higher-than-self"); + }); + + it("revokes and reconciler removes from spaces_members", async () => { + const spaceUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/general`; + const res = await call(app, "POST", "/xrpc/test.comm.community.space.revoke", ALICE, { + spaceUri, + subject: { did: BOB }, + }); + expect(res.status).toBe(200); + + const level = await call( + app, + "GET", + `/xrpc/test.comm.community.whoami?spaceUri=${encodeURIComponent(spaceUri)}`, + BOB + ); + expect(((await level.json()) as any).accessLevel).toBe(null); + }); + + it("cannot delete a reserved space", async () => { + const adminUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/$admin`; + const res = await call(app, "POST", "/xrpc/test.comm.community.space.delete", ALICE, { + spaceUri: adminUri, + }); + expect(res.status).toBe(403); + expect(((await res.json()) as any).reason).toBe("reserved-space-cannot-be-deleted"); + }); + + it("lists communities for an actor", async () => { + const res = await call(app, "GET", `/xrpc/test.comm.community.list`, ALICE); + expect(res.status).toBe(200); + const body = (await res.json()) as any; + expect(body.communities.map((c: any) => c.did)).toContain(COMMUNITY_DID); + }); +}); diff --git a/tests/community-mint.test.ts b/tests/community-mint.test.ts new file mode 100644 index 0000000..96922a6 --- /dev/null +++ b/tests/community-mint.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { Hono } from "hono"; +import type { MiddlewareHandler } from "hono"; +import { createSqliteDatabase } from "../src/adapters/sqlite"; +import { initSchema } from "../src/core/db/schema"; +import { createApp } from "../src/core/router"; +import { resolveConfig } from "../src/core/types"; +import type { ContrailConfig } from "../src/core/types"; +import { + buildGenesisOp, + computeDidPlc, + encodeDagCbor, + generateKeyPair, + jwkToDidKey, + signGenesisOp, +} from "../src/core/community/plc"; + +const ALICE = "did:plc:alice"; +const BOB = "did:plc:bob"; + +const MASTER_KEY = new Uint8Array(32).fill(99); + +/** Captures requests that would have gone to plc.directory. */ +const plcCalls: Array<{ url: string; method: string; body: any }> = []; + +const CONFIG: ContrailConfig = { + namespace: "test.comm", + collections: { message: { collection: "app.event.message" } }, + spaces: { + type: "tools.atmo.event.space", + serviceDid: "did:web:test.example#svc", + }, + community: { + masterKey: MASTER_KEY, + plcDirectory: "https://plc.test", + fetch: mockFetch, + }, +}; + +async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? "GET"; + const body = init?.body ? JSON.parse(init.body as string) : {}; + if (url.startsWith("https://plc.test/")) { + plcCalls.push({ url, method, body }); + return new Response("{}", { status: 200 }); + } + return new Response("not found", { status: 404 }); +} + +function fakeAuth(): MiddlewareHandler { + return async (c, next) => { + const did = c.req.header("X-Test-Did"); + if (!did) return c.json({ error: "AuthRequired" }, 401); + c.set("serviceAuth", { issuer: did, audience: CONFIG.spaces!.serviceDid, lxm: undefined }); + await next(); + }; +} + +async function makeApp(): Promise { + const db = createSqliteDatabase(":memory:"); + const resolved = resolveConfig(CONFIG); + await initSchema(db, resolved); + return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); +} + +function call( + app: Hono, + method: string, + path: string, + did: string, + body?: any +): Promise { + const headers: Record = { "X-Test-Did": did }; + if (body !== undefined) headers["Content-Type"] = "application/json"; + return app.fetch( + new Request(`http://localhost${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + ); +} + +describe("plc op encoding (unit)", () => { + it("encodes canonical CBOR with sorted map keys", () => { + // Maps must be sorted by key length first, then lexicographically. + const out = encodeDagCbor({ b: 1, a: 2, ab: 3 }); + // Expected order: a, b, ab (short keys first). + // Header byte: major 5 (map), count 3 → 0xa3. + expect(out[0]).toBe(0xa3); + // First key should be "a" (0x61 text-string head len 1, then 0x61). + expect(out[1]).toBe(0x61); + expect(out[2]).toBe(0x61); + }); + + it("generates a P-256 keypair and a did:key", async () => { + const pair = await generateKeyPair(); + expect(pair.publicDidKey).toMatch(/^did:key:z/); + expect(pair.privateJwk.kty).toBe("EC"); + expect(pair.privateJwk.crv).toBe("P-256"); + }); + + it("computeDidPlc returns a stable did:plc", async () => { + const signing = await generateKeyPair(); + const rotation = await generateKeyPair(); + const unsigned = buildGenesisOp({ + rotationKeys: [rotation.publicDidKey], + verificationMethodAtproto: signing.publicDidKey, + }); + const signed = await signGenesisOp(unsigned, rotation.privateJwk); + const did = await computeDidPlc(signed); + expect(did).toMatch(/^did:plc:[a-z2-7]{24}$/); + }); + + it("jwkToDidKey: public key roundtrip shape", async () => { + const pair = await generateKeyPair(); + const k = jwkToDidKey(pair.privateJwk); // private JWK carries the pub coords + expect(k).toBe(pair.publicDidKey); + }); +}); + +describe("community.mint — stage 4", () => { + let app: Hono; + + beforeAll(async () => { + app = await makeApp(); + }); + + it("mints a community, returns recovery key, submits to PLC, bootstraps reserved spaces", async () => { + const before = plcCalls.length; + const res = await call(app, "POST", "/xrpc/test.comm.community.mint", ALICE, {}); + expect(res.status).toBe(200); + const body = (await res.json()) as any; + + expect(body.communityDid).toMatch(/^did:plc:[a-z2-7]{24}$/); + // Recovery key is a private JWK returned once. + expect(body.recoveryKey.kty).toBe("EC"); + expect(body.recoveryKey.crv).toBe("P-256"); + expect(body.recoveryKey.d).toBeTruthy(); // private scalar + + // PLC submission happened. + const ourCalls = plcCalls.slice(before); + expect(ourCalls).toHaveLength(1); + expect(ourCalls[0]!.method).toBe("POST"); + expect(ourCalls[0]!.url).toBe(`https://plc.test/${body.communityDid}`); + expect(ourCalls[0]!.body.type).toBe("plc_operation"); + expect(ourCalls[0]!.body.sig).toBeTruthy(); + expect(ourCalls[0]!.body.rotationKeys).toHaveLength(2); + + // Reserved spaces exist with the caller as owner. + const adminUri = `at://${body.communityDid}/tools.atmo.event.space/$admin`; + const whoami = await call(app, "GET", `/xrpc/test.comm.community.whoami?spaceUri=${encodeURIComponent(adminUri)}`, ALICE); + expect(((await whoami.json()) as any).accessLevel).toBe("owner"); + }); + + it("minted community rejects publishing (no PDS)", async () => { + const res = await call(app, "POST", "/xrpc/test.comm.community.mint", ALICE, {}); + const { communityDid } = (await res.json()) as any; + + const pub = await call(app, "POST", "/xrpc/test.comm.community.putRecord", ALICE, { + communityDid, + collection: "app.event.message", + record: { text: "nope" }, + }); + expect(pub.status).toBe(400); + expect(((await pub.json()) as any).reason).toBe("publishing-not-supported-for-minted-communities"); + }); + + it("multiple mints produce distinct DIDs", async () => { + const r1 = await call(app, "POST", "/xrpc/test.comm.community.mint", BOB, {}); + const r2 = await call(app, "POST", "/xrpc/test.comm.community.mint", BOB, {}); + const d1 = ((await r1.json()) as any).communityDid; + const d2 = ((await r2.json()) as any).communityDid; + expect(d1).not.toBe(d2); + }); +}); diff --git a/tests/community-publishing.test.ts b/tests/community-publishing.test.ts new file mode 100644 index 0000000..5c97c2b --- /dev/null +++ b/tests/community-publishing.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { Hono } from "hono"; +import type { MiddlewareHandler } from "hono"; +import { createSqliteDatabase } from "../src/adapters/sqlite"; +import { initSchema } from "../src/core/db/schema"; +import { createApp } from "../src/core/router"; +import { resolveConfig } from "../src/core/types"; +import type { ContrailConfig } from "../src/core/types"; + +const ALICE = "did:plc:alice"; +const BOB = "did:plc:bob"; +const CHARLIE = "did:plc:charlie"; +const COMMUNITY_DID = "did:plc:pubcomm"; +const PDS_ENDPOINT = "https://pds.example"; + +const MASTER_KEY = new Uint8Array(32).fill(42); + +/** Shared state for asserting PDS proxying happened. */ +const pdsCalls: Array<{ url: string; body: any }> = []; + +const CONFIG: ContrailConfig = { + namespace: "test.comm", + collections: { message: { collection: "app.event.message" } }, + spaces: { + type: "tools.atmo.event.space", + serviceDid: "did:web:test.example#svc", + }, + community: { + masterKey: MASTER_KEY, + fetch: mockFetch, + resolver: mockResolver(), + }, +}; + +function mockResolver(): any { + return { + resolve: async (did: string) => { + if (did !== COMMUNITY_DID) throw new Error("unknown did"); + return { + id: did, + service: [ + { + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: PDS_ENDPOINT, + }, + ], + }; + }, + }; +} + +async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const body = init?.body ? JSON.parse(init.body as string) : {}; + pdsCalls.push({ url, body }); + if (url.endsWith("/xrpc/com.atproto.server.createSession")) { + if (body.password === "correct-pw" || body.password === "new-correct-pw") { + return new Response( + JSON.stringify({ accessJwt: "a.b.c", refreshJwt: "r.r.r", did: COMMUNITY_DID }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + return new Response(JSON.stringify({ error: "AuthFailed" }), { status: 401 }); + } + if (url.endsWith("/xrpc/com.atproto.repo.createRecord")) { + return new Response( + JSON.stringify({ + uri: `at://${COMMUNITY_DID}/${body.collection}/fakerkey`, + cid: "bafyfake", + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + if (url.endsWith("/xrpc/com.atproto.repo.deleteRecord")) { + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response("not found", { status: 404 }); +} + +function fakeAuth(): MiddlewareHandler { + return async (c, next) => { + const did = c.req.header("X-Test-Did"); + if (!did) return c.json({ error: "AuthRequired" }, 401); + c.set("serviceAuth", { issuer: did, audience: CONFIG.spaces!.serviceDid, lxm: undefined }); + await next(); + }; +} + +async function makeApp(): Promise { + const db = createSqliteDatabase(":memory:"); + const resolved = resolveConfig(CONFIG); + await initSchema(db, resolved); + return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); +} + +function call( + app: Hono, + method: string, + path: string, + did: string, + body?: any +): Promise { + const headers: Record = { "X-Test-Did": did }; + if (body !== undefined) headers["Content-Type"] = "application/json"; + return app.fetch( + new Request(`http://localhost${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + ); +} + +async function adopt(app: Hono, caller: string, password: string) { + const res = await call(app, "POST", "/xrpc/test.comm.community.adopt", caller, { + identifier: COMMUNITY_DID, + appPassword: password, + }); + expect(res.status).toBe(200); +} + +async function grant(app: Hono, caller: string, spaceUri: string, subject: any, accessLevel: string) { + const res = await call(app, "POST", "/xrpc/test.comm.community.space.grant", caller, { + spaceUri, + subject, + accessLevel, + }); + expect(res.status).toBe(200); + return res; +} + +describe("community publishing + reauth — stage 3", () => { + let app: Hono; + const publishers = `at://${COMMUNITY_DID}/tools.atmo.event.space/$publishers`; + const admin = `at://${COMMUNITY_DID}/tools.atmo.event.space/$admin`; + + beforeAll(async () => { + app = await makeApp(); + await adopt(app, ALICE, "correct-pw"); + }); + + it("community.putRecord proxies to PDS for a $publishers member", async () => { + // Alice is already owner of $publishers from bootstrap; add Bob as plain member. + await grant(app, ALICE, publishers, { did: BOB }, "member"); + + const before = pdsCalls.length; + const res = await call(app, "POST", "/xrpc/test.comm.community.putRecord", BOB, { + communityDid: COMMUNITY_DID, + collection: "app.event.message", + record: { text: "hello from the community" }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as any; + expect(body.uri).toMatch(/^at:\/\//); + + const newCalls = pdsCalls.slice(before); + expect(newCalls.some((c) => c.url.endsWith("/xrpc/com.atproto.server.createSession"))).toBe(true); + expect(newCalls.some((c) => c.url.endsWith("/xrpc/com.atproto.repo.createRecord"))).toBe(true); + }); + + it("non-$publishers member cannot putRecord", async () => { + const res = await call(app, "POST", "/xrpc/test.comm.community.putRecord", CHARLIE, { + communityDid: COMMUNITY_DID, + collection: "app.event.message", + record: { text: "nope" }, + }); + expect(res.status).toBe(403); + expect(((await res.json()) as any).reason).toBe("not-in-publishers"); + }); + + it("community.space.putRecord writes in-space with community author (admin+ required)", async () => { + // Create a content space. + const createRes = await call(app, "POST", "/xrpc/test.comm.community.space.create", ALICE, { + communityDid: COMMUNITY_DID, + key: "announcements", + }); + expect(createRes.status).toBe(200); + const spaceUri = ((await createRes.json()) as any).space.uri; + + // Alice (owner, which ≥ admin) writes. + const put = await call(app, "POST", "/xrpc/test.comm.community.space.putRecord", ALICE, { + spaceUri, + collection: "app.event.message", + record: { text: "first post" }, + }); + expect(put.status).toBe(200); + const body = (await put.json()) as any; + expect(body.authorDid).toBe(COMMUNITY_DID); + }); + + it("community.space.putRecord rejects non-admin members", async () => { + const createRes = await call(app, "POST", "/xrpc/test.comm.community.space.create", ALICE, { + communityDid: COMMUNITY_DID, + key: "ann2", + }); + const spaceUri = ((await createRes.json()) as any).space.uri; + await grant(app, ALICE, spaceUri, { did: BOB }, "member"); + + const put = await call(app, "POST", "/xrpc/test.comm.community.space.putRecord", BOB, { + spaceUri, + collection: "app.event.message", + record: { text: "nope" }, + }); + expect(put.status).toBe(403); + }); + + it("setAccessLevel with rank-outranking", async () => { + const createRes = await call(app, "POST", "/xrpc/test.comm.community.space.create", ALICE, { + communityDid: COMMUNITY_DID, + key: "roles-test", + }); + const spaceUri = ((await createRes.json()) as any).space.uri; + await grant(app, ALICE, spaceUri, { did: BOB }, "manager"); + await grant(app, ALICE, spaceUri, { did: CHARLIE }, "member"); + + // Bob (manager) promotes Charlie to manager — OK. + const ok = await call(app, "POST", "/xrpc/test.comm.community.space.setAccessLevel", BOB, { + spaceUri, + subject: { did: CHARLIE }, + accessLevel: "manager", + }); + expect(ok.status).toBe(200); + + // Bob (manager) tries to promote Charlie to admin — rejected. + const nope = await call(app, "POST", "/xrpc/test.comm.community.space.setAccessLevel", BOB, { + spaceUri, + subject: { did: CHARLIE }, + accessLevel: "admin", + }); + expect(nope.status).toBe(403); + }); + + it("getHealth reports healthy", async () => { + const res = await call(app, "GET", `/xrpc/test.comm.community.getHealth?communityDid=${COMMUNITY_DID}`, ALICE); + expect(res.status).toBe(200); + expect(((await res.json()) as any).status).toBe("healthy"); + }); + + it("reauth replaces the stored app password; old password no longer works", async () => { + const reauth = await call(app, "POST", "/xrpc/test.comm.community.reauth", ALICE, { + communityDid: COMMUNITY_DID, + appPassword: "new-correct-pw", + }); + expect(reauth.status).toBe(200); + + // getHealth still works with the new stored password. + const health = await call(app, "GET", `/xrpc/test.comm.community.getHealth?communityDid=${COMMUNITY_DID}`, ALICE); + expect(((await health.json()) as any).status).toBe("healthy"); + }); + + it("reauth requires owner in $admin", async () => { + const res = await call(app, "POST", "/xrpc/test.comm.community.reauth", BOB, { + communityDid: COMMUNITY_DID, + appPassword: "correct-pw", + }); + expect(res.status).toBe(403); + }); + + it("reauth with bad credentials is rejected", async () => { + const res = await call(app, "POST", "/xrpc/test.comm.community.reauth", ALICE, { + communityDid: COMMUNITY_DID, + appPassword: "totally-wrong", + }); + expect(res.status).toBe(401); + }); +}); diff --git a/tests/spaces-acl.test.ts b/tests/spaces-acl.test.ts index 81ff32a..4f09ea5 100644 --- a/tests/spaces-acl.test.ts +++ b/tests/spaces-acl.test.ts @@ -9,7 +9,6 @@ function mkSpace(overrides: Partial = {}): SpaceRow { type: "tools.atmo.event.space", key: "s1", serviceDid: "did:web:example.com#svc", - memberListRef: null, appPolicyRef: null, appPolicy: null, createdAt: 1, @@ -18,8 +17,8 @@ function mkSpace(overrides: Partial = {}): SpaceRow { }; } -function mkMember(did: string, perms: "read" | "write" = "write"): SpaceMemberRow { - return { spaceUri: "x", did, perms, addedAt: 1, addedBy: null }; +function mkMember(did: string): SpaceMemberRow { + return { spaceUri: "x", did, addedAt: 1, addedBy: null }; } describe("spaces acl", () => { @@ -48,38 +47,17 @@ describe("spaces acl", () => { expect((r as any).reason).toBe("not-member"); }); - it("member with read perm can read", () => { + it("member can read and write (no perm tiering)", () => { const s = mkSpace(); - const r = checkAccess({ - op: "read", - space: s, - callerDid: "did:plc:bob", - member: mkMember("did:plc:bob", "read"), - }); - expect(r.allow).toBe(true); - }); - - it("member with read perm cannot write", () => { - const s = mkSpace(); - const r = checkAccess({ - op: "write", - space: s, - callerDid: "did:plc:bob", - member: mkMember("did:plc:bob", "read"), - }); - expect(r.allow).toBe(false); - expect((r as any).reason).toBe("not-writer"); - }); - - it("member with write perm can write", () => { - const s = mkSpace(); - const r = checkAccess({ - op: "write", - space: s, - callerDid: "did:plc:bob", - member: mkMember("did:plc:bob", "write"), - }); - expect(r.allow).toBe(true); + for (const op of ["read", "write"] as const) { + const r = checkAccess({ + op, + space: s, + callerDid: "did:plc:bob", + member: mkMember("did:plc:bob"), + }); + expect(r.allow).toBe(true); + } }); it("non-member cannot write", () => { @@ -91,28 +69,28 @@ describe("spaces acl", () => { member: null, }); expect(r.allow).toBe(false); - expect((r as any).reason).toBe("not-writer"); + expect((r as any).reason).toBe("not-member"); }); - it("delete own: member-with-write can delete own record", () => { + it("delete own: member can delete own record", () => { const s = mkSpace(); const r = checkAccess({ op: "delete", space: s, callerDid: "did:plc:bob", - member: mkMember("did:plc:bob", "write"), + member: mkMember("did:plc:bob"), targetAuthorDid: "did:plc:bob", }); expect(r.allow).toBe(true); }); - it("delete other's: member-with-write cannot delete someone else's record", () => { + it("delete other's: member cannot delete someone else's record", () => { const s = mkSpace(); const r = checkAccess({ op: "delete", space: s, callerDid: "did:plc:bob", - member: mkMember("did:plc:bob", "write"), + member: mkMember("did:plc:bob"), targetAuthorDid: "did:plc:charlie", }); expect(r.allow).toBe(false); @@ -131,17 +109,17 @@ describe("spaces acl", () => { expect(r.allow).toBe(true); }); - it("delete without write perm: read-only member cannot delete own", () => { + it("delete by non-member: denied as not-member, not not-own-record", () => { const s = mkSpace(); const r = checkAccess({ op: "delete", space: s, callerDid: "did:plc:bob", - member: mkMember("did:plc:bob", "read"), + member: null, targetAuthorDid: "did:plc:bob", }); expect(r.allow).toBe(false); - expect((r as any).reason).toBe("not-writer"); + expect((r as any).reason).toBe("not-member"); }); it("app policy: allow-mode with apps[] denylists those apps", () => { diff --git a/tests/spaces-e2e.test.ts b/tests/spaces-e2e.test.ts index 92b1fe5..1fd965c 100644 --- a/tests/spaces-e2e.test.ts +++ b/tests/spaces-e2e.test.ts @@ -136,7 +136,6 @@ describe("spaces e2e", () => { const res = await call(app, "POST", "/xrpc/test.spaces.space.addMember", ALICE, { spaceUri, did: BOB, - perms: "write", }); expect(res.status).toBe(200); }); diff --git a/tests/spaces-invites.test.ts b/tests/spaces-invites.test.ts index e14f641..22219b3 100644 --- a/tests/spaces-invites.test.ts +++ b/tests/spaces-invites.test.ts @@ -93,7 +93,6 @@ describe("invite e2e", () => { it("owner creates an invite and Bob redeems it to become a member", async () => { const create = await call(app, "POST", "/xrpc/test.spaces.space.invite.create", ALICE, { spaceUri, - perms: "write", }); expect(create.status).toBe(200); const { token, invite } = (await create.json()) as any; @@ -106,7 +105,6 @@ describe("invite e2e", () => { expect(redeem.status).toBe(200); const body = (await redeem.json()) as any; expect(body.spaceUri).toBe(spaceUri); - expect(body.perms).toBe("write"); // Bob is now a member — can write a message const put = await call(app, "POST", "/xrpc/test.spaces.space.putRecord", BOB, { @@ -201,7 +199,7 @@ describe("invite e2e", () => { it("read-join token grants anonymous read AND can be redeemed for membership", async () => { const create = await call(app, "POST", "/xrpc/test.spaces.space.invite.create", ALICE, { - spaceUri, kind: "read-join", perms: "write", + spaceUri, kind: "read-join", }); const { token, invite } = (await create.json()) as any; expect(invite.kind).toBe("read-join"); diff --git a/tests/types.test.ts b/tests/types.test.ts index b18d746..bec1de5 100644 --- a/tests/types.test.ts +++ b/tests/types.test.ts @@ -183,7 +183,7 @@ describe("resolveConfig", () => { it("applies default jetstreams and relays", () => { const resolved = resolveConfig({ namespace: "test", collections: {} }); - expect(resolved.jetstreams).toHaveLength(4); + expect(resolved.jetstreams).toHaveLength(1); expect(resolved.relays).toHaveLength(1); });