Custom controllers
Take over the HTTP surface without losing the library's token semantics — the three levels of control, a complete working controller, and the authorization you take over along with it.
The built-in routes are ordinary Adonis routes, which is why most apps never think about them. When you do need to think about them, there are three levels, and only the third means writing controllers.
Level one — change the wrapper
Prefix, middleware and user resolution are config. This is enough for the large majority of cases, including putting your auth middleware in front of every collaboration endpoint:
routes: {
prefix: '/api/collab',
middleware: [middleware.auth(), middleware.tenant()],
resolveUser: (ctx) => ({ id: String(ctx.auth.user!.id), name: ctx.auth.user!.fullName }),
}Level two — register them yourself
Disable auto-registration and call collaborationRoutes where you want it in start/routes.ts —
inside an existing group, behind a version prefix, next to your other API routes:
routes: { enabled: false }import router from '@adonisjs/core/services/router'
import { collaborationRoutes } from '@adonis-agora/collaboration'
import { middleware } from '#start/kernel'
router.group(() => {
collaborationRoutes(router, {
prefix: '/collab',
middleware: [middleware.auth()],
})
}).prefix('/api/v2')collaborationRoutes is synchronous precisely so this works: a RouteGroup closes as soon as its
callback returns, so an async callback with an await before the registration would land the
routes outside the group and outside your middleware.
The provider still boots the manager and attaches the WebSocket — only the route registration is
skipped. The permission rule is unchanged either way: with no authorize option the routes resolve
it through the manager, from config/collaboration.ts.
Level three — write the controllers
Now you own the handlers. The library still owns sync, storage, versions and comments; you own the HTTP shape — including the permission checks, which stop being applied for you the moment the built-in routes are off.
routes: { enabled: false }import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import {
CollabForbiddenError,
CollaborationManager,
canMutateComment,
issueToken,
} from '@adonis-agora/collaboration'
import app from '@adonisjs/core/services/app'
@inject()
export default class CollaborationController {
constructor(private collaboration: CollaborationManager) {}
/** A WebSocket credential for a document. */
async token({ request, response, auth }: HttpContext) {
const docName = request.qs().doc as string | undefined
if (!docName) return response.badRequest({ error: 'doc is required' })
const config = app.config.get<{ path?: string; partykit?: unknown }>('collaboration', {})
const engine = await this.collaboration.engineFor({ docName })
try {
return await issueToken(
{ engine, path: config.path, partykit: config.partykit as never },
{ id: String(auth.user!.id), name: auth.user!.fullName },
docName,
// The manager's resolution, NOT your own copy of the global callback —
// this is what keeps per-document rules applying.
(ctx, doc) => this.collaboration.authorize(ctx, doc),
)
} catch (error) {
if (error instanceof CollabForbiddenError) return response.forbidden({ error: error.message })
throw error
}
}
/** Comments — reproducing, by hand, the check the built-in route already makes. */
async listComments({ request, response, auth }: HttpContext) {
const { doc, space } = request.qs() as { doc?: string; space?: string }
if (!doc) return response.badRequest({ error: 'doc is required' })
const permission = await this.collaboration.authorize({ userId: String(auth.user!.id) }, doc)
if (!permission.canRead) return response.forbidden({ error: 'no access to this document' })
return this.collaboration.comments.list(doc, space)
}
async createComment({ request, response, auth }: HttpContext) {
const body = request.body()
const permission = await this.collaboration.authorize(
{ userId: String(auth.user!.id) },
body.docName,
)
if (!permission.canComment) return response.forbidden({ error: 'commenting is not allowed' })
return this.collaboration.comments.create(body.docName, {
space: body.space,
anchor: body.anchor,
body: body.body,
userId: String(auth.user!.id), // from the session, never from the request
authorName: auth.user!.fullName,
})
}
/**
* Resolving or deleting someone else's comment is not `canComment`.
* `canMutateComment` is the library's own rule, exported so a controller
* enforces exactly what the built-in route does.
*/
async resolveComment({ request, response, params, auth }: HttpContext) {
const docName = request.qs().doc as string
const userId = String(auth.user!.id)
const permission = await this.collaboration.authorize({ userId }, docName)
if (!permission.canComment) return response.forbidden({ error: 'commenting is not allowed' })
const comments = await this.collaboration.comments.list(docName)
const comment = comments.find((entry) => entry.id === params.id)
if (!comment) return response.notFound({ error: 'comment not found' })
if (!canMutateComment({ comment, userId, permission })) {
return response.forbidden({ error: 'not your comment' })
}
return this.collaboration.comments.resolve(docName, params.id, request.body().resolved ?? true)
}
async createVersion({ request, auth }: HttpContext) {
const { docName, label } = request.body()
return this.collaboration.createVersion({
docName,
createdBy: String(auth.user!.id),
label: label ?? null,
})
}
async presence({ request }: HttpContext) {
return this.collaboration.listPresence({ docName: String(request.qs().doc) })
}
}const Collaboration = () => import('#controllers/collaboration_controller')
router
.group(() => {
router.get('/token', [Collaboration, 'token'])
router.get('/comments', [Collaboration, 'listComments'])
router.post('/comments', [Collaboration, 'createComment'])
router.patch('/comments/:id', [Collaboration, 'resolveComment'])
router.post('/versions', [Collaboration, 'createVersion'])
router.get('/presence', [Collaboration, 'presence'])
})
.prefix('/api/collab')
.use([middleware.auth(), middleware.tenant()])The three details that matter
Use issueToken, do not hand-roll it. It signs the credential with the key the handshake
verifies against (the app's own for self-hosted, partykit.jwtSecret for the edge), binds it to
the document, builds the wsUrl, and enforces canRead. Writing your own means a token the driver
or the worker will not accept — and, before 0.13, meant a token anyone could have written.
Pass the manager's authorize, not the config's. this.collaboration.authorize(ctx, docName)
resolves declared documents first and falls back to the global callback — handing issueToken your
raw config.authorize silently skips every per-document rule.
Take userId from the session, never from the request body. Anything a client sends about who
it is, it can lie about. The built-in routes attribute a comment to the authenticated caller and
ignore a body-supplied userId; a controller of your own should do the same.
Reuse canMutateComment for comment mutations. canComment is permission to add a comment, not
to resolve or delete someone else's — the rule is author-or-canWrite, and it is exported precisely
so a controller does not have to re-derive it and get it subtly wrong. Checking only canComment
there is the bug the built-in routes had before 0.10.0.
issueToken refuses without a rule. Pass it an authorize or it throws CollabForbiddenError
rather than minting: an absent rule is a misconfiguration, not a grant. That is why the example
above passes the manager's.
The built-in routes already guard themselves
Every built-in route resolves the caller and checks the matching permission before touching
anything — canRead to read, canWrite for versions, canComment to add a comment, and
author-or-canWrite to resolve or delete one. See
Authorization. The controller above reproduces that by
hand, which earns its keep when you need a different response shape or an extra rule, and is pure
duplication when you do not. Reach for level three for the surface, not for the guard — and if you
do, reproduce all four rules, not the three that are obvious.
What you keep either way
Taking over HTTP does not fork the library. The WebSocket still runs, authorize still guards the
handshake, storage still persists, the manager still resolves engines per document, and the client
hooks still work — as long as your routes answer the same shapes. Point the client at your prefix
with baseUrl, or keep the paths and change only the guards.
The trade
Take control when you need per-route guards, a versioned API, a response shape a frontend already depends on, or a rule of your own on top of the library's.
Stay with the defaults when the built-in routes fit. They are ordinary routes: Tuyau types
them, routes.middleware protects them, and routes.prefix moves them. Every controller you write
is one more thing to test and keep in sync when the library's shapes evolve.
Advanced
The two escape hatches — replacing the built-in REST surface with your own controllers, and binding a rich-text editor directly to the shared document.
Tiptap collaboration
Turn a single-user Tiptap editor into a shared one — the provider, the shared Y.Doc, remote cursors, the undo trap, and what the saved document actually looks like.