A self-hostable link language server for AD4M. Communities run this on their own hardware; AD4M agents authenticate with their DID and sync link data through it. Think Matrix homeserver, but purpose-built for AD4M link sync instead of chat.
Companion package: server-link-language — the AD4M link language that talks to this server.
npx @coasys/link-server --port 3456 --data ./my-dataOr with Docker:
cd link-server
docker compose up -dThe server generates its own JWT signing secret on first run (<data-dir>/data.sqlite) and creates rooms on demand — there's no separate provisioning step.
Build and start the server:
cd link-server
docker compose up -dThe server listens on port 3456 and stores data in a named Docker volume (link-server-data). Override settings with environment variables:
PORT=4000 AUTO_ADMIT=true docker compose up -d| Variable | Default | What it does |
|---|---|---|
PORT |
3456 |
Listen port (host and container) |
AUTO_ADMIT |
false |
Admit every authenticating agent automatically |
The Dockerfile includes a HEALTHCHECK that polls GET /health every 30 seconds. Use docker inspect or docker compose ps to confirm the container reports healthy.
To stop:
docker compose down # stop the container (data persists in the volume)
docker compose down -v # stop and delete the data volumeControl the server through environment variables or CLI flags:
| Environment variable | CLI flag | Default | What it does |
|---|---|---|---|
PORT |
--port |
3456 |
Listen port |
DATA_DIR |
--data |
./data |
Storage directory (SQLite database) |
AUTO_ADMIT |
--auto-admit |
false |
Admit every agent automatically when they authenticate |
MAX_DIFFS_PER_ROOM |
--max-diffs |
10000 |
Maximum diff entries retained per room (older entries pruned) |
BODY_LIMIT |
--body-limit |
10485760 |
Maximum HTTP request body size in bytes (10 MiB) |
No room creation step needed. When the first agent authenticates against a room ID, the server creates that room and promotes the agent to admin. Every subsequent agent must pass the room's access control before they can read or write.
Without AUTO_ADMIT, only the admin can access the room. The admin adds or removes members through the /acl endpoint:
# Add a member
curl -X POST https://your-server:3456/rooms/YOUR_ROOM/acl \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"action": "add", "did": "did:key:z6Mk..."}'
# Remove a member
curl -X POST https://your-server:3456/rooms/YOUR_ROOM/acl \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"action": "remove", "did": "did:key:z6Mk..."}'
# List all members
curl https://your-server:3456/rooms/YOUR_ROOM/acl \
-H "Authorization: Bearer $ADMIN_TOKEN"For open communities, start the server with AUTO_ADMIT=true and skip member management entirely.
The server handles storage and sync — it does not speak AD4M on its own. The companion server-link-language bridges the gap: AD4M agents load that language, which then connects to this server, authenticates, and syncs links automatically. See that package for instructions on publishing the language and creating neighbourhoods.
E2E encryption uses true client-side key generation: the admin's language client generates each room key locally, seals it to every member's X25519 public key via ECIES (ephemeral X25519 ECDH + HKDF-SHA256 + AES-256-GCM), and uploads only the sealed envelopes. The server never sees the plaintext key.
# Enable or rotate the room key (admin only).
# The client generates the key, fetches ACL for X25519 public keys,
# seals the key to each member, and POSTs only sealed envelopes:
curl -X POST https://your-server:3456/rooms/YOUR_ROOM/keys/rotate \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"keys": [{"did": "did:key:z6Mk...", "encryptedKey": {"ephemeralPublicKey": "...", "nonce": "...", "ciphertext": "..."}}]}'After rotation, each member receives a sealed copy of the new room key the next time they connect or refresh their key ring. Once a room has E2E enabled, the server rejects plaintext commits — a client without keys cannot write until it receives them.
After adding a new member to an encrypted room, rotate again so they receive the new version. The admin's language instance then automatically detects members missing historical key versions and re-seals those versions for them (see performAdminKeyGrants in server-link-language).
- Rooms are independent link-sync spaces, identified by an opaque
roomIdthe client chooses. The first agent to authenticate against a room becomes its admin. - Auth is DID challenge-response: an agent proves control of its
did:keyed25519 key by signing a server-issued nonce, and receives a JWT scoped to(did, roomId). - ACL gates every room endpoint. Only the admin can add/remove DIDs.
- Links are stored as an append-only diff log (
PerspectiveDiff= additions/removals of signedLinkExpressions) plus a derived active-set table, so the room's state is alwaysreplay(diffs). The revision is a content hash of the active set's link hashes — order-independent, so two servers with the same active links converge to the same revision regardless of how they got there. - WebSocket push delivers committed diffs and telepresence events in real time.
- E2E encryption is opt-in per room: the entire
LinkExpression(author, timestamp, proof, and data) becomes an opaque ciphertext blob. The server sees only{ciphertext, nonce}plus a client-computedlink_hashfor OR-Set dedup. Once enabled, the server rejects plaintext commits.
See AGENTS.md for architecture, file layout, and implementation decisions made where the spec was ambiguous.
All endpoints except /rooms/:roomId/auth require Authorization: Bearer <jwt>.
POST /rooms/:roomId/auth { did } -> { challenge }
{ did, challenge, signature } -> { token, expiresAt }
POST /rooms/:roomId/commit { additions: LinkExpression[], removals: LinkExpression[] } -> { sequence, revision }
GET /rooms/:roomId/sync ?since=<sequence> -> { diffs: PerspectiveDiff[], revision, sequence }
GET /rooms/:roomId/render -> { links: LinkExpression[], revision }
GET /rooms/:roomId/revision -> { revision, sequence }
GET /rooms/:roomId/peers -> { peers: string[] } (currently online agents)
POST /rooms/:roomId/acl { action: "add"|"remove", did } (admin only)
GET /rooms/:roomId/acl -> { admin, members: [{did, x25519PublicKey}] }
GET /rooms/:roomId/keys -> { keys: [...], e2e_enabled } | 404 (no E2E)
GET /rooms/:roomId/keys/missing (admin only) -> { membersNeedingHistoricalKeys }
POST /rooms/:roomId/keys/rotate (admin only) -> { version, recipients, membersNeedingHistoricalKeys }
GET /rooms/:roomId/ws (WebSocket upgrade — first message must be {type:"auth",token:"<jwt>"})
Server -> client: diff, telepresence-signal, telepresence-broadcast, online-agents, peer-joined, peer-left, status-changed, auth-error.
Client -> server: auth { token } (first message only), telepresence-signal { toDid, payload }, telepresence-broadcast { payload }, set-online-status { status }.
100 req/min per IP on /auth, 300 req/min per JWT on room endpoints, 60 req/min per JWT on /commit specifically (stacked on top of the general room limit). Sliding window, in-memory. 429 responses carry Retry-After in seconds.
npm install # NODE_ENV must not be "production" or devDependencies won't install
npm test # node's built-in test runner, tests/*.test.ts
npm run build # tsc -> dist/
npm run dev # tsx src/index.ts, no build stepTests boot a real server per test (random port, temp SQLite file) and drive it over real HTTP/WebSocket — there are no mocks of the server itself.
Key generation now happens exclusively client-side — the admin generates each room key locally, seals it to members' X25519 public keys, and uploads only sealed envelopes. The server never touches plaintext key material. This closes the most significant trust gap (server-side key generation).
Remaining gap:
- Unsigned X25519 public key. The DID challenge signature covers only the
nonce, not the
x25519PublicKeyfield sent alongside it. A malicious server could substitute its own X25519 key for a member's during the ACL ormembersNeedingHistoricalKeysresponse, causing the admin to seal keys to the server instead of the real member. Fix: require the client to sendsignature = sign(x25519PublicKey)at registration, store it, return it in ACL/missing-keys responses, and have the admin verify the DID signature before sealing. The signing capability already exists.
With this gap open, the E2E guarantee protects against passive observation and later compromise, but not against an actively malicious server operator who tampers with X25519 public keys at registration time.
- Admin succession / key revocation: if the room admin's key gets compromised, no mechanism exists to rotate admin authority or revoke a leaked agent key retroactively. A compromised admin can seal new room keys for arbitrary recipients. Future work: admin transfer endpoint, key revocation list, and forward-secrecy ratchet for room keys.
- Perfect forward secrecy: room keys are long-lived. Compromising a room key exposes all past ciphertext sealed under it. A ratchet or epoch-based key rotation would bound the exposure window.