Skip to content

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.

~10 minutesBuilds on Tutorial 4TypeScript · zero codegen

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.

tutorial 5 · real sign-up, real session, whole chat domainbooting…

the password is scrypt-hashed by the real plugin — in this tab

What's real here: the actual plugin-auth sign-up path — scrypt password hash, session mint, guest→user connection swap — and the actual plugin-chat membership/message model, on one merged contract. In-tab substitutions: the loopback wire, and Grace's replies are scripted (sent through the real server-side chatKit).

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.

bash
pnpm add @super-line/plugin-auth @super-line/plugin-chat
bash
npm install @super-line/plugin-auth @super-line/plugin-chat
bash
yarn add @super-line/plugin-auth @super-line/plugin-chat

2. 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:

ts
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:

ts
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:

ts
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 ​

bash
npx tsx src/app.ts
super-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:

tsx
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 wroteRoleWhat it does
plugins: [authContract(), chatContract()]ContractMerges identity + the whole chat model into your one contract.
auth({ … }) / chat({ … }) kitsServerShip every policy and handler; expose trusted imperative surfaces (users.create, messages.send).
authenticate + identify + nodeKeyServerThe three auth touch-points; principal := userId everywhere.
hooks.sendMessage.beforeServerTransform or veto any op — for browsers, kits, and agents alike.
authClient / chatClientClientThe 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 ​

Released under the MIT License.