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.