Collaboration/ACL

ACL

Every membership change is a signed record appended to the space's access-control list and verified by every peer. These endpoints write those records; the members list is how you read the result.

Endpoints

Method Path Purpose
POST /v1/spaces/:spaceId/acl/accept grant a pending join request a permission
POST /v1/spaces/:spaceId/acl/decline decline a join request
POST /v1/spaces/:spaceId/acl/permissions change permissions, batched
POST /v1/spaces/:spaceId/acl/remove remove accounts — rotates the read key
POST /v1/spaces/:spaceId/acl/add add accounts by identity
POST /v1/spaces/:spaceId/acl/ownership transfer ownership
POST /v1/spaces/:spaceId/acl/self-remove leave the space
POST /v1/spaces/:spaceId/acl/cancel-join withdraw your own pending join request
POST /v1/spaces/:spaceId/acl/stop-sharing drop everyone and rotate the key

Every successful operation returns 204 No Content. self-remove, cancel-join and stop-sharing take no body. Unknown permission strings return 400 request.schema.

Accepting a join request

Pending requests appear on GET /v1/spaces/:spaceId/members/requests; pass a row's recordId as requestRecordId.

curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/acl/accept \
  -d '{"requestRecordId": "bafy…", "permission": "writer"}'

curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/acl/decline \
  -d '{"identity": "A6ux…"}'
any acl accept  <spaceId> --record <recordId> [--permission writer]
any acl decline <spaceId> --identity <identity>

On the joiner's side the space row flips from joining to active, or to deleted on a decline; the owner's members list shows active or declined.

Changing permissions

curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/acl/permissions \
  -d '{"changes": [
        {"identity": "A6ux…", "permission": "reader"},
        {"identity": "BcdE…", "permission": "admin"}]}'
any acl grant <spaceId> <identity> <permission>

Members see their own change on SpaceInfo.ownRole and on the members stream as a changed frame.

Removing members

curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/acl/remove \
  -d '{"identities": ["A6ux…", "BcdE…"]}'
any acl remove <spaceId> <identity>...

Removal rotates the space's read key. Content written after the rotation is encrypted under a key the removed accounts never receive.

Why it matters. Removal is enforced by cryptography rather than by a server refusing reads: what a removed member keeps is the ciphertext they already had, and the sync nodes cannot hand them anything newer they could decrypt.

Adding by identity

When you hold the account id, add directly instead of passing an invite token:

curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/acl/add \
  -d '{"accounts": [
        {"identity": "A6ux…", "permission": "writer", "metadata": {"name": "Alice"}}]}'
any acl add <spaceId> <identity>[,<identity>...] <permission>

The whole batch lands in one ACL record, and every added account is notified through the coordinator inbox (durable, retried). On their side the space shows up as a synced invite_pending row in the space list, carrying the sender-supplied name hint. They are already full ACL members; the pending state only governs whether their device materializes the space — nothing is downloaded until accepted.

GET  /v1/spaces?status=invite_pending             # discover
POST /v1/spaces/:spaceId/invite/accept            # 200 SpaceInfo, or 202 while loading continues in the background
POST /v1/spaces/:spaceId/invite/decline           # 204 — synced sticky, non-terminal

Accept flips the synced status to active on every device and loads the space; it is idempotent and overrides an earlier decline. Decline suppresses the invite account-wide but changes nothing on the ACL — the account remains a member until it self-removes.

Status Code When
404 space.not_found unknown space id
409 space.not_invite_pending the row is not awaiting approval
400 request.invalid_field a one-to-one row — use the one-to-one endpoints

Ownership and leaving

curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/acl/ownership \
  -d '{"newOwner": "A6ux…", "oldOwnerPerm": "admin"}'

curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/acl/self-remove
curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/acl/cancel-join
curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/acl/stop-sharing
any acl ownership    <spaceId> --new-owner <identity> [--old-owner-perm admin]
any acl self-remove  <spaceId>
any acl cancel-join  <spaceId>
any acl stop-sharing <spaceId>
Operation Effect
ownership hands the owner role to newOwner; the previous owner keeps oldOwnerPerm
self-remove requests your own removal; your row shows removing until applied
cancel-join withdraws your own pending join request: the owner's members row shows canceled, your space row reads deleted on every device of the account, and a fresh POST /v1/spaces/join re-requests
stop-sharing drops every other member and rotates the read key — the space becomes private to the owner again

cancel-join is the one operation that never loads the space — a pending join is never materialized — so it works from any device of the account. It answers 404 space.not_found for a space the account has no row for, and 409 space.join_not_pending when the row is not joining or the owner accepted first; a request already gone from the chain with no membership behind it is settled by the call (204, row deleted).

Note. A one-to-one space has an immutable two-writer ACL, so none of these operations apply to it. "Accepting" a direct chat is a device-local decision, described on One-to-one.