5. Sign in before joining
We now assemble the final application. Build it and create two accounts:
npm run build
npm run add-user -- alice
npm run add-user -- bob
npm startEach provisioning command prints a fresh random password once. Save it. Visit http://localhost:8181/login, not a different hostname. Use separate browser profiles or a private window so Alice and Bob do not share one cookie jar.
Reuse the tested sign-in implementation
The project includes the authentication and store modules from the released Redweb 0.14.0 dashboard recipe. The website generates these files from that immutable catalogue instead of maintaining a second copy of password logic. Only the relative import extension changes for this ESM project.
Passwords use asynchronous salted scrypt. Sessions are random opaque tokens, stored as hashes in SQLite, and carried in HttpOnly, SameSite=Strict cookies. Login attempts and concurrent password work are bounded. There is no default password or browser-stored bearer secret.
The existing store contains unused dashboard card tables; this tutorial reuses its account/session operations only. This is a reuse boundary, not a claim that Redweb itself provides a managed identity service.
One identity for the page and socket
import express from 'express';
import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineApp, page, type LivePageRequestContext } from 'redweb';
import { DashboardAuth, sessionToken } from './shared/auth.js';
import { DashboardStore } from './shared/store.js';
import { matchRoute } from './routes.js';
export const databasePath = () => resolve(process.env.GAME_DATABASE ?? 'data/game.sqlite');
export interface Options { port?: number; database?: string; origin?: string; signals?: boolean; }
export function gameApp(options: Options = {}) {
let store: DashboardStore;
let auth: DashboardAuth;
const configured = options.origin ?? process.env.GAME_ORIGIN;
if (configured && (!/^https?:$/.test(new URL(configured).protocol) || new URL(configured).origin !== configured)) throw new Error('GAME_ORIGIN must be an exact HTTP(S) origin.');
if (process.env.NODE_ENV === 'production' && !configured?.startsWith('https://')) throw new Error('Set an HTTPS GAME_ORIGIN for production.');
const origin = () => configured ?? `http://localhost:${(app.server!.address() as { port: number }).port}`;
// Exact browser origin: do not derive trust from forwarded headers.
const origins = (candidate: string | undefined) => candidate === origin();
const account = (request: { headers: { cookie?: string | readonly string[] } }) =>
store.session(sessionToken(typeof request.headers.cookie === 'string' ? request.headers.cookie : undefined))?.account;
const MatchRoute = matchRoute(account, origins);
@page('/login', { live: false })
class Login {
render() { return <main><h1>Sign in to play</h1><p>Use an account created with npm run add-user.</p>
<form method="post" action="/login"><label>Account <input name="account" autocomplete="username" required /></label>
<label>Password <input name="password" type="password" autocomplete="current-password" required /></label>
<button>Sign in</button></form></main>; }
}
const Board = () => <div id="board" aria-label="Game board">{Array.from({ length: 9 }, (_, cell) =>
<button data-cell={cell} aria-label={`Square ${cell + 1}`} disabled>·</button>)}</div>;
@page('/', { live: false, authorize: context => Boolean(account(context.request)) })
class GamePage {
render(context: LivePageRequestContext) { return <main><h1>Tic-tac-toe</h1><p>Signed in as {context.principal}</p>
<form method="post" action="/logout"><button>Sign out all sessions</button></form>
<p>Share a room name with another player. The first two accounts reserve its seats.</p>
<form id="join"><label>Room <input name="room" pattern="[a-z0-9-]{1,32}" maxlength="32" required /></label><button>Join room</button></form>
<p id="connection" role="status">Connecting…</p><p id="status" role="status">Choose a room.</p>
<Board /><p id="notice" role="alert"></p><p><a href="/">Choose another room</a></p>
<link rel="stylesheet" href="/game.css" /><script type="module" src="/client.js"></script>
</main>; }
}
class Accounts {
onInit() {
const filename = options.database ?? databasePath();
mkdirSync(dirname(filename), { recursive: true });
store = new DashboardStore(filename);
auth = new DashboardAuth(store);
app.app!.use(express.urlencoded({ extended: false, limit: '4kb', parameterLimit: 4 }));
app.app!.get('/client.js', (_request, response) => response.sendFile(fileURLToPath(new URL('./client.js', import.meta.url))));
app.app!.get('/game.css', (_request, response) => response.sendFile(fileURLToPath(new URL('../src/game.css', import.meta.url))));
auth.mount(app.app!, origin, origins, async user => {
const route = app.sockets!.routes.find(route => route.path === '/match')!;
for (const socket of route.clients.values()) if (socket.context?.principal === user) socket.close(1008, 'Signed out.');
});
}
onShutdown() { auth?.close(); store?.close(); }
}
const app = defineApp({ pages: [Login, GamePage], sockets: [MatchRoute], services: [Accounts],
port: options.port ?? Number(process.env.PORT ?? 8181), bind: '127.0.0.1', signals: options.signals, logger: null,
authenticate: request => request.url?.split('?')[0] === '/login' ? true : account(request),
origins,
});
return app;
}The account lookup is used for the protected TSX page and /match admission. Login/logout POSTs and WebSocket upgrades require the exact trusted origin. Production requires an explicit HTTPS origin; the app does not trust forwarded headers to establish identity or origin.
Accounts.onInit() opens the store and mounts ordinary Express routes before the listener opens. onShutdown() closes resources. The final defineApp registers the pages, game route and lifecycle service together. There is no second call to initialize a WebSocket server.
The gameApp factory is application code for configuring isolated instances in tests. It is not an extra Redweb startup primitive. The executable entry remains:
import { gameApp } from './server.js';
const app = gameApp();
await app.run();Checkpoint: an incorrect password is rejected through the real browser form; a correct one opens the board. An unsigned or cross-origin socket cannot join. Logout invalidates sessions before closing that account's existing sockets.
Download the complete project Setup, optional configuration, and limits