diff --git a/server.js b/server.js
new file mode 100644
index 0000000..8a16906
--- /dev/null
+++ b/server.js
@@ -0,0 +1,203 @@
+import http from 'node:http';
+import crypto from 'node:crypto';
+import { Pool } from 'pg';
+
+const PORT = Number(process.env.PORT || 3000);
+const APP_BASE_URL = (process.env.APP_BASE_URL || '').replace(/\/$/, '');
+const ISSUER = (process.env.KEYCLOAK_ISSUER || '').replace(/\/$/, '');
+const CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID || '';
+const CLIENT_SECRET = process.env.KEYCLOAK_CLIENT_SECRET || '';
+const DATABASE_URL = process.env.DATABASE_URL || '';
+
+for (const [name, value] of Object.entries({ APP_BASE_URL, ISSUER, CLIENT_ID, CLIENT_SECRET, DATABASE_URL })) {
+ if (!value) throw new Error(`Missing required environment variable: ${name}`);
+}
+
+const pool = new Pool({ connectionString: DATABASE_URL, max: 5 });
+const sessions = new Map();
+
+function esc(value) {
+ return String(value ?? '').replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]);
+}
+
+function parseCookies(req) {
+ const out = {};
+ for (const part of (req.headers.cookie || '').split(';')) {
+ const i = part.indexOf('=');
+ if (i < 0) continue;
+ out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1));
+ }
+ return out;
+}
+
+function setCookie(res, name, value, maxAge = 3600) {
+ const cookie = `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;
+ const existing = res.getHeader('Set-Cookie');
+ res.setHeader('Set-Cookie', existing ? [...(Array.isArray(existing) ? existing : [existing]), cookie] : cookie);
+}
+
+function redirect(res, location) {
+ res.writeHead(302, { Location: location });
+ res.end();
+}
+
+function send(res, status, body, type = 'text/html; charset=utf-8') {
+ res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' });
+ res.end(body);
+}
+
+async function readForm(req) {
+ let raw = '';
+ for await (const chunk of req) {
+ raw += chunk;
+ if (raw.length > 1024 * 1024) throw new Error('Request too large');
+ }
+ return Object.fromEntries(new URLSearchParams(raw));
+}
+
+function sessionFor(req) {
+ const sid = parseCookies(req).crud_session;
+ if (!sid) return null;
+ const session = sessions.get(sid);
+ if (!session || session.expiresAt < Date.now()) {
+ if (sid) sessions.delete(sid);
+ return null;
+ }
+ return session;
+}
+
+function layout(user, content) {
+ return `
+
+Arrowstack Tasks
+
+
Arrowstack Tasks
Keycloak + dedicated PostgreSQL CRUD demo
${esc(user.preferred_username || user.email || 'user')} Logout
+${content}
+
`;
+}
+
+async function initDb() {
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS tasks (
+ id BIGSERIAL PRIMARY KEY,
+ title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200),
+ completed BOOLEAN NOT NULL DEFAULT FALSE,
+ created_by TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )
+ `);
+ await pool.query(`CREATE INDEX IF NOT EXISTS idx_tasks_created_by_created_at ON tasks (created_by, created_at DESC)`);
+}
+
+await initDb();
+console.log('database schema ready');
+
+const server = http.createServer(async (req, res) => {
+ try {
+ const url = new URL(req.url, APP_BASE_URL);
+
+ if (url.pathname === '/health') {
+ await pool.query('SELECT 1');
+ return send(res, 200, JSON.stringify({ ok: true, database: 'connected' }), 'application/json; charset=utf-8');
+ }
+
+ if (url.pathname === '/login') {
+ const state = crypto.randomBytes(24).toString('base64url');
+ setCookie(res, 'oidc_state', state, 300);
+ const auth = new URL(`${ISSUER}/protocol/openid-connect/auth`);
+ auth.searchParams.set('client_id', CLIENT_ID);
+ auth.searchParams.set('redirect_uri', `${APP_BASE_URL}/callback`);
+ auth.searchParams.set('response_type', 'code');
+ auth.searchParams.set('scope', 'openid profile email');
+ auth.searchParams.set('state', state);
+ return redirect(res, auth.toString());
+ }
+
+ if (url.pathname === '/callback') {
+ const cookies = parseCookies(req);
+ const state = url.searchParams.get('state') || '';
+ const code = url.searchParams.get('code') || '';
+ if (!code || !state || cookies.oidc_state !== state) return send(res, 400, 'Invalid OAuth callback state', 'text/plain; charset=utf-8');
+
+ const tokenRes = await fetch(`${ISSUER}/protocol/openid-connect/token`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ grant_type: 'authorization_code',
+ code,
+ redirect_uri: `${APP_BASE_URL}/callback`,
+ client_id: CLIENT_ID,
+ client_secret: CLIENT_SECRET
+ })
+ });
+ const token = await tokenRes.json();
+ if (!tokenRes.ok || !token.access_token) return send(res, 502, `Keycloak token exchange failed (${tokenRes.status})`, 'text/plain; charset=utf-8');
+
+ const userRes = await fetch(`${ISSUER}/protocol/openid-connect/userinfo`, { headers: { Authorization: `Bearer ${token.access_token}` } });
+ const user = await userRes.json();
+ if (!userRes.ok || !user.sub) return send(res, 502, 'Keycloak userinfo failed', 'text/plain; charset=utf-8');
+
+ const sid = crypto.randomBytes(32).toString('base64url');
+ sessions.set(sid, { user, expiresAt: Date.now() + Math.min(Number(token.expires_in || 900), 3600) * 1000 });
+ setCookie(res, 'crud_session', sid, Math.min(Number(token.expires_in || 900), 3600));
+ setCookie(res, 'oidc_state', '', 0);
+ return redirect(res, '/');
+ }
+
+ if (url.pathname === '/logout') {
+ const sid = parseCookies(req).crud_session;
+ if (sid) sessions.delete(sid);
+ setCookie(res, 'crud_session', '', 0);
+ return redirect(res, '/login');
+ }
+
+ const session = sessionFor(req);
+ if (!session) return redirect(res, '/login');
+ const userKey = session.user.sub;
+
+ if (req.method === 'POST' && url.pathname === '/tasks') {
+ const form = await readForm(req);
+ const title = String(form.title || '').trim().slice(0, 200);
+ if (title) await pool.query('INSERT INTO tasks (title, created_by) VALUES ($1, $2)', [title, userKey]);
+ return redirect(res, '/');
+ }
+
+ const match = url.pathname.match(/^\/tasks\/(\d+)\/(toggle|update|delete)$/);
+ if (req.method === 'POST' && match) {
+ const id = Number(match[1]);
+ const action = match[2];
+ if (action === 'toggle') {
+ await pool.query('UPDATE tasks SET completed = NOT completed, updated_at = NOW() WHERE id = $1 AND created_by = $2', [id, userKey]);
+ } else if (action === 'update') {
+ const form = await readForm(req);
+ const title = String(form.title || '').trim().slice(0, 200);
+ if (title) await pool.query('UPDATE tasks SET title = $1, updated_at = NOW() WHERE id = $2 AND created_by = $3', [title, id, userKey]);
+ } else if (action === 'delete') {
+ await pool.query('DELETE FROM tasks WHERE id = $1 AND created_by = $2', [id, userKey]);
+ }
+ return redirect(res, '/');
+ }
+
+ if (req.method === 'GET' && url.pathname === '/') {
+ const result = await pool.query('SELECT id, title, completed, created_at FROM tasks WHERE created_by = $1 ORDER BY created_at DESC', [userKey]);
+ const rows = result.rows.map(task => `
+
+
+
Created ${esc(new Date(task.created_at).toLocaleString())}
+
+
`).join('');
+
+ return send(res, 200, layout(session.user, `${rows || '
No tasks yet. Add one above.
'}
`));
+ }
+
+ return send(res, 404, 'Not found', 'text/plain; charset=utf-8');
+ } catch (err) {
+ console.error(err);
+ return send(res, 500, 'Internal server error', 'text/plain; charset=utf-8');
+ }
+});
+
+server.listen(PORT, '0.0.0.0', () => console.log(`arrowstack-crud-demo listening on :${PORT}`));