diff --git a/server.js b/server.js
new file mode 100644
index 0000000..a485cdf
--- /dev/null
+++ b/server.js
@@ -0,0 +1,323 @@
+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: 8 });
+const SESSION_COOKIE = 'cooking_session';
+const STATE_COOKIE = 'cooking_oidc_state';
+
+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;
+ try { out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1)); } catch {}
+ }
+ 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 b64url(input) {
+ return Buffer.from(input).toString('base64url');
+}
+
+function sign(value) {
+ return crypto.createHmac('sha256', CLIENT_SECRET).update(value).digest('base64url');
+}
+
+function makeSession(user, ttlSeconds) {
+ const payload = b64url(JSON.stringify({
+ sub: user.sub,
+ name: user.preferred_username || user.name || 'cook',
+ exp: Math.floor(Date.now() / 1000) + ttlSeconds
+ }));
+ return `${payload}.${sign(payload)}`;
+}
+
+function readSession(req) {
+ const raw = parseCookies(req)[SESSION_COOKIE];
+ if (!raw) return null;
+ const [payload, signature] = raw.split('.');
+ if (!payload || !signature) return null;
+ const expected = sign(payload);
+ const a = Buffer.from(signature);
+ const b = Buffer.from(expected);
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
+ try {
+ const session = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
+ if (!session.sub || !session.exp || session.exp < Math.floor(Date.now() / 1000)) return null;
+ return session;
+ } catch {
+ return null;
+ }
+}
+
+function redirect(res, location) {
+ res.writeHead(302, { Location: location, 'cache-control': 'no-store' });
+ 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 slugify(title) {
+ const base = String(title).toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'recipe';
+ return `${base}-${crypto.randomBytes(3).toString('hex')}`;
+}
+
+function normaliseTags(value) {
+ const tags = String(value || '').split(',').map(v => v.trim().toLowerCase().replace(/[^a-z0-9 -]/g, '').replace(/\s+/g, '-')).filter(Boolean);
+ return [...new Set(tags)].slice(0, 8);
+}
+
+function clampInt(value, min, max, fallback = 0) {
+ const n = Number.parseInt(value, 10);
+ if (!Number.isFinite(n)) return fallback;
+ return Math.max(min, Math.min(max, n));
+}
+
+function nl2br(value) {
+ return esc(value).replace(/\n/g, '
');
+}
+
+function nav(session) {
+ return ``;
+}
+
+function page(title, body, session, options = {}) {
+ return `
+ ${esc(title)} · cooking
+ ${nav(session)}${body}
`;
+}
+
+function recipeForm(recipe = {}) {
+ const tags = Array.isArray(recipe.tags) ? recipe.tags.join(', ') : '';
+ const published = recipe.published !== false;
+ return `
+
+
+
+
+
+ `;
+}
+
+async function initDb() {
+ await pool.query(`CREATE TABLE IF NOT EXISTS recipes (
+ id BIGSERIAL PRIMARY KEY,
+ slug TEXT NOT NULL UNIQUE,
+ title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 160),
+ summary TEXT NOT NULL DEFAULT '',
+ ingredients TEXT NOT NULL,
+ instructions TEXT NOT NULL,
+ tags TEXT[] NOT NULL DEFAULT '{}',
+ servings INTEGER NOT NULL DEFAULT 4 CHECK (servings BETWEEN 1 AND 50),
+ prep_minutes INTEGER NOT NULL DEFAULT 0 CHECK (prep_minutes BETWEEN 0 AND 1440),
+ cook_minutes INTEGER NOT NULL DEFAULT 0 CHECK (cook_minutes BETWEEN 0 AND 1440),
+ published BOOLEAN NOT NULL DEFAULT TRUE,
+ created_by TEXT NOT NULL,
+ author_name 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_recipes_published_created ON recipes (published, created_at DESC)`);
+ await pool.query(`CREATE INDEX IF NOT EXISTS idx_recipes_created_by ON recipes (created_by, updated_at DESC)`);
+ await pool.query(`CREATE INDEX IF NOT EXISTS idx_recipes_tags ON recipes USING GIN (tags)`);
+}
+
+await initDb();
+console.log('cooking schema ready');
+
+const server = http.createServer(async (req, res) => {
+ try {
+ const url = new URL(req.url, APP_BASE_URL);
+ const session = readSession(req);
+
+ if (url.pathname === '/favicon.ico') return send(res, 204, '', 'image/x-icon');
+ if (url.pathname === '/health') {
+ await pool.query('SELECT id FROM recipes LIMIT 1');
+ return send(res, 200, JSON.stringify({ ok: true, database: 'connected', schema: 1 }), 'application/json; charset=utf-8');
+ }
+
+ if (url.pathname === '/login') {
+ const state = crypto.randomBytes(24).toString('base64url');
+ setCookie(res, STATE_COOKIE, 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[STATE_COOKIE] !== state) return redirect(res, '/login');
+
+ 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', '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 ttl = Math.max(60, Math.min(Number(token.expires_in || 900), 3600));
+ setCookie(res, SESSION_COOKIE, makeSession(user, ttl), ttl);
+ setCookie(res, STATE_COOKIE, '', 0);
+ return redirect(res, '/');
+ }
+
+ if (url.pathname === '/logout') {
+ setCookie(res, SESSION_COOKIE, '', 0);
+ setCookie(res, STATE_COOKIE, '', 0);
+ return redirect(res, '/');
+ }
+
+ if (req.method === 'GET' && url.pathname === '/') {
+ const q = String(url.searchParams.get('q') || '').trim().slice(0, 80);
+ const tag = String(url.searchParams.get('tag') || '').trim().toLowerCase().slice(0, 40);
+ const where = ['published = TRUE'];
+ const values = [];
+ if (q) {
+ values.push(`%${q}%`);
+ where.push(`(title ILIKE $${values.length} OR summary ILIKE $${values.length})`);
+ }
+ if (tag) {
+ values.push(tag);
+ where.push(`$${values.length} = ANY(tags)`);
+ }
+ const result = await pool.query(`SELECT slug,title,summary,tags,servings,prep_minutes,cook_minutes,author_name,created_at FROM recipes WHERE ${where.join(' AND ')} ORDER BY created_at DESC LIMIT 60`, values);
+ const tagsResult = await pool.query(`SELECT tag, COUNT(*)::int AS count FROM recipes, unnest(tags) tag WHERE published = TRUE GROUP BY tag ORDER BY count DESC, tag LIMIT 14`);
+ const cards = result.rows.map(r => `${esc(r.title)}
${esc(r.summary || 'A shared recipe waiting to be cooked.')}
${r.tags.slice(0,3).map(t => `${esc(t)}`).join('')}
by ${esc(r.author_name)}${r.prep_minutes + r.cook_minutes} min · ${r.servings} serves
`).join('');
+ const chips = tagsResult.rows.map(r => `#${esc(r.tag)} ${r.count}`).join('');
+ const body = `Recipes worth passing on
Cook it. Love it.
Share it.
A simple home for recipes from people you know. No life story before the ingredients.
${chips ? `${chips}
` : ''}${cards || `No recipes yet
${session ? 'Share the first one.' : 'Sign in and share the first one.'}
${session ? '
Add a recipe' : '
Sign in'}
`}`;
+ return send(res, 200, page('Discover recipes', body, session));
+ }
+
+ if (req.method === 'GET' && url.pathname === '/new') {
+ if (!session) return redirect(res, '/login');
+ return send(res, 200, page('New recipe', ``, session));
+ }
+
+ if (req.method === 'POST' && url.pathname === '/recipes') {
+ if (!session) return redirect(res, '/login');
+ const form = await readForm(req);
+ const title = String(form.title || '').trim().slice(0,160);
+ const summary = String(form.summary || '').trim().slice(0,500);
+ const ingredients = String(form.ingredients || '').trim().slice(0,12000);
+ const instructions = String(form.instructions || '').trim().slice(0,20000);
+ if (!title || !ingredients || !instructions) return send(res, 400, 'Title, ingredients and method are required', 'text/plain; charset=utf-8');
+ const slug = slugify(title);
+ await pool.query(`INSERT INTO recipes (slug,title,summary,ingredients,instructions,tags,servings,prep_minutes,cook_minutes,published,created_by,author_name) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, [slug,title,summary,ingredients,instructions,normaliseTags(form.tags),clampInt(form.servings,1,50,4),clampInt(form.prep_minutes,0,1440,0),clampInt(form.cook_minutes,0,1440,0),form.published === '1',session.sub,session.name]);
+ return redirect(res, `/recipe/${encodeURIComponent(slug)}`);
+ }
+
+ if (req.method === 'GET' && url.pathname === '/mine') {
+ if (!session) return redirect(res, '/login');
+ const result = await pool.query(`SELECT slug,title,summary,tags,servings,prep_minutes,cook_minutes,published,updated_at FROM recipes WHERE created_by=$1 ORDER BY updated_at DESC`, [session.sub]);
+ const cards = result.rows.map(r => `${r.published ? 'Public' : 'Private'}
${esc(r.title)}
${esc(r.summary)}
${r.prep_minutes + r.cook_minutes} minUpdated ${esc(new Date(r.updated_at).toLocaleDateString('en-NZ'))}
`).join('');
+ const body = `${cards || 'No recipes yet.
'}`;
+ return send(res, 200, page('My recipes', body, session));
+ }
+
+ const editMatch = url.pathname.match(/^\/recipe\/([^/]+)\/edit$/);
+ if (req.method === 'GET' && editMatch) {
+ if (!session) return redirect(res, '/login');
+ const slug = decodeURIComponent(editMatch[1]);
+ const result = await pool.query('SELECT * FROM recipes WHERE slug=$1 AND created_by=$2', [slug, session.sub]);
+ const recipe = result.rows[0];
+ if (!recipe) return send(res, 404, 'Recipe not found', 'text/plain; charset=utf-8');
+ return send(res, 200, page(`Edit ${recipe.title}`, ``, session));
+ }
+
+ const updateMatch = url.pathname.match(/^\/recipe\/([^/]+)\/update$/);
+ if (req.method === 'POST' && updateMatch) {
+ if (!session) return redirect(res, '/login');
+ const slug = decodeURIComponent(updateMatch[1]);
+ const form = await readForm(req);
+ const title = String(form.title || '').trim().slice(0,160);
+ const summary = String(form.summary || '').trim().slice(0,500);
+ const ingredients = String(form.ingredients || '').trim().slice(0,12000);
+ const instructions = String(form.instructions || '').trim().slice(0,20000);
+ if (!title || !ingredients || !instructions) return send(res, 400, 'Title, ingredients and method are required', 'text/plain; charset=utf-8');
+ const result = await pool.query(`UPDATE recipes SET title=$1,summary=$2,ingredients=$3,instructions=$4,tags=$5,servings=$6,prep_minutes=$7,cook_minutes=$8,published=$9,updated_at=NOW() WHERE slug=$10 AND created_by=$11 RETURNING slug`, [title,summary,ingredients,instructions,normaliseTags(form.tags),clampInt(form.servings,1,50,4),clampInt(form.prep_minutes,0,1440,0),clampInt(form.cook_minutes,0,1440,0),form.published === '1',slug,session.sub]);
+ if (!result.rowCount) return send(res, 404, 'Recipe not found', 'text/plain; charset=utf-8');
+ return redirect(res, `/recipe/${encodeURIComponent(slug)}`);
+ }
+
+ const deleteMatch = url.pathname.match(/^\/recipe\/([^/]+)\/delete$/);
+ if (req.method === 'POST' && deleteMatch) {
+ if (!session) return redirect(res, '/login');
+ const slug = decodeURIComponent(deleteMatch[1]);
+ await pool.query('DELETE FROM recipes WHERE slug=$1 AND created_by=$2', [slug, session.sub]);
+ return redirect(res, '/mine');
+ }
+
+ const recipeMatch = url.pathname.match(/^\/recipe\/([^/]+)$/);
+ if (req.method === 'GET' && recipeMatch) {
+ const slug = decodeURIComponent(recipeMatch[1]);
+ const result = await pool.query('SELECT * FROM recipes WHERE slug=$1', [slug]);
+ const recipe = result.rows[0];
+ if (!recipe || (!recipe.published && session?.sub !== recipe.created_by)) return send(res, 404, 'Recipe not found', 'text/plain; charset=utf-8');
+ const owner = session?.sub === recipe.created_by;
+ const ingredientItems = recipe.ingredients.split(/\r?\n/).map(x => x.trim()).filter(Boolean).map(x => `${esc(x)}`).join('');
+ const tagLinks = recipe.tags.map(t => `#${esc(t)}`).join('');
+ const ownerbar = owner ? `` : '';
+ const body = `Shared by ${esc(recipe.author_name)}
${esc(recipe.title)}
${esc(recipe.summary)}
${ownerbar}
🍽 ${recipe.servings} servings🔪 ${recipe.prep_minutes} min prep🔥 ${recipe.cook_minutes} min cook${recipe.published ? 'Public recipe' : 'Private'}
${tagLinks ? `${tagLinks}
` : ''}Method
${nl2br(recipe.instructions)}
`;
+ return send(res, 200, page(recipe.title, body, session));
+ }
+
+ 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-cooking listening on :${PORT}`));