Tutorial 5 · Add auth + chat — plugins snap in whole domains
Tutorials → 4 · Store your data → 5 · Add auth + chat → 6 · Collaborate on one document
In Tutorial 4 you faked identity with a handshake param and hand-wrote one policy. Real apps need sign-up, sessions, roles — and a real chat model is channels, membership, and per-channel security on top. Both ship as plugins: contract fragments that merge into your contract, paired with server kits that bring their own policies and handlers. You wire touch-points; you write no auth tables and no chat handlers.
Merge two pluginsSign up authClientChat chatClient
First, see it run
This form is not a mock. Submitting it runs @super-line/plugin-auth's actual sign-up path in this tab — scrypt-hashes the password, creates the user, mints a session, and swaps the guest connection for an authed one (watch the state strip). Then you're chatting in a real plugin-chat channel as that user. Grace is a seeded user; her replies are scripted, but they travel through the real server-side chatKit — the same trusted door an AI agent uses.
Sign out and watch the surface go idle — every identity change in super-line is a session replacement: the role is fixed at connect, so changing who you are means swapping the connection. The plugins own that dance; you never write it.
1. Install the two plugins
Chat requires auth on the same server — its rows reference the users directory and every action is keyed on the signed-in principal. You have everything else from Tutorials 1–4.
pnpm add @super-line/plugin-auth @super-line/plugin-chatnpm install @super-line/plugin-auth @super-line/plugin-chatyarn add @super-line/plugin-auth @super-line/plugin-chat2. Merge both onto the contract
A plugin's collections, roles, and requests merge into the one contract via plugins: [...]. authContract() brings the guest role, users/credentials/sessions, and signIn/signUp/signOut/whoami. chatContract() brings six collections (channels/memberships/messages/…) and ~20 request verbs. You declare almost nothing:
import { defineContract, type RowOf } from '@super-line/core'
import { authContract } from '@super-line/plugin-auth'
import { chatContract } from '@super-line/plugin-chat'
export const app = defineContract({
roles: { user: {} }, // your role — do NOT declare `guest`; the auth plugin adds it
plugins: [authContract(), chatContract()],
})
// Types flow from the merged contract — one source of truth, no codegen.
export type Message = RowOf<typeof app, 'messages'>A plugin is a merge, not a side-table
RowOf<typeof app, 'messages'> resolves because messages really is on this contract now — the same mechanism as your own collections block, just contributed by the fragment. See the plugin model.
3. Wire the server — kits, not handlers
auth() returns an authKit; chat() a chatKit. Their .plugin halves carry every policy and handler — membership-scoped read RLS, write-deny, all the request implementations. Your three auth touch-points are authenticate, identify, and the plugin arrays; your one piece of custom logic here is a hook, the un-bypassable extension seam that fires for browser requests and server-side calls alike:
import http from 'node:http'
import { createSuperLineServer } from '@super-line/server'
import { webSocketServerTransport } from '@super-line/transport-websocket'
import { memoryCollections } from '@super-line/collections-memory'
import { auth } from '@super-line/plugin-auth/server'
import { chat } from '@super-line/plugin-chat/server'
import { app } from './contract'
const server = http.createServer()
const backend = memoryCollections() // ONE backend serves your collections AND the plugins'
export const authKit = auth({ contract: app, collections: backend, defaultRoles: ['user'] })
export const chatKit = chat({
contract: app,
hooks: {
sendMessage: {
before: (input) => {
const content = typeof input.content === 'string' ? input.content.trim() : input.content
if (!content) throw new Error('empty message') // veto — nothing is written
return { ...input, content } // transform — every message arrives trimmed
},
},
},
})
export const srv = createSuperLineServer(app, {
nodeKey: 'my-line-1', // stable per replica — plugin-auth keys sessions on it
transports: [webSocketServerTransport({ server })],
collections: backend,
authenticate: authKit.authenticate, // passwords, access tokens, API keys, JWT — all handled
identify: authKit.identify, // principal := userId — drives YOUR policies and chat's
plugins: [authKit.plugin, chatKit.plugin],
})
server.listen(3000, () => console.log('super-line chat server on ws://localhost:3000'))Compare this to Tutorial 4: there you wrote the notes policy yourself — that skill still matters for your collections (and principal is now a real logged-in userId, not a handshake claim). But channels, memberships, messages? Their policies shipped in chatKit.plugin.
4. Sign up and chat from the client
authClient wraps the guest↔authed session replacement behind plain signUp/signIn/signOut; chatClient wraps the chat verbs and live message windows. Append a tracer to src/server.ts (below listen), or keep it as its own file importing the kits:
import { createSuperLineClient } from '@super-line/client'
import { webSocketClientTransport } from '@super-line/transport-websocket'
import { authClient } from '@super-line/plugin-auth/client'
import { chatClient } from '@super-line/plugin-chat/client'
import { app } from './contract'
import { authKit, chatKit } from './server'
// Seed a teammate + a public channel, server-side (trusted kit calls).
const grace = await authKit.users.create({ displayName: 'Grace' })
const channel = await chatKit.channels.create({ name: 'welcome', visibility: 'public', owner: grace.id })
// The reader's side: sign up, then chat as that user.
const connect = ({ role, params }: { role: string; params: Record<string, string> }) =>
createSuperLineClient(app, {
transport: webSocketClientTransport({ url: 'ws://localhost:3000' }),
role: role as 'user',
params,
})
let stored: string | null = null // in the browser, omit `storage` — it defaults to localStorage
const ada = authClient({
authedRole: 'user',
connect,
storage: { get: () => stored, set: (t) => (stored = t) },
})
await ada.ready
await ada.signUp({ email: 'ada@example.com', password: 'correct-horse', displayName: 'Ada' })
console.log('ada →', ada.state.status, ada.state.displayName, ada.state.roles)
const cc = chatClient(ada.client, { userId: ada.state.userId! })
await cc.join(channel.id) // public channel → self-join
const feed = cc.messages(channel.id)
await feed.ready
feed.subscribe(() => {
const last = feed.rows().at(-1)
if (last) console.log(`💬 ${last.authorId === grace.id ? 'Grace' : 'Ada'}: ${last.content}`)
})
await cc.send(channel.id, ' hi! ') // note the padding — your sendMessage hook trims it
await chatKit.messages.send({ channelId: channel.id, authorId: grace.id, content: 'welcome aboard 👋' })
await new Promise((r) => setTimeout(r, 300))
ada.client.close()5. Run it
npx tsx src/app.tssuper-line chat server on ws://localhost:3000
ada → authed Ada [ 'user' ]
💬 Ada: hi!
💬 Grace: welcome aboard 👋A real login and a whole chat domain — and you wrote neither.
Ada's password was scrypt-hashed and a session minted by authKit; identify made her the principal behind every policy; her send was validated, trimmed by your hook, stamped, and fanned out by chatKit's handlers; and Grace's reply came through the imperative server kit — the exact door an AI agent uses. The demo at the top is this code, live.
The same, in React
The browser story is two providers that feed the one registered binding from Tutorial 3 — no bridge code:
import { SuperLineAuthProvider, useAuth } from '@super-line/plugin-auth/react'
import { ChatProvider, useChannels, useMessages } from '@super-line/plugin-chat/react'
createRoot(el).render(
<SuperLineAuthProvider authedRole="user" connect={connect}>
<ChatProvider>
<App /> {/* useAuth() → { state, signIn, signUp, signOut }; every data hook follows the session */}
</ChatProvider>
</SuperLineAuthProvider>,
)<SuperLineAuthProvider> owns the session lifecycle and feeds useCollection/useDoc/useRequest/… — before sign-in they idle (reads empty, writes reject); after sign-in they follow the new session automatically. <ChatProvider> auto-builds the chat binding on top. See Add authentication and the chat plugin.
What just happened
| What you wrote | Role | What it does |
|---|---|---|
plugins: [authContract(), chatContract()] | Contract | Merges identity + the whole chat model into your one contract. |
auth({ … }) / chat({ … }) kits | Server | Ship every policy and handler; expose trusted imperative surfaces (users.create, messages.send). |
authenticate + identify + nodeKey | Server | The three auth touch-points; principal := userId everywhere. |
hooks.sendMessage.before | Server | Transform or veto any op — for browsers, kits, and agents alike. |
authClient / chatClient | Client | The guest↔authed replacement machine; typed chat verbs + live message windows. |
Next: state that merges
Chat messages are rows — last-writer-wins, and that's right for them. But a shared canvas, a rich-text doc, a scene graph want two people editing at once without clobbering each other. That's the other consistency model.
Continue the series
Tutorial 6 · Collaborate on one document → — CRDT document collections: open by id, edit concurrently, merge — with every write still schema-validated.
Or branch off from here
- Add authentication (plugin) — everything the kit wires, including the React provider.
- Sessions, roles & API keys · JWT & sealed tokens — the other credential shapes.
- Add a chat backbone (plugin) — structured message bodies, the imperative kit, all the hooks.
- Run an AI chat bot — the agent side of the same door Grace used.
examples/collections-chat— a Slack-like app built on exactly this pairing.