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();
const PRIORITIES = new Set(['low', 'normal', 'high']);
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 normalisePriority(value) {
const priority = String(value || 'normal').toLowerCase();
return PRIORITIES.has(priority) ? priority : 'normal';
}
function priorityOptions(current = 'normal') {
return ['low', 'normal', 'high'].map(value => ``).join('');
}
function layout(user, content) {
return `
Arrowstack Tasks
Arrowstack Tasks schema v2
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,
priority TEXT NOT NULL DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'high')),
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await pool.query(`
ALTER TABLE tasks
ADD COLUMN IF NOT EXISTS priority TEXT NOT NULL DEFAULT 'normal'
CHECK (priority IN ('low', 'normal', 'high'))
`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_tasks_created_by_created_at ON tasks (created_by, created_at DESC)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_tasks_created_by_priority_created_at ON tasks (created_by, priority, created_at DESC)`);
}
await initDb();
console.log('database schema v2 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 priority FROM tasks LIMIT 1');
return send(res, 200, JSON.stringify({ ok: true, database: 'connected', schema: 2 }), '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);
const priority = normalisePriority(form.priority);
if (title) await pool.query('INSERT INTO tasks (title, priority, created_by) VALUES ($1, $2, $3)', [title, priority, 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);
const priority = normalisePriority(form.priority);
if (title) await pool.query('UPDATE tasks SET title = $1, priority = $2, updated_at = NOW() WHERE id = $3 AND created_by = $4', [title, priority, 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, priority, created_at FROM tasks WHERE created_by = $1 ORDER BY created_at DESC', [userKey]);
const rows = result.rows.map(task => `
${esc(task.priority)}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}`));