REST routes
The ten built-in endpoints — the full request and response reference, how they are auto-registered, the three levels of control over them, and why every document name travels as a query or body field.
Collaboration is not only a WebSocket. Everything that is not the live document — the credential
to open it, its comment threads, its version history, its raw bytes — travels over ordinary HTTP.
The provider mounts ten endpoints under /collaboration at boot, and because they are ordinary
Adonis routes, Tuyau types them for your frontend like any other.
The endpoints
| Method | Path | Purpose |
|---|---|---|
GET | /collaboration/token?doc= | mint a WebSocket credential for a document |
GET | /collaboration/comments?doc=&space=&page=&size= | list a document's comments, optionally one space |
POST | /collaboration/comments | create an anchored comment |
PATCH | /collaboration/comments/:id?doc= | resolve or reopen a comment |
DELETE | /collaboration/comments/:id?doc= | remove a comment |
GET | /collaboration/versions?doc=&page=&size= | list a document's versions |
POST | /collaboration/versions | create a named version |
POST | /collaboration/versions/restore | restore a version into the live document |
GET | /collaboration/state?doc= | read the raw document binary |
POST | /collaboration/state?doc= | persist a document binary (worker only) |
The document name never rides in the path
researches/42/writing has slashes in it, so putting it in a route segment means escaping it and
un-escaping it correctly everywhere forever. Every endpoint takes the name in the query string
(?doc=) or the body (docName) instead. The only path parameter in the whole surface is a
comment id, which is a UUID.
Reference
GET /collaboration/token
The one endpoint with a security story of its own — see Token flow.
GET /collaboration/token?doc=researches/42/writing{ "token": "eyJ1c2VySWQ…", "wsUrl": "/collaboration", "engine": "yjs" }| Status | When |
|---|---|
400 | the doc query param is missing |
401 | no authenticated user could be resolved |
403 | authorize returned canRead: false |
Comments
GET /collaboration/comments?doc=researches/42/writing&space=textReturns CollabComment[], oldest first. Omit space for every space. page/size page the
result — offset-based with a 1-based page (default 1) and a size clamped server-side to
1..200 (default 50), the same ?page=1&size=25 shape every @adonis-agora/* list endpoint
uses. Omit both for the first page.
POST /collaboration/comments
Content-Type: application/json
{
"docName": "researches/42/writing",
"space": "text",
"anchor": { "kind": "text-range", "start": 120, "end": 160, "selectedText": "the sentence" },
"body": "rephrase this",
"authorName": "Jane"
}Returns the created comment, with its generated id and createdAt. There is no userId field:
the author is the authenticated caller, and a body that supplies one is ignored.
PATCH /collaboration/comments/:id?doc=researches/42/writing { "resolved": true }
DELETE /collaboration/comments/:id?doc=researches/42/writingresolved defaults to true, so an empty PATCH body resolves; pass false to reopen. DELETE
answers { "deleted": true }. An id that is not in the document answers 404.
Both mutations are author-or-elevated: the person who wrote the comment may always resolve or
delete it, and anyone else needs canWrite on the document — a moderating editor, not a fellow
commenter. canComment alone gets you 403 on someone else's thread.
Versions
GET /collaboration/versions?doc=researches/42/writing
POST /collaboration/versions { "docName": "…", "label": "before review" }
POST /collaboration/versions/restore { "docName": "…", "versionId": "…" }GET /versions takes the same page/size paging as GET /comments. POST /versions returns
the created CollabVersion; like a comment, its createdBy is the authenticated caller and a
userId in the body is ignored. POST /versions/restore returns 204
and takes effect live — every connected client receives the restored content through the sync
protocol, and the state the restore replaced is written to the history first as
restored from #<seq>, attributed to the caller. See
Versions.
Binary state
GET /collaboration/state?doc=researches/42/writing → application/octet-stream
POST /collaboration/state?doc=researches/42/writing ← the worker's snapshotMachine-to-machine, used by edge workers. POST requires the worker's shared secret in
x-collab-worker-secret and answers 403 without it. See
Binary state.
Three levels of control
Level one — leave them alone. The provider registers everything under /collaboration during
its boot() — before Server.boot() commits the router, which is the only window in which a route
is accepted. Nothing to write.
Level two — keep the handlers, change the wrapper. Prefix, middleware and user resolution are config:
routes: {
prefix: '/api/collab',
middleware: [middleware.auth(), middleware.tenant()],
resolveUser: (ctx) => ({ id: String(ctx.auth.user!.id), name: ctx.auth.user!.fullName }),
}Or register them yourself, for full control over placement in start/routes.ts:
import router from '@adonisjs/core/services/router'
import { collaborationRoutes } from '@adonis-agora/collaboration'
// with routes.enabled = false in config
collaborationRoutes(router, {
prefix: '/api/collab',
middleware: [middleware.auth()],
})collaborationRoutes is synchronous, so it also composes inside a group you already have —
a RouteGroup closes the moment its callback returns, and an await in there would drop the
routes outside it:
router
.group(() => {
collaborationRoutes(router, { prefix: '/collab' })
})
.prefix('/api/v2')
.use(middleware.auth())Registering them yourself does not weaken the permission rule. With no explicit authorize option
the routes resolve it through the manager — the same declared-document-then-global-then-deny
resolution the WebSocket handshake runs, straight out of config/collaboration.ts.
Level three — write the controllers. Disable auto-registration and build the surface yourself,
keeping the library's token semantics through the exported issueToken. See
Custom controllers.
Authentication
The token endpoint resolves the caller through routes.resolveUser, which defaults to reading
ctx.auth.user and throwing a 401 when there is none — so an install that forgot to protect
these routes fails closed rather than minting anonymous credentials.
Every other endpoint runs the same check. Each resolves the caller through routes.resolveUser
and then runs the document's authorize, requiring the permission that route needs:
| Endpoint | Requires |
|---|---|
GET /comments, GET /versions, GET /state | canRead |
POST /versions, POST /versions/restore | canWrite |
POST /comments | canComment |
PATCH/DELETE /comments/:id | canComment and authorship — or canWrite to moderate |
POST /state is the exception: it is machine-to-machine and authenticated by the worker secret
instead. See Binary state.
A comment is attributed to the caller, not to the body
POST /comments takes the author from the authenticated user and ignores any userId in the
request body — otherwise anyone could comment as anyone. The same applies to the createdBy on a
version.
Authentication: the default resolver tries first
The routes enforce authorization — who may do what to which document. For authentication the
default resolveUser calls ctx.auth.check() (or authenticate()) itself before reading
ctx.auth.user, so a lazily-resolving guard still produces a caller without a global middleware
bolted onto a hardcoded prefix. A request that cannot be authenticated answers 401.
Setting routes.middleware is still the right move when you want the whole group behind your own
stack — rate limiting, tenancy, an audit trail — or when your guard is not reachable from
ctx.auth. Then supply routes.resolveUser too.
Edge (PartyKit / PartyServer)
Move the WebSocket to Cloudflare Durable Objects — how the worker, the signed token and the /state endpoints divide the work, what you have to own, and when the latency is worth it.
Token flow
How a browser gets a credential a WebSocket can carry — the five steps, what the signed token contains, where its key comes from, what happens when one expires, and how the reconnect loop turns expiry into revocation.