From 892479dceb4f7ea887e290800c65afb826e475dd Mon Sep 17 00:00:00 2001 From: Shane Maynard Date: Wed, 29 Jul 2026 22:04:40 -0400 Subject: [PATCH] feat: harden authenticated deployment --- .dockerignore | 1 + .env.example | 6 +- .gitignore | 3 +- Dockerfile | 4 + README.md | 19 +- docker-compose.yml | 17 +- public/admin.html | 25 +- public/index.html | 2716 +++++++++++++++++++++++--------- public/js/admin.js | 45 + public/js/alog-ui.js | 97 +- public/js/landing.js | 24 + public/js/main.js | 965 +++++++----- public/js/prefill-ui.js | 113 +- public/landing.html | 47 +- public/sw.js | 114 +- server/app.js | 478 +++++- server/db.js | 57 +- server/index.js | 4 +- test/auth.test.js | 157 +- test/container-startup.test.js | 83 + 20 files changed, 3677 insertions(+), 1298 deletions(-) create mode 100644 public/js/admin.js create mode 100644 public/js/landing.js create mode 100644 test/container-startup.test.js diff --git a/.dockerignore b/.dockerignore index e5fac2c..bfdbe9e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,3 +5,4 @@ node_modules npm-debug.log* Dockerfile README.md +appdata/ diff --git a/.env.example b/.env.example index 8617ff4..df911c4 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,8 @@ # Generate each with: openssl rand -base64 48 POSTGRES_PASSWORD= -# One-use secret to create snowspeeder@gmail.com; remove after bootstrap. +# One-use secret to create snowspeeder@gmail.com; leave blank/remove after bootstrap. BOOTSTRAP_SETUP_TOKEN= +# Optional host path mounted read-only at /home/node/.pi/agent (never commit it). +PI_AGENT_CONFIG_DIR=./appdata/pi-agent +# Optional explicit reverse-proxy IP/CIDR; leave blank if no trusted proxy is present. +TRUST_PROXY= diff --git a/.gitignore b/.gitignore index e05ae32..98066dc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -node_modules/ +node_modules +appdata// data/ *.log .DS_Store diff --git a/Dockerfile b/Dockerfile index 658aeeb..7dad0ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,11 @@ RUN npm ci --omit=dev COPY public ./public COPY server ./server COPY shared ./shared +COPY db/migrations ./db/migrations +# The optional Pi agent configuration is mounted read-only here at runtime. +ENV HOME=/home/node +RUN mkdir -p /home/node/.pi/agent && chown -R node:node /home/node/.pi USER node EXPOSE 8090 CMD ["node", "server/index.js"] diff --git a/README.md b/README.md index 9a9d295..8e3b310 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,21 @@ The public landing page is at `/`; plans require an account at `/app`. Productio ### First administrator -Generate `BOOTSTRAP_SETUP_TOKEN` with `openssl rand -base64 48`, keep it only in the deployment environment, then call `POST /api/auth/bootstrap` with that token, `snowspeeder@gmail.com`, and a 12+ character password. The endpoint can create that account only once. Remove the setup token after success; no administrator password is stored in source control. +Generate `BOOTSTRAP_SETUP_TOKEN` with `openssl rand -base64 48`, keep it only in the deployment environment, then call `POST /api/auth/bootstrap` with that token, `snowspeeder@gmail.com`, and a 12+ character password. The endpoint can create that account only once. Remove the setup token after success; it is optional thereafter and no administrator password is stored in source control. + +### Pi agent configuration in Docker + +The `app` service mounts `PI_AGENT_CONFIG_DIR` (default `./appdata/pi-agent`) read-only at `/home/node/.pi/agent`, the non-root Node user's Pi configuration directory. This lets `/api/prefill` use the same configured model at runtime without baking credentials into the image. The directory is ignored by Git and Docker build context; do not commit its contents. + +Before bringing up the stack, sync only the local Pi agent configuration you intend to make available to the container: + +```bash +mkdir -p appdata/pi-agent +rsync -a --delete ~/.pi/agent/ appdata/pi-agent/ +docker compose --env-file .env up --build +``` + +Set `PI_AGENT_CONFIG_DIR` to another protected host directory instead if preferred. Restrict access to that directory because it can contain provider credentials. The mount is read-only, so Pi cannot alter the host configuration. If deployed behind a reverse proxy, set `TRUST_PROXY` only to that proxy's specific IP/CIDR (or keep it blank when the app is directly exposed). ## Mobile and PWA use @@ -61,8 +75,7 @@ If those tables change in the paper worksheet, port the change here too. ## Known gaps (v1) -- No automated test suite yet (the ledger math and `.alog` parser were verified manually - against the worksheet's worked examples and all 14 logs in `ref/roasts/`, respectively). +- Offline drafts are intentionally scoped to the authenticated browser account and are cleared on logout; account-backed plans remain the authoritative copy. - Roastetta (roastetta.com) integration is intentionally out of scope — it needs a headed, Cloudflare-clearing browser and the operator's own credentials. Use the `.alog` file picker, or point `ALOG_DIR` at wherever the `roastetta` skill already downloaded files. diff --git a/docker-compose.yml b/docker-compose.yml index 74c54de..d710bdb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,8 +6,16 @@ services: DATABASE_URL: postgresql://roast:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/roast APP_ORIGIN: https://roast.srmr.xyz COOKIE_SECURE: "true" - BOOTSTRAP_SETUP_TOKEN: ${BOOTSTRAP_SETUP_TOKEN:?set a one-time random token} - depends_on: [db] + # Optional after the one-time administrator bootstrap has completed. + BOOTSTRAP_SETUP_TOKEN: ${BOOTSTRAP_SETUP_TOKEN:-} + # Leave unset unless a known reverse-proxy address/CIDR is configured. + TRUST_PROXY: ${TRUST_PROXY:-} + volumes: + # Mount only non-secret Pi agent model/auth configuration; keep it read-only. + - ${PI_AGENT_CONFIG_DIR:-./appdata/pi-agent}:/home/node/.pi/agent:ro + depends_on: + db: + condition: service_healthy expose: ["8090"] restart: unless-stopped db: @@ -17,6 +25,11 @@ services: POSTGRES_USER: roast POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} volumes: [postgres-data:/var/lib/postgresql/data] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U roast -d roast"] + interval: 5s + timeout: 3s + retries: 12 restart: unless-stopped volumes: postgres-data: diff --git a/public/admin.html b/public/admin.html index 7462a88..6e5b29c 100644 --- a/public/admin.html +++ b/public/admin.html @@ -1 +1,24 @@ -Roast Planner Admin
← Plans

Administration

Users

    Recent roast plans

      \ No newline at end of file + + + + + + Roast Planner Admin + + + +
      +
      + ← Plans +

      Administration

      + +

      Users

      +
        +

        Recent roast plans

        +
          +
          +
          + + + diff --git a/public/index.html b/public/index.html index 7e6edca..34be1c2 100644 --- a/public/index.html +++ b/public/index.html @@ -1,766 +1,2000 @@ - + - - - -Roast Planner - - - - - - - - - - - -
          -
          - -
          -

          Roast Planner

          -

          New plan

          -
          -
          - - - -
          - - - - - - - - - - Not saved yet -
          -
          - - - - - - - - -
          - -
          -
          - -
          -

          The Coffee

          -
          -
          - - - - + + + + Roast Planner + + + + + + + + + + +
          +
          + +
          +

          Roast Planner

          +

          New plan

          +
          -
          Cultivar
          -
          - -
          Origin
          -
          - Group -
          - - - - + + +
          + + + + + + + + + + + Not saved yet +
          +
          + + + + + + + + + +
          +
          +
          +

          The Coffee

          +
          +
          + + + + +
          + +
          Cultivar
          +
          + +
          + Origin +
          +
          + Group +
          + + + + +
          +
          +
          +
          + + + + +
          +
          +
          + +
          +
          +

          Blend

          +
          + + +
          +
          +
          +
          +
          +
          +
          +
          + 0% of 100% +
          + + +
          How the components combine
          +
          +
          + + + +
          +
          +
          + + +
          +
          +
          + +
          +

          Roast Target

          +
          +
          + Process +
          + + + + + +
          +
          +
          + +
          + +
          Roast level
          +
          +
          + + + + + +
          +
          +
          + + +
          +
          +
          + +
          +

          Bean Condition

          +
          +
          + + + + +
          +
          +
          + Sweet spot +
          + + + +
          +
          + +
          +
          +
          + +
          +

          Machine Plan

          +
          +
          Temperatures & rate-of-rise
          +
          +
          +
          Charge
          + 0:00 +
          +
          +
          +
          +
          + °C +
          +
          +
          +
          Turning point
          + +
          +
          +
          +
          +
          + °C +
          +
          +
          +
          Yellow
          + +
          +
          +
          +
          +
          + °C +
          +
          +
          +
          First crack
          + +
          +
          +
          +
          +
          + °C +
          +
          +
          +
          Drop
          + +
          +
          +
          +
          +
          + °C +
          +
          +
          + +
          Actuator schedule (planned)
          +
          + +
          +
          + +
          +

          Roast Log — Plan vs. Actual

          +
          +
          +
          +
          Charge
          + 0:00 + + + +
          +
          +
          Turning point
          + + + + +
          +
          +
          Yellow
          + + + + +
          +
          +
          First crack
          + + + + +
          +
          +
          Drop
          + + + + +
          +
          +
          +
          + +
          +

          After the Roast

          +
          +
          + + + + +
          +
          + + + + +
          + +
          + + +
          +
          +
          +
          + +
          -
          -
          - - - - -
          -
          -
          -
          -
          -

          Blend

          -
          - - -
          -
          -
          -
          -
          -
          - 0% of 100% -
          - - -
          How the components combine
          -
          -
          - - - -
          -
          -
          - - -
          -
          -
          - -
          -

          Roast Target

          -
          -
          - Process -
          - - - - - -
          -
          -
          - -
          - -
          Roast level
          -
          -
          - - - - - -
          -
          -
          - - -
          -
          -
          - -
          -

          Bean Condition

          -
          -
          - - - - -
          -
          -
          - Sweet spot -
          - - - +
          +

          Sanity Checks

          +
          +
          + Drying share + + target 43–51% +
          +
          + Maillard share + + target 33–39% +
          +
          + Development ratio + + target 12–20% +
          +
          + Development ceiling + + under 4:30 +
          +
          -
          - + +
          +
          +

          The Curve

          + plan + reference +
          +
          + + + + + + + + first-crack band, 176–187 °C + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 60 + 80 + 100 + 120 + 140 + 160 + 180 + 200 + + + 0:00 + 1:00 + 2:00 + 3:00 + 4:00 + 5:00 + 6:00 + 7:00 + 8:00 + 9:00 + 10:00 + 11:00 + 12:00 + 13:00 + 14:00 + 15:00 + + + + +
          +
          +
          -
          -
          + -
          -

          Machine Plan

          -
          -
          Temperatures & rate-of-rise
          -
          -
          -
          Charge
          - 0:00 -
          -
          °C
          -
          -
          -
          Turning point
          - -
          -
          °C
          -
          -
          -
          Yellow
          - -
          -
          °C
          -
          -
          -
          First crack
          - -
          -
          °C
          -
          -
          -
          Drop
          - -
          -
          °C
          -
          -
          - -
          Actuator schedule (planned)
          -
          - -
          -
          - -
          -

          Roast Log — Plan vs. Actual

          -
          -
          -
          -
          Charge
          - 0:00 - - - -
          -
          -
          Turning point
          - - - - -
          -
          -
          Yellow
          - - - - -
          -
          -
          First crack
          - - - - -
          -
          -
          Drop
          - - - - -
          -
          -
          -
          - -
          -

          After the Roast

          -
          -
          - - - - -
          -
          - - - - -
          - -
          - - -
          -
          -
          - -
          - - - -
          - - - - - - - - - - - + + diff --git a/public/js/admin.js b/public/js/admin.js new file mode 100644 index 0000000..f227012 --- /dev/null +++ b/public/js/admin.js @@ -0,0 +1,45 @@ +const csrf = () => + document.cookie + .split("; ") + .find((value) => value.startsWith("rp_csrf=")) + ?.split("=")[1] || ""; +const users = document.querySelector("#users"); +const plans = document.querySelector("#plans"); + +async function load() { + const usersResponse = await fetch("/api/admin/users"); + if (!usersResponse.ok) return; + const body = await usersResponse.json(); + document.querySelector("#signup").checked = body.signupEnabled; + users.replaceChildren( + ...body.users.map((user) => + Object.assign(document.createElement("li"), { + textContent: `${user.email} (${user.role}) — ${user.plan_count} plans`, + }), + ), + ); + const plansResponse = await fetch("/api/admin/plans"); + if (!plansResponse.ok) return; + const plansBody = await plansResponse.json(); + plans.replaceChildren( + ...plansBody.plans.map((plan) => + Object.assign(document.createElement("li"), { + textContent: `${plan.email}: ${plan.plan?.fields?.["0.1"] || "Untitled plan"}`, + }), + ), + ); +} + +document.querySelector("#save").addEventListener("click", async () => { + await fetch("/api/admin/signup-enabled", { + method: "PUT", + headers: { + "content-type": "application/json", + "x-csrf-token": csrf(), + }, + body: JSON.stringify({ enabled: document.querySelector("#signup").checked }), + }); + await load(); +}); + +load(); diff --git a/public/js/alog-ui.js b/public/js/alog-ui.js index 6818d9e..e5b905b 100644 --- a/public/js/alog-ui.js +++ b/public/js/alog-ui.js @@ -1,9 +1,92 @@ -const csrf = () => document.cookie.split("; ").find((v) => v.startsWith("rp_csrf="))?.split("=")[1] || ""; -const setText = (el, text, error = false) => { const node = document.createElement("p"); node.textContent = text; if (error) node.style.color="#a8371a"; el.replaceChildren(node); }; +const csrf = () => + document.cookie + .split("; ") + .find((v) => v.startsWith("rp_csrf=")) + ?.split("=")[1] || ""; +const setText = (el, text, error = false) => { + const node = document.createElement("p"); + node.textContent = text; + if (error) node.style.color = "#a8371a"; + el.replaceChildren(node); +}; export function initAlogPanel({ state, recompute }) { - const fileInput=document.getElementById("alog-file"), libraryBtn=document.getElementById("alog-library-refresh"), libraryList=document.getElementById("alog-library-list"), resultEl=document.getElementById("alog-result"); - fileInput.addEventListener("change", async(e)=>{const file=e.target.files?.[0];if(!file)return;setText(resultEl,"Parsing…");try{const res=await fetch("/api/alog",{method:"POST",headers:{"content-type":"application/json","x-csrf-token":csrf()},body:JSON.stringify({filename:file.name,content:await file.text()})});applyResult(await res.json());}catch(err){setText(resultEl,err.message,true);}}); - libraryBtn.addEventListener("click",async()=>{libraryList.classList.remove("hidden");libraryList.replaceChildren(Object.assign(document.createElement("li"),{textContent:"Loading…"}));try{const body=await (await fetch("/api/alog/library")).json();if(!body.ok||!body.files.length){libraryList.replaceChildren(Object.assign(document.createElement("li"),{textContent:"No .alog files found. Set ALOG_DIR or drop files in ~/Roastetta."}));return;}libraryList.replaceChildren(...body.files.map(f=>{const li=document.createElement("li");li.textContent=`${f.filename} (${Math.round(f.sizeBytes/1024)} KB)`;li.onclick=async()=>{setText(resultEl,"Loading…");applyResult(await (await fetch(`/api/alog/library/${encodeURIComponent(f.filename)}`)).json());};return li;}));}catch(err){const li=document.createElement("li");li.textContent=err.message;li.style.color="#a8371a";libraryList.replaceChildren(li);}}); - function applyResult(body){if(!body.ok){setText(resultEl,body.error??body.code,true);return;}state.plan.reference=body;recompute();setText(resultEl,`${body.roast.title} — ${body.roast.roastDate||"no date"}; first crack ${fmt(body.derived?.firstCrackS)}; development ${fmt(body.derived?.developmentS)}; drop ${fmt(body.derived?.dropS)}; DTR ${body.derived?.dtrPct??"—"}%.`);} + const fileInput = document.getElementById("alog-file"), + libraryBtn = document.getElementById("alog-library-refresh"), + libraryList = document.getElementById("alog-library-list"), + resultEl = document.getElementById("alog-result"); + fileInput.addEventListener("change", async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + setText(resultEl, "Parsing…"); + try { + const res = await fetch("/api/alog", { + method: "POST", + headers: { "content-type": "application/json", "x-csrf-token": csrf() }, + body: JSON.stringify({ + filename: file.name, + content: await file.text(), + }), + }); + applyResult(await res.json()); + } catch (err) { + setText(resultEl, err.message, true); + } + }); + libraryBtn.addEventListener("click", async () => { + libraryList.classList.remove("hidden"); + libraryList.replaceChildren( + Object.assign(document.createElement("li"), { textContent: "Loading…" }), + ); + try { + const body = await (await fetch("/api/alog/library")).json(); + if (!body.ok || !body.files.length) { + libraryList.replaceChildren( + Object.assign(document.createElement("li"), { + textContent: + "No .alog files found. Set ALOG_DIR or drop files in ~/Roastetta.", + }), + ); + return; + } + libraryList.replaceChildren( + ...body.files.map((f) => { + const li = document.createElement("li"); + li.textContent = `${f.filename} (${Math.round(f.sizeBytes / 1024)} KB)`; + li.onclick = async () => { + setText(resultEl, "Loading…"); + applyResult( + await ( + await fetch( + `/api/alog/library/${encodeURIComponent(f.filename)}`, + ) + ).json(), + ); + }; + return li; + }), + ); + } catch (err) { + const li = document.createElement("li"); + li.textContent = err.message; + li.style.color = "#a8371a"; + libraryList.replaceChildren(li); + } + }); + function applyResult(body) { + if (!body.ok) { + setText(resultEl, body.error ?? body.code, true); + return; + } + state.plan.reference = body; + recompute(); + setText( + resultEl, + `${body.roast.title} — ${body.roast.roastDate || "no date"}; first crack ${fmt(body.derived?.firstCrackS)}; development ${fmt(body.derived?.developmentS)}; drop ${fmt(body.derived?.dropS)}; DTR ${body.derived?.dtrPct ?? "—"}%.`, + ); + } +} +function fmt(seconds) { + if (seconds == null) return "—"; + const s = Math.round(seconds); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; } -function fmt(seconds){if(seconds==null)return "—";const s=Math.round(seconds);return `${Math.floor(s/60)}:${String(s%60).padStart(2,"0")}`;} diff --git a/public/js/landing.js b/public/js/landing.js new file mode 100644 index 0000000..98a3ac1 --- /dev/null +++ b/public/js/landing.js @@ -0,0 +1,24 @@ +const form = document.querySelector("#auth-form"); +const message = document.querySelector("#message"); + +async function submit(url) { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(Object.fromEntries(new FormData(form))), + }); + const body = await response.json(); + if (!response.ok) { + message.textContent = body.error || body.code; + return; + } + location.assign("/app"); +} + +form.addEventListener("submit", (event) => { + event.preventDefault(); + submit("/api/auth/login"); +}); +document.querySelector("#signup").addEventListener("click", () => + submit("/api/auth/signup"), +); diff --git a/public/js/main.js b/public/js/main.js index 76391e1..727934a 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -1,6 +1,10 @@ import { FIELD_IDS, blankPlan } from "/shared/fields.js"; import { computeLedger } from "/shared/ledger.js"; -import { formatDuration, formatSigned, parseRangeMidpoint } from "/shared/time.js"; +import { + formatDuration, + formatSigned, + parseRangeMidpoint, +} from "/shared/time.js"; import { CULTIVARS, findCultivar } from "/shared/reference-data.js"; import { buildPlanCurve, pointsToPathD, tToX, tempToY } from "/shared/curve.js"; import { initPrefillPanel } from "./prefill-ui.js"; @@ -8,13 +12,22 @@ import { initAlogPanel } from "./alog-ui.js"; import { initPrint } from "./print.js"; const FIELD_ID_SET = new Set(FIELD_IDS); -const STORAGE_KEY = "roastPlannerPlan.v1"; +const STORAGE_PREFIX = "roastPlannerPlan.v2"; +let storageKey = null; let remotePlanId = null; -const csrfToken = () => document.cookie.split("; ").find((v) => v.startsWith("rp_csrf="))?.split("=")[1] || ""; -export const protectedFetch = (url, options = {}) => fetch(url, { ...options, headers: { ...options.headers, "x-csrf-token": csrfToken() } }); +const csrfToken = () => + document.cookie + .split("; ") + .find((v) => v.startsWith("rp_csrf=")) + ?.split("=")[1] || ""; +export const protectedFetch = (url, options = {}) => + fetch(url, { + ...options, + headers: { ...options.headers, "x-csrf-token": csrfToken() }, + }); const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the Machine Plan section -export const state = { plan: loadFromStorage() ?? blankPlan() }; +export const state = { plan: blankPlan() }; const form = document.getElementById("plan-form"); @@ -22,38 +35,39 @@ const form = document.getElementById("plan-form"); // so its radios don't fight the screen form's identically-named radios for exclusivity. // Every sync pass therefore has to reach both containers explicitly. function allNamedInputs() { - return document.querySelectorAll( - "#plan-form input, #plan-form select, #plan-form textarea, " + - "#print-sheet input, #print-sheet select, #print-sheet textarea", - ); + return document.querySelectorAll( + "#plan-form input, #plan-form select, #plan-form textarea, " + + "#print-sheet input, #print-sheet select, #print-sheet textarea", + ); } // ---- nested path get/set for names like "temps.charge.tempC" or "blendComponents.0.cultivar" function getPath(obj, path) { - return path.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj); + return path.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj); } function setPath(obj, path, value) { - const parts = path.split("."); - let node = obj; - for (let i = 0; i < parts.length - 1; i++) { - if (node[parts[i]] === undefined || node[parts[i]] === null) node[parts[i]] = {}; - node = node[parts[i]]; - } - node[parts[parts.length - 1]] = value; + const parts = path.split("."); + let node = obj; + for (let i = 0; i < parts.length - 1; i++) { + if (node[parts[i]] === undefined || node[parts[i]] === null) + node[parts[i]] = {}; + node = node[parts[i]]; + } + node[parts[parts.length - 1]] = value; } function valueForName(name) { - if (FIELD_ID_SET.has(name)) return state.plan.fields[name] ?? ""; - const v = getPath(state.plan, name); - return v ?? ""; + if (FIELD_ID_SET.has(name)) return state.plan.fields[name] ?? ""; + const v = getPath(state.plan, name); + return v ?? ""; } function setValueForName(name, value) { - if (FIELD_ID_SET.has(name)) { - state.plan.fields[name] = value; - } else { - setPath(state.plan, name, value); - } + if (FIELD_ID_SET.has(name)) { + state.plan.fields[name] = value; + } else { + setPath(state.plan, name, value); + } } // ---- dynamic rows: blend components + actuator schedule @@ -61,93 +75,105 @@ function setValueForName(name, value) { // component, and the hidden print-only worksheet table (#blend-rows / #actuator-rows), // which is kept in sync by renderFormFromPlan() and only needs to look right on paper. function renderBlendPrintRows() { - const tbody = document.getElementById("blend-rows"); - tbody.replaceChildren(); - state.plan.blendComponents.forEach((_, i) => { - const tr = document.createElement("tr"); - tr.append(document.createRange().createContextualFragment(` + const tbody = document.getElementById("blend-rows"); + tbody.replaceChildren(); + state.plan.blendComponents.forEach((_, i) => { + const tr = document.createElement("tr"); + tr.append( + document.createRange().createContextualFragment(` - `)); - tbody.appendChild(tr); - }); + `), + ); + tbody.appendChild(tr); + }); } function renderBlendCards() { - const wrap = document.getElementById("blend-cards"); - wrap.replaceChildren(); - state.plan.blendComponents.forEach((_, i) => { - const card = document.createElement("div"); - card.className = "blend-card"; - card.append(document.createRange().createContextualFragment(` + const wrap = document.getElementById("blend-cards"); + wrap.replaceChildren(); + state.plan.blendComponents.forEach((_, i) => { + const card = document.createElement("div"); + card.className = "blend-card"; + card.append( + document.createRange().createContextualFragment(` - `)); - wrap.appendChild(card); - }); - for (const btn of wrap.querySelectorAll("[data-remove-blend]")) { - btn.addEventListener("click", () => { - const i = Number(btn.dataset.removeBlend); - if (state.plan.blendComponents.length <= 1) return; - state.plan.blendComponents.splice(i, 1); - renderBlend(); - renderFormFromPlan(); - recompute(); - }); - } + `), + ); + wrap.appendChild(card); + }); + for (const btn of wrap.querySelectorAll("[data-remove-blend]")) { + btn.addEventListener("click", () => { + const i = Number(btn.dataset.removeBlend); + if (state.plan.blendComponents.length <= 1) return; + state.plan.blendComponents.splice(i, 1); + renderBlend(); + renderFormFromPlan(); + recompute(); + }); + } } function updateBlendTotal() { - const total = state.plan.blendComponents.reduce((sum, c) => sum + (Number.parseFloat(c.sharePct) || 0), 0); - const fill = document.getElementById("blend-total-fill"); - const label = document.getElementById("blend-total-label"); - if (!fill || !label) return; - fill.style.width = `${Math.min(100, total)}%`; - fill.classList.toggle("over", total > 100); - fill.classList.toggle("under", total > 0 && total < 100); - label.textContent = `${Math.round(total * 10) / 10}% of 100%`; + const total = state.plan.blendComponents.reduce( + (sum, c) => sum + (Number.parseFloat(c.sharePct) || 0), + 0, + ); + const fill = document.getElementById("blend-total-fill"); + const label = document.getElementById("blend-total-label"); + if (!fill || !label) return; + fill.style.width = `${Math.min(100, total)}%`; + fill.classList.toggle("over", total > 100); + fill.classList.toggle("under", total > 0 && total < 100); + label.textContent = `${Math.round(total * 10) / 10}% of 100%`; } function updateBlendVisibility(mode) { - document.getElementById("blend-body").classList.toggle("collapsed", mode !== "blend"); + document + .getElementById("blend-body") + .classList.toggle("collapsed", mode !== "blend"); } function renderBlend() { - renderBlendPrintRows(); - renderBlendCards(); - updateBlendTotal(); + renderBlendPrintRows(); + renderBlendCards(); + updateBlendTotal(); } function renderActuatorPrintRows() { - const tbody = document.getElementById("actuator-rows"); - tbody.replaceChildren(); - state.plan.actuators.forEach((_, i) => { - const tr = document.createElement("tr"); - tr.append(document.createRange().createContextualFragment(` + const tbody = document.getElementById("actuator-rows"); + tbody.replaceChildren(); + state.plan.actuators.forEach((_, i) => { + const tr = document.createElement("tr"); + tr.append( + document.createRange().createContextualFragment(` - `)); - tbody.appendChild(tr); - }); + `), + ); + tbody.appendChild(tr); + }); } function renderActuatorTimeline() { - const wrap = document.getElementById("actuator-timeline"); - wrap.replaceChildren(); - state.plan.actuators.forEach((_, i) => { - const step = document.createElement("div"); - step.className = "actuator-step"; - step.append(document.createRange().createContextualFragment(` + const wrap = document.getElementById("actuator-timeline"); + wrap.replaceChildren(); + state.plan.actuators.forEach((_, i) => { + const step = document.createElement("div"); + step.className = "actuator-step"; + step.append( + document.createRange().createContextualFragment(`
          @@ -157,421 +183,528 @@ function renderActuatorTimeline() {
          - `)); - wrap.appendChild(step); - }); - for (const btn of wrap.querySelectorAll("[data-remove-actuator]")) { - btn.addEventListener("click", () => { - const i = Number(btn.dataset.removeActuator); - if (state.plan.actuators.length <= 1) return; - state.plan.actuators.splice(i, 1); - renderActuators(); - renderFormFromPlan(); - recompute(); - }); - } + `), + ); + wrap.appendChild(step); + }); + for (const btn of wrap.querySelectorAll("[data-remove-actuator]")) { + btn.addEventListener("click", () => { + const i = Number(btn.dataset.removeActuator); + if (state.plan.actuators.length <= 1) return; + state.plan.actuators.splice(i, 1); + renderActuators(); + renderFormFromPlan(); + recompute(); + }); + } } function renderActuators() { - renderActuatorPrintRows(); - renderActuatorTimeline(); + renderActuatorPrintRows(); + renderActuatorTimeline(); } // ---- populate every control in the form from state.plan export function renderFormFromPlan() { - for (const el of allNamedInputs()) { - const name = el.name; - if (!name) continue; - const value = valueForName(name); - if (el.type === "radio") { - el.checked = el.value === value; - } else if (el.type === "checkbox") { - el.checked = Boolean(value); - } else { - el.value = value ?? ""; - } - } - updateBlendVisibility(state.plan.fields["2.1"]); - document.getElementById("header-coffee-name").textContent = state.plan.fields["0.1"] || "New plan"; + for (const el of allNamedInputs()) { + const name = el.name; + if (!name) continue; + const value = valueForName(name); + if (el.type === "radio") { + el.checked = el.value === value; + } else if (el.type === "checkbox") { + el.checked = Boolean(value); + } else { + el.value = value ?? ""; + } + } + updateBlendVisibility(state.plan.fields["2.1"]); + document.getElementById("header-coffee-name").textContent = + state.plan.fields["0.1"] || "New plan"; } function fmtOut(id, text) { - for (const el of document.querySelectorAll(`[data-out="${id}"]`)) el.textContent = text ?? "—"; + for (const el of document.querySelectorAll(`[data-out="${id}"]`)) + el.textContent = text ?? "—"; } function renderLedger() { - const ledger = computeLedger(state.plan); - const d = (s) => (s === null || s === undefined ? "—" : formatDuration(s)); - const ds = (s) => (s === null || s === undefined ? "—" : formatSigned(s)); + const ledger = computeLedger(state.plan); + const d = (s) => (s === null || s === undefined ? "—" : formatDuration(s)); + const ds = (s) => (s === null || s === undefined ? "—" : formatSigned(s)); - fmtOut("l1", d(ledger.lines.l1)); - fmtOut("l2", ds(ledger.lines.l2)); - fmtOut("l3", ds(ledger.lines.l3)); - fmtOut("A", d(ledger.A)); - fmtOut("yellow", d(ledger.yellow)); - fmtOut("maillard", d(ledger.maillard)); - fmtOut("l7", d(ledger.lines.l7)); - fmtOut("l8", ds(ledger.lines.l8)); - fmtOut("l9", ds(ledger.lines.l9)); - fmtOut("C", d(ledger.C)); - fmtOut("D", d(ledger.D)); + fmtOut("l1", d(ledger.lines.l1)); + fmtOut("l2", ds(ledger.lines.l2)); + fmtOut("l3", ds(ledger.lines.l3)); + fmtOut("A", d(ledger.A)); + fmtOut("yellow", d(ledger.yellow)); + fmtOut("maillard", d(ledger.maillard)); + fmtOut("l7", d(ledger.lines.l7)); + fmtOut("l8", ds(ledger.lines.l8)); + fmtOut("l9", ds(ledger.lines.l9)); + fmtOut("C", d(ledger.C)); + fmtOut("D", d(ledger.D)); - const pct = (v) => (v === null || v === undefined ? "—" : `${v.toFixed(1)}%`); - fmtOut("check-drying", pct(ledger.checks.drying.pct)); - fmtOut("check-maillard", pct(ledger.checks.maillard.pct)); - fmtOut("check-dtr", pct(ledger.checks.dtr.pct)); - fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS)); + const pct = (v) => (v === null || v === undefined ? "—" : `${v.toFixed(1)}%`); + fmtOut("check-drying", pct(ledger.checks.drying.pct)); + fmtOut("check-maillard", pct(ledger.checks.maillard.pct)); + fmtOut("check-dtr", pct(ledger.checks.dtr.pct)); + fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS)); - for (const [key, check] of Object.entries(ledger.checks)) { - for (const cell of document.querySelectorAll(`[data-pass="${key}"]`)) { - cell.classList.remove("pass", "fail", "unknown"); - cell.classList.add(check.pass === null ? "unknown" : check.pass ? "pass" : "fail"); - } - } + for (const [key, check] of Object.entries(ledger.checks)) { + for (const cell of document.querySelectorAll(`[data-pass="${key}"]`)) { + cell.classList.remove("pass", "fail", "unknown"); + cell.classList.add( + check.pass === null ? "unknown" : check.pass ? "pass" : "fail", + ); + } + } - // Box 8 / box 11 derived time columns - fmtOut("t-charge", "0:00"); - fmtOut("t-yellow", d(ledger.yellow)); - fmtOut("t-fc", d(ledger.A)); - fmtOut("t-drop", d(ledger.D)); - fmtOut("pa-charge", "0:00"); - fmtOut("pa-tp", state.plan.temps.tp.time || "—"); - fmtOut("pa-yellow", d(ledger.yellow)); - fmtOut("pa-fc", d(ledger.A)); - fmtOut("pa-drop", d(ledger.D)); + // Box 8 / box 11 derived time columns + fmtOut("t-charge", "0:00"); + fmtOut("t-yellow", d(ledger.yellow)); + fmtOut("t-fc", d(ledger.A)); + fmtOut("t-drop", d(ledger.D)); + fmtOut("pa-charge", "0:00"); + fmtOut("pa-tp", state.plan.temps.tp.time || "—"); + fmtOut("pa-yellow", d(ledger.yellow)); + fmtOut("pa-fc", d(ledger.A)); + fmtOut("pa-drop", d(ledger.D)); - document.getElementById("back-coffee-name").textContent = state.plan.fields["0.1"] || ""; - document.getElementById("header-coffee-name").textContent = state.plan.fields["0.1"] || "New plan"; + document.getElementById("back-coffee-name").textContent = + state.plan.fields["0.1"] || ""; + document.getElementById("header-coffee-name").textContent = + state.plan.fields["0.1"] || "New plan"; - return ledger; + return ledger; } function domainPct(v) { - const [lo, hi] = BAND_DOMAIN; - return Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100)); + const [lo, hi] = BAND_DOMAIN; + return Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100)); } function renderBandRanges() { - for (const row of document.querySelectorAll(".band-row")) { - const lo = Number(row.dataset.bandLo); - const hi = Number(row.dataset.bandHi); - const range = row.querySelector(".band-range"); - if (!range) continue; - const loPct = domainPct(lo); - range.style.left = `${loPct}%`; - range.style.width = `${domainPct(hi) - loPct}%`; - } + for (const row of document.querySelectorAll(".band-row")) { + const lo = Number(row.dataset.bandLo); + const hi = Number(row.dataset.bandHi); + const range = row.querySelector(".band-range"); + if (!range) continue; + const loPct = domainPct(lo); + range.style.left = `${loPct}%`; + range.style.width = `${domainPct(hi) - loPct}%`; + } } function renderBandMarkers() { - for (const row of document.querySelectorAll(".band-row")) { - const input = row.querySelector('input[name$=".tempC"]'); - const marker = row.querySelector(".band-marker"); - if (!input || !marker) continue; - const v = Number.parseFloat(input.value); - if (!Number.isFinite(v)) { - marker.style.display = "none"; - continue; - } - const lo = Number(row.dataset.bandLo); - const hi = Number(row.dataset.bandHi); - marker.style.display = "block"; - marker.style.left = `${domainPct(v)}%`; - marker.classList.toggle("out-of-band", v < lo || v > hi); - } + for (const row of document.querySelectorAll(".band-row")) { + const input = row.querySelector('input[name$=".tempC"]'); + const marker = row.querySelector(".band-marker"); + if (!input || !marker) continue; + const v = Number.parseFloat(input.value); + if (!Number.isFinite(v)) { + marker.style.display = "none"; + continue; + } + const lo = Number(row.dataset.bandLo); + const hi = Number(row.dataset.bandHi); + marker.style.display = "block"; + marker.style.left = `${domainPct(v)}%`; + marker.classList.toggle("out-of-band", v < lo || v > hi); + } } function paintCurveInto(planGroup, refGroup, planPoints, ref) { - planGroup.replaceChildren(); - if (planPoints.length > 0) { - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - path.setAttribute("d", pointsToPathD(planPoints)); - path.setAttribute("fill", "none"); - path.setAttribute("stroke", "currentColor"); - path.setAttribute("stroke-width", "1.6"); - path.style.color = "var(--ink, #1a1512)"; - planGroup.appendChild(path); - for (const p of planPoints) { - const c = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - c.setAttribute("cx", tToX(p.timeS).toFixed(1)); - c.setAttribute("cy", tempToY(p.tempC).toFixed(1)); - c.setAttribute("r", "3.2"); - c.setAttribute("fill", "currentColor"); - c.style.color = "var(--ink, #1a1512)"; - planGroup.appendChild(c); - } - } + planGroup.replaceChildren(); + if (planPoints.length > 0) { + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", pointsToPathD(planPoints)); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", "currentColor"); + path.setAttribute("stroke-width", "1.6"); + path.style.color = "var(--ink, #1a1512)"; + planGroup.appendChild(path); + for (const p of planPoints) { + const c = document.createElementNS( + "http://www.w3.org/2000/svg", + "circle", + ); + c.setAttribute("cx", tToX(p.timeS).toFixed(1)); + c.setAttribute("cy", tempToY(p.tempC).toFixed(1)); + c.setAttribute("r", "3.2"); + c.setAttribute("fill", "currentColor"); + c.style.color = "var(--ink, #1a1512)"; + planGroup.appendChild(c); + } + } - refGroup.replaceChildren(); - if (ref?.curve?.length) { - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - const d = ref.curve - .map((p, i) => `${i === 0 ? "M" : "L"}${tToX(p.t).toFixed(1)},${tempToY(p.bt).toFixed(1)}`) - .join(" "); - path.setAttribute("d", d); - path.setAttribute("fill", "none"); - path.setAttribute("stroke", "currentColor"); - path.setAttribute("stroke-width", "1.3"); - path.setAttribute("stroke-dasharray", "4 3"); - path.setAttribute("opacity", ".65"); - path.style.color = "var(--ink-2, #5a5048)"; - refGroup.appendChild(path); - for (const m of ref.milestones ?? []) { - if (m.tempC === null) continue; - const c = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - c.setAttribute("cx", tToX(m.timeS).toFixed(1)); - c.setAttribute("cy", tempToY(m.tempC).toFixed(1)); - c.setAttribute("r", "2.6"); - c.setAttribute("fill", "none"); - c.setAttribute("stroke", "currentColor"); - c.setAttribute("stroke-width", "1.2"); - c.style.color = "var(--ink-2, #5a5048)"; - refGroup.appendChild(c); - } - } + refGroup.replaceChildren(); + if (ref?.curve?.length) { + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + const d = ref.curve + .map( + (p, i) => + `${i === 0 ? "M" : "L"}${tToX(p.t).toFixed(1)},${tempToY(p.bt).toFixed(1)}`, + ) + .join(" "); + path.setAttribute("d", d); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", "currentColor"); + path.setAttribute("stroke-width", "1.3"); + path.setAttribute("stroke-dasharray", "4 3"); + path.setAttribute("opacity", ".65"); + path.style.color = "var(--ink-2, #5a5048)"; + refGroup.appendChild(path); + for (const m of ref.milestones ?? []) { + if (m.tempC === null) continue; + const c = document.createElementNS( + "http://www.w3.org/2000/svg", + "circle", + ); + c.setAttribute("cx", tToX(m.timeS).toFixed(1)); + c.setAttribute("cy", tempToY(m.tempC).toFixed(1)); + c.setAttribute("r", "2.6"); + c.setAttribute("fill", "none"); + c.setAttribute("stroke", "currentColor"); + c.setAttribute("stroke-width", "1.2"); + c.style.color = "var(--ink-2, #5a5048)"; + refGroup.appendChild(c); + } + } } function renderCurve(ledger) { - const planPoints = buildPlanCurve(state.plan, ledger); - const ref = state.plan.reference; + const planPoints = buildPlanCurve(state.plan, ledger); + const ref = state.plan.reference; - paintCurveInto( - document.getElementById("plan-curve"), - document.getElementById("ref-curve"), - planPoints, - ref, - ); - paintCurveInto( - document.getElementById("plan-curve-live"), - document.getElementById("ref-curve-live"), - planPoints, - ref, - ); + paintCurveInto( + document.getElementById("plan-curve"), + document.getElementById("ref-curve"), + planPoints, + ref, + ); + paintCurveInto( + document.getElementById("plan-curve-live"), + document.getElementById("ref-curve-live"), + planPoints, + ref, + ); } export function recompute() { - const ledger = renderLedger(); - renderCurve(ledger); - renderBandMarkers(); - autosave(); + const ledger = renderLedger(); + renderCurve(ledger); + renderBandMarkers(); + autosave(); } let autosaveAgeTimer = null; function autosave() { - const chip = document.getElementById("autosave-status"); - const text = chip.querySelector(".autosave-text"); - chip.classList.remove("saved"); - chip.classList.add("saving"); - text.textContent = "Saving…"; + const chip = document.getElementById("autosave-status"); + const text = chip.querySelector(".autosave-text"); + chip.classList.remove("saved"); + chip.classList.add("saving"); + text.textContent = "Saving…"; - clearTimeout(autosave._t); - autosave._t = setTimeout(() => { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(state.plan)); - // Local storage preserves edits while offline; the account copy is authoritative when online. - if (navigator.onLine && csrfToken()) { - const method = remotePlanId ? "PUT" : "POST"; - const url = remotePlanId ? `/api/plans/${remotePlanId}` : "/api/plans"; - protectedFetch(url, { method, headers: { "content-type": "application/json" }, body: JSON.stringify({ plan: state.plan }) }) - .then((r) => r.ok ? r.json() : null).then((body) => { if (body?.plan?.id) remotePlanId = body.plan.id; }) - .catch(() => { /* local copy remains available */ }); - } - const savedAt = Date.now(); - chip.classList.remove("saving"); - chip.classList.add("saved"); - const tick = () => { - const secs = Math.round((Date.now() - savedAt) / 1000); - text.textContent = secs < 8 ? "Saved just now" : secs < 60 ? `Saved ${secs}s ago` : `Saved ${Math.round(secs / 60)}m ago`; - }; - tick(); - clearInterval(autosaveAgeTimer); - autosaveAgeTimer = setInterval(tick, 5000); - } catch { - chip.classList.remove("saving", "saved"); - text.textContent = "Save unavailable"; - } - }, 400); + clearTimeout(autosave._t); + autosave._t = setTimeout(() => { + try { + if (storageKey) localStorage.setItem(storageKey, JSON.stringify(state.plan)); + // Local storage is namespaced per authenticated account; the account copy is authoritative when online. + if (navigator.onLine && csrfToken()) { + const method = remotePlanId ? "PUT" : "POST"; + const url = remotePlanId ? `/api/plans/${remotePlanId}` : "/api/plans"; + protectedFetch(url, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ plan: state.plan }), + }) + .then((r) => (r.ok ? r.json() : null)) + .then((body) => { + if (body?.plan?.id) remotePlanId = body.plan.id; + }) + .catch(() => { + /* local copy remains available */ + }); + } + const savedAt = Date.now(); + chip.classList.remove("saving"); + chip.classList.add("saved"); + const tick = () => { + const secs = Math.round((Date.now() - savedAt) / 1000); + text.textContent = + secs < 8 + ? "Saved just now" + : secs < 60 + ? `Saved ${secs}s ago` + : `Saved ${Math.round(secs / 60)}m ago`; + }; + tick(); + clearInterval(autosaveAgeTimer); + autosaveAgeTimer = setInterval(tick, 5000); + } catch { + chip.classList.remove("saving", "saved"); + text.textContent = "Save unavailable"; + } + }, 400); } -function loadFromStorage() { - try { - const raw = localStorage.getItem(STORAGE_KEY); - return raw ? JSON.parse(raw) : null; - } catch { - return null; - } +function loadFromStorage(userId) { + try { + storageKey = `${STORAGE_PREFIX}:${userId}`; + // A shared browser must never retain a previous account's local-only draft. + for (let i = localStorage.length - 1; i >= 0; i--) { + const key = localStorage.key(i); + if ((key?.startsWith(`${STORAGE_PREFIX}:`) && key !== storageKey) || key === "roastPlannerPlan.v1") + localStorage.removeItem(key); + } + const raw = localStorage.getItem(storageKey); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +function clearDraft() { + if (storageKey) localStorage.removeItem(storageKey); + storageKey = null; + remotePlanId = null; } function cultivarAutofill(name) { - const row = findCultivar(name); - if (!row) return; - setValueForName("1.2", row.group); - setValueForName("1.4", formatDuration(parseRangeMidpoint(row.fcAnchor))); - setValueForName("1.5", row.profile.join("|")); - setValueForName("1.7", formatSigned(row.devModS)); - renderFormFromPlan(); + const row = findCultivar(name); + if (!row) return; + setValueForName("1.2", row.group); + setValueForName("1.4", formatDuration(parseRangeMidpoint(row.fcAnchor))); + setValueForName("1.5", row.profile.join("|")); + setValueForName("1.7", formatSigned(row.devModS)); + renderFormFromPlan(); } function wireCultivarDatalist() { - const list = document.getElementById("cultivar-list"); - list.replaceChildren(...CULTIVARS.map((c) => { - const option = document.createElement("option"); - option.value = c.name; - return option; - })); + const list = document.getElementById("cultivar-list"); + list.replaceChildren( + ...CULTIVARS.map((c) => { + const option = document.createElement("option"); + option.value = c.name; + return option; + }), + ); } function wireForm() { - form.addEventListener("input", (e) => { - const el = e.target; - if (!el.name) return; - if (el.type === "radio" && !el.checked) return; - setValueForName(el.name, el.value); - if (el.name === "1.1") cultivarAutofill(el.value); - if (el.name === "2.1") updateBlendVisibility(el.value); - if (el.name.startsWith("blendComponents.")) updateBlendTotal(); - recompute(); - }); + form.addEventListener("input", (e) => { + const el = e.target; + if (!el.name) return; + if (el.type === "radio" && !el.checked) return; + setValueForName(el.name, el.value); + if (el.name === "1.1") cultivarAutofill(el.value); + if (el.name === "2.1") updateBlendVisibility(el.value); + if (el.name.startsWith("blendComponents.")) updateBlendTotal(); + recompute(); + }); } function wireDrawers() { - const overlay = document.getElementById("drawer-overlay"); - const prefill = document.getElementById("panel-prefill"); - const alog = document.getElementById("panel-alog"); + const overlay = document.getElementById("drawer-overlay"); + const prefill = document.getElementById("panel-prefill"); + const alog = document.getElementById("panel-alog"); - function open(panel) { - for (const p of [prefill, alog]) p.classList.add("hidden"); - panel.classList.remove("hidden"); - panel.setAttribute("aria-hidden", "false"); - overlay.classList.remove("hidden"); - } - function closeAll() { - for (const p of [prefill, alog]) { - p.classList.add("hidden"); - p.setAttribute("aria-hidden", "true"); - } - overlay.classList.add("hidden"); - } + function open(panel) { + for (const p of [prefill, alog]) p.classList.add("hidden"); + panel.classList.remove("hidden"); + panel.setAttribute("aria-hidden", "false"); + overlay.classList.remove("hidden"); + } + function closeAll() { + for (const p of [prefill, alog]) { + p.classList.add("hidden"); + p.setAttribute("aria-hidden", "true"); + } + overlay.classList.add("hidden"); + } - document.getElementById("btn-toggle-prefill").addEventListener("click", () => { - prefill.classList.contains("hidden") ? open(prefill) : closeAll(); - }); - document.getElementById("btn-toggle-alog").addEventListener("click", () => { - alog.classList.contains("hidden") ? open(alog) : closeAll(); - }); - overlay.addEventListener("click", closeAll); - for (const btn of document.querySelectorAll("[data-close-drawer]")) { - btn.addEventListener("click", closeAll); - } + document + .getElementById("btn-toggle-prefill") + .addEventListener("click", () => { + prefill.classList.contains("hidden") ? open(prefill) : closeAll(); + }); + document.getElementById("btn-toggle-alog").addEventListener("click", () => { + alog.classList.contains("hidden") ? open(alog) : closeAll(); + }); + overlay.addEventListener("click", closeAll); + for (const btn of document.querySelectorAll("[data-close-drawer]")) { + btn.addEventListener("click", closeAll); + } } function wireToolbar() { - document.getElementById("btn-toggle-fids").addEventListener("click", (e) => { - const on = document.body.classList.toggle("show-fids"); - e.currentTarget.setAttribute("aria-pressed", String(on)); - }); + document.getElementById("btn-toggle-fids").addEventListener("click", (e) => { + const on = document.body.classList.toggle("show-fids"); + e.currentTarget.setAttribute("aria-pressed", String(on)); + }); - document.getElementById("btn-save").addEventListener("click", () => { - const blob = new Blob([JSON.stringify(state.plan, null, 2)], { type: "application/json" }); - const a = document.createElement("a"); - a.href = URL.createObjectURL(blob); - a.download = `${(state.plan.fields["0.1"] || "roast-plan").replace(/[^\w-]+/g, "_")}.json`; - a.click(); - URL.revokeObjectURL(a.href); - }); - document.getElementById("btn-load").addEventListener("click", () => document.getElementById("file-load").click()); - document.getElementById("file-load").addEventListener("change", async (e) => { - const file = e.target.files?.[0]; - if (!file) return; - try { - const loaded = JSON.parse(await file.text()); - state.plan = { ...blankPlan(), ...loaded }; - renderBlend(); - renderActuators(); - renderFormFromPlan(); - recompute(); - } catch (err) { - alert(`Could not load plan: ${err.message}`); - } - }); + document.getElementById("btn-save").addEventListener("click", () => { + const blob = new Blob([JSON.stringify(state.plan, null, 2)], { + type: "application/json", + }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `${(state.plan.fields["0.1"] || "roast-plan").replace(/[^\w-]+/g, "_")}.json`; + a.click(); + URL.revokeObjectURL(a.href); + }); + document + .getElementById("btn-load") + .addEventListener("click", () => + document.getElementById("file-load").click(), + ); + document.getElementById("file-load").addEventListener("change", async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + const loaded = JSON.parse(await file.text()); + state.plan = { ...blankPlan(), ...loaded }; + renderBlend(); + renderActuators(); + renderFormFromPlan(); + recompute(); + } catch (err) { + alert(`Could not load plan: ${err.message}`); + } + }); - document.getElementById("btn-add-blend").addEventListener("click", () => { - state.plan.blendComponents.push({ cultivar: "", group: "", process: "", sharePct: "", fcAnchor: "" }); - renderBlend(); - renderFormFromPlan(); - recompute(); - }); - document.getElementById("btn-add-actuator").addEventListener("click", () => { - state.plan.actuators.push({ time: "", heatPct: "", fanPct: "", expectedBt: "", why: "" }); - renderActuators(); - renderFormFromPlan(); - recompute(); - }); + document.getElementById("btn-add-blend").addEventListener("click", () => { + state.plan.blendComponents.push({ + cultivar: "", + group: "", + process: "", + sharePct: "", + fcAnchor: "", + }); + renderBlend(); + renderFormFromPlan(); + recompute(); + }); + document.getElementById("btn-logout").addEventListener("click", async () => { + try { + await protectedFetch("/api/auth/logout", { method: "POST" }); + } finally { + clearDraft(); + location.assign("/"); + } + }); + document.getElementById("btn-add-actuator").addEventListener("click", () => { + state.plan.actuators.push({ + time: "", + heatPct: "", + fanPct: "", + expectedBt: "", + why: "", + }); + renderActuators(); + renderFormFromPlan(); + recompute(); + }); } function wirePwa() { - const status = document.getElementById("connection-status"); - const renderConnection = () => { - const offline = !navigator.onLine; - status.classList.toggle("hidden", !offline); - status.textContent = offline ? "Offline — changes continue saving on this device." : ""; - }; + const status = document.getElementById("connection-status"); + const renderConnection = () => { + const offline = !navigator.onLine; + status.classList.toggle("hidden", !offline); + status.textContent = offline + ? "Offline — changes continue saving on this device." + : ""; + }; - window.addEventListener("online", renderConnection); - window.addEventListener("offline", renderConnection); - renderConnection(); + window.addEventListener("online", renderConnection); + window.addEventListener("offline", renderConnection); + renderConnection(); - if ("serviceWorker" in navigator) { - window.addEventListener("load", () => { - navigator.serviceWorker.register("/sw.js").catch((error) => { - console.warn("Service worker registration failed:", error); - }); - }); - } + if ("serviceWorker" in navigator) { + window.addEventListener("load", () => { + navigator.serviceWorker.register("/sw.js").catch((error) => { + console.warn("Service worker registration failed:", error); + }); + }); + } } function wireSectionNav() { - const links = [...document.querySelectorAll(".section-nav a")]; - const sections = links - .map((a) => document.querySelector(a.getAttribute("href"))) - .filter(Boolean); - if (sections.length === 0) return; + const links = [...document.querySelectorAll(".section-nav a")]; + const sections = links + .map((a) => document.querySelector(a.getAttribute("href"))) + .filter(Boolean); + if (sections.length === 0) return; - const observer = new IntersectionObserver( - (entries) => { - for (const entry of entries) { - if (!entry.isIntersecting) continue; - const id = `#${entry.target.id}`; - for (const a of links) a.classList.toggle("active", a.getAttribute("href") === id); - } - }, - { rootMargin: "-20% 0px -70% 0px" }, - ); - for (const s of sections) observer.observe(s); + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const id = `#${entry.target.id}`; + for (const a of links) + a.classList.toggle("active", a.getAttribute("href") === id); + } + }, + { rootMargin: "-20% 0px -70% 0px" }, + ); + for (const s of sections) observer.observe(s); } async function init() { - try { - const response = await fetch("/api/plans"); - if (response.ok) { const body = await response.json(); const latest = body.plans?.[0]; if (latest) { state.plan = latest.plan; remotePlanId = latest.id; } } - } catch { /* offline starts from the local cache */ } - renderBlend(); - renderActuators(); - wireCultivarDatalist(); - renderBandRanges(); - renderFormFromPlan(); - wireForm(); - wireDrawers(); - wireToolbar(); - wirePwa(); - wireSectionNav(); - fetch("/api/auth/me").then((r) => r.ok ? r.json() : null).then((body) => { - if (body?.user?.role === "admin") { const button = document.getElementById("btn-admin"); button.classList.remove("hidden"); button.onclick = () => { location.href = "/admin"; }; } - }).catch(() => {}); - initPrefillPanel({ state, renderBlendRows: renderBlend, renderActuatorRows: renderActuators, renderFormFromPlan, recompute }); - initAlogPanel({ state, recompute }); - initPrint({ beforePrint: renderFormFromPlan }); - recompute(); + try { + const meResponse = await fetch("/api/auth/me"); + if (!meResponse.ok) { + location.replace("/"); + return; + } + const { user } = await meResponse.json(); + state.plan = loadFromStorage(user.id) ?? blankPlan(); + const response = await fetch("/api/plans"); + if (response.ok) { + const body = await response.json(); + const latest = body.plans?.[0]; + if (latest) { + state.plan = latest.plan; + remotePlanId = latest.id; + } + } + } catch { + // An authenticated user can still use their own namespaced local draft offline. + } + renderBlend(); + renderActuators(); + wireCultivarDatalist(); + renderBandRanges(); + renderFormFromPlan(); + wireForm(); + wireDrawers(); + wireToolbar(); + wirePwa(); + wireSectionNav(); + fetch("/api/auth/me") + .then((r) => (r.ok ? r.json() : null)) + .then((body) => { + if (body?.user?.role === "admin") { + const button = document.getElementById("btn-admin"); + button.classList.remove("hidden"); + button.onclick = () => { + location.href = "/admin"; + }; + } + }) + .catch(() => {}); + initPrefillPanel({ + state, + renderBlendRows: renderBlend, + renderActuatorRows: renderActuators, + renderFormFromPlan, + recompute, + }); + initAlogPanel({ state, recompute }); + initPrint({ beforePrint: renderFormFromPlan }); + recompute(); } init(); diff --git a/public/js/prefill-ui.js b/public/js/prefill-ui.js index 7986f48..b8b2192 100644 --- a/public/js/prefill-ui.js +++ b/public/js/prefill-ui.js @@ -1,21 +1,100 @@ // URL prefill UI; output is always built with DOM nodes so remote text never becomes markup. -const csrf = () => document.cookie.split("; ").find((v) => v.startsWith("rp_csrf="))?.split("=")[1] || ""; -function message(target, text, error = false) { const p = document.createElement("p"); p.textContent = text; if (error) p.style.color = "#a8371a"; target.replaceChildren(p); } +const csrf = () => + document.cookie + .split("; ") + .find((v) => v.startsWith("rp_csrf=")) + ?.split("=")[1] || ""; +function message(target, text, error = false) { + const p = document.createElement("p"); + p.textContent = text; + if (error) p.style.color = "#a8371a"; + target.replaceChildren(p); +} export function initPrefillPanel({ state, renderFormFromPlan, recompute }) { - const urlInput = document.getElementById("prefill-url"), overwriteBox = document.getElementById("prefill-overwrite"), goBtn = document.getElementById("prefill-go"), undoBtn = document.getElementById("prefill-undo"), resultEl = document.getElementById("prefill-result"); let snapshot = null; - goBtn.addEventListener("click", async () => { - const url = urlInput.value.trim(); if (!url) return; goBtn.disabled = true; message(resultEl, "Fetching…"); - try { - const res = await fetch("/api/prefill", { method: "POST", headers: { "content-type": "application/json", "x-csrf-token": csrf() }, body: JSON.stringify({ url }) }); const body = await res.json(); - if (!body.ok) { message(resultEl, body.error ?? body.code, true); return; } - snapshot = structuredClone(state.plan); let applied = 0; - for (const [id, value] of Object.entries(body.fields ?? {})) { if (!overwriteBox.checked && (state.plan.fields[id] ?? "") !== "") continue; state.plan.fields[id] = value; applied++; } - renderFormFromPlan(); markPrefilled(Object.keys(body.fields ?? {}), body.provenance ?? {}); recompute(); undoBtn.disabled = false; - const p = document.createElement("p"), link = document.createElement("a"); link.href = body.source.finalUrl; link.target = "_blank"; link.rel = "noopener"; link.textContent = body.source.finalUrl; p.append(`Applied ${applied} field${applied === 1 ? "" : "s"} from `, link, "."); - const children = [p]; if (body.warnings?.length) { const ul=document.createElement("ul"); ul.className="warnings"; for(const warning of body.warnings){const li=document.createElement("li");li.textContent=warning;ul.append(li);} children.push(ul); } resultEl.replaceChildren(...children); - } catch (err) { message(resultEl, err.message, true); } finally { goBtn.disabled = false; } - }); - undoBtn.addEventListener("click", () => { if (!snapshot) return; state.plan = snapshot; snapshot = null; undoBtn.disabled = true; renderFormFromPlan(); for (const el of document.querySelectorAll(".prefilled")) { el.classList.remove("prefilled"); el.removeAttribute("title"); } recompute(); message(resultEl, "Prefill undone."); }); - function markPrefilled(ids, provenance) { for (const id of ids) { const el=document.querySelector(`[name="${CSS.escape(id)}"]`); if(el){el.classList.add("prefilled");if(provenance[id])el.title=`from: ${provenance[id]}`;} } } + const urlInput = document.getElementById("prefill-url"), + overwriteBox = document.getElementById("prefill-overwrite"), + goBtn = document.getElementById("prefill-go"), + undoBtn = document.getElementById("prefill-undo"), + resultEl = document.getElementById("prefill-result"); + let snapshot = null; + goBtn.addEventListener("click", async () => { + const url = urlInput.value.trim(); + if (!url) return; + goBtn.disabled = true; + message(resultEl, "Fetching…"); + try { + const res = await fetch("/api/prefill", { + method: "POST", + headers: { "content-type": "application/json", "x-csrf-token": csrf() }, + body: JSON.stringify({ url }), + }); + const body = await res.json(); + if (!body.ok) { + message(resultEl, body.error ?? body.code, true); + return; + } + snapshot = structuredClone(state.plan); + let applied = 0; + for (const [id, value] of Object.entries(body.fields ?? {})) { + if (!overwriteBox.checked && (state.plan.fields[id] ?? "") !== "") + continue; + state.plan.fields[id] = value; + applied++; + } + renderFormFromPlan(); + markPrefilled(Object.keys(body.fields ?? {}), body.provenance ?? {}); + recompute(); + undoBtn.disabled = false; + const p = document.createElement("p"), + link = document.createElement("a"); + link.href = body.source.finalUrl; + link.target = "_blank"; + link.rel = "noopener"; + link.textContent = body.source.finalUrl; + p.append( + `Applied ${applied} field${applied === 1 ? "" : "s"} from `, + link, + ".", + ); + const children = [p]; + if (body.warnings?.length) { + const ul = document.createElement("ul"); + ul.className = "warnings"; + for (const warning of body.warnings) { + const li = document.createElement("li"); + li.textContent = warning; + ul.append(li); + } + children.push(ul); + } + resultEl.replaceChildren(...children); + } catch (err) { + message(resultEl, err.message, true); + } finally { + goBtn.disabled = false; + } + }); + undoBtn.addEventListener("click", () => { + if (!snapshot) return; + state.plan = snapshot; + snapshot = null; + undoBtn.disabled = true; + renderFormFromPlan(); + for (const el of document.querySelectorAll(".prefilled")) { + el.classList.remove("prefilled"); + el.removeAttribute("title"); + } + recompute(); + message(resultEl, "Prefill undone."); + }); + function markPrefilled(ids, provenance) { + for (const id of ids) { + const el = document.querySelector(`[name="${CSS.escape(id)}"]`); + if (el) { + el.classList.add("prefilled"); + if (provenance[id]) el.title = `from: ${provenance[id]}`; + } + } + } } diff --git a/public/landing.html b/public/landing.html index d5f1686..5e557f9 100644 --- a/public/landing.html +++ b/public/landing.html @@ -1 +1,46 @@ -Roast Planner

          Roast Planner

          Build, save, and revisit your coffee roast plans.

          Accounts require a password of at least 12 characters.

          \ No newline at end of file + + + + + + Roast Planner + + + + +
          +
          +

          Roast Planner

          +

          Build, save, and revisit your coffee roast plans.

          +
          +
          + +
          +

          + Accounts require a password of at least 12 characters. +

          +
          +
          + + + diff --git a/public/sw.js b/public/sw.js index e5b265c..87ad060 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,59 +1,81 @@ -const CACHE_NAME = "roast-planner-static-v1"; +const CACHE_NAME = "roast-planner-static-v2"; const APP_SHELL = [ - "/", - "/landing.html", - "/app.css", - "/worksheet.css", - "/manifest.webmanifest", - "/icon.svg", - "/js/main.js", - "/js/prefill-ui.js", - "/js/alog-ui.js", - "/js/print.js", - "/shared/fields.js", - "/shared/ledger.js", - "/shared/time.js", - "/shared/reference-data.js", - "/shared/curve.js", + "/", + "/landing.html", + "/app.css", + "/worksheet.css", + "/manifest.webmanifest", + "/icon.svg", + "/js/main.js", + "/js/landing.js", + "/js/admin.js", + "/js/prefill-ui.js", + "/js/alog-ui.js", + "/js/print.js", + "/shared/fields.js", + "/shared/ledger.js", + "/shared/time.js", + "/shared/reference-data.js", + "/shared/curve.js", ]; self.addEventListener("install", (event) => { - event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))); - self.skipWaiting(); + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)), + ); + self.skipWaiting(); }); self.addEventListener("activate", (event) => { - event.waitUntil( - caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))), - ); - self.clients.claim(); + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((key) => key !== CACHE_NAME) + .map((key) => caches.delete(key)), + ), + ), + ); + self.clients.claim(); }); self.addEventListener("fetch", (event) => { - const { request } = event; - let url; - try { - url = new URL(request.url); - } catch { - return; - } - if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return; + const { request } = event; + let url; + try { + url = new URL(request.url); + } catch { + return; + } + if ( + request.method !== "GET" || + url.origin !== self.location.origin || + url.pathname.startsWith("/api/") + ) + return; - if (request.mode === "navigate") { - // Never serve authenticated application HTML from cache after logout. - event.respondWith(fetch(request).catch(() => caches.match("/landing.html"))); - return; - } + if (request.mode === "navigate") { + // Never serve authenticated application HTML from cache after logout. + event.respondWith( + fetch(request).catch(() => caches.match("/landing.html")), + ); + return; + } - event.respondWith( - caches.match(request).then((cached) => { - const update = fetch(request) - .then((response) => { - if (response.ok) caches.open(CACHE_NAME).then((cache) => cache.put(request, response.clone())); - return response; - }) - .catch(() => cached); - return cached ?? update; - }), - ); + event.respondWith( + caches.match(request).then((cached) => { + const update = fetch(request) + .then((response) => { + if (response.ok) + caches + .open(CACHE_NAME) + .then((cache) => cache.put(request, response.clone())); + return response; + }) + .catch(() => cached); + return cached ?? update; + }), + ); }); diff --git a/server/app.js b/server/app.js index 7891076..377ec2e 100644 --- a/server/app.js +++ b/server/app.js @@ -10,48 +10,444 @@ import { listAlogLibrary, readAlogFromLibrary } from "./alog-library.js"; const hash = (value) => crypto.createHash("sha256").update(value).digest("hex"); const token = () => crypto.randomBytes(32).toString("base64url"); const ADMIN_EMAIL = "snowspeeder@gmail.com"; -const emailOf = (value) => String(value || "").trim().toLowerCase(); -const PASSWORD_OK = (value) => typeof value === "string" && value.length >= 12 && value.length <= 256; +const emailOf = (value) => + String(value || "") + .trim() + .toLowerCase(); +const PASSWORD_OK = (value) => + typeof value === "string" && value.length >= 12 && value.length <= 256; /** Creates the HTTP app separately from listening, so tests can use an isolated database. */ export function createApp({ db, root, env = process.env } = {}) { - const app = express(); - const production = env.NODE_ENV === "production"; - const cookieSecure = env.COOKIE_SECURE ? env.COOKIE_SECURE === "true" : production; - const origin = env.APP_ORIGIN || (production ? "https://roast.srmr.xyz" : ""); - const buckets = new Map(); - const rateLimit = (name, max, windowMs) => (req, res, next) => { - const key = `${name}:${req.ip}`; const now = Date.now(); const b = buckets.get(key) || { count: 0, reset: now + windowMs }; - if (now > b.reset) Object.assign(b, { count: 0, reset: now + windowMs }); - b.count++; buckets.set(key, b); res.set("RateLimit-Limit", String(max)); - if (b.count > max) return res.status(429).json({ ok: false, code: "rate_limited" }); next(); - }; - app.disable("x-powered-by"); - app.set("trust proxy", 1); - app.use((req, res, next) => { res.set({ "X-Content-Type-Options":"nosniff", "X-Frame-Options":"DENY", "Referrer-Policy":"strict-origin-when-cross-origin", "Permissions-Policy":"camera=(), microphone=(), geolocation=()", "Cross-Origin-Opener-Policy":"same-origin", "Content-Security-Policy":"default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; connect-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:" }); next(); }); - app.use(express.json({ limit: "1mb" })); - const cookie = (req, name) => Object.fromEntries((req.headers.cookie || "").split(";").map(x => x.trim().split("=")).filter(x => x[0]))[name]; - const setSessionCookie = (res, value, maxAge, csrfToken = "") => { res.cookie("rp_session", value, { httpOnly:true, secure:cookieSecure, sameSite:"lax", path:"/", maxAge }); res.cookie("rp_csrf", csrfToken, { httpOnly:false, secure:cookieSecure, sameSite:"lax", path:"/", maxAge }); }; - async function session(req) { const raw = cookie(req, "rp_session"); if (!raw) return null; const r = await db.query("SELECT s.csrf_hash,u.id,u.email,u.role FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=$1 AND s.expires_at>now()", [hash(raw)]); return r.rows[0] || null; } - async function requireAuth(req,res,next) { try { req.user = await session(req); if (!req.user) return res.status(401).json({ok:false,code:"unauthorized"}); next(); } catch (e) { next(e); } } - const csrf = (req,res,next) => { if (origin && req.get("origin") && req.get("origin") !== origin) return res.status(403).json({ok:false,code:"bad_origin"}); const value=req.get("x-csrf-token"); if (!value || value !== cookie(req, "rp_csrf") || !req.user || !crypto.timingSafeEqual(Buffer.from(hash(value)), Buffer.from(req.user.csrf_hash))) return res.status(403).json({ok:false,code:"csrf_failed"}); next(); }; - const admin = (req,res,next) => req.user.role === "admin" ? next() : res.status(403).json({ok:false,code:"forbidden"}); - const createSession = async (user) => { const raw=token(), csrfToken=token(); await db.query("INSERT INTO sessions(token_hash,user_id,csrf_hash,expires_at) VALUES($1,$2,$3,now()+interval '14 days')",[hash(raw),user.id,hash(csrfToken)]); return {raw,csrfToken}; }; - app.post("/api/auth/signup", rateLimit("signup", 8, 60_000), async (req,res,next) => { try { const email=emailOf(req.body.email), password=req.body.password; if (!/^\S+@\S+\.\S+$/.test(email) || !PASSWORD_OK(password)) return res.status(400).json({ok:false,code:"invalid_credentials",error:"Use a valid email and a password of at least 12 characters."}); const setting=await db.query("SELECT value FROM app_settings WHERE key='signup_enabled'"); if (setting.rows[0]?.value !== "true") return res.status(403).json({ok:false,code:"signup_disabled"}); const password_hash=await bcrypt.hash(password, 12); const user=(await db.query("INSERT INTO users(email,password_hash) VALUES($1,$2) RETURNING id,email,role",[email,password_hash])).rows[0]; const s=await createSession(user); setSessionCookie(res,s.raw,14*864e5,s.csrfToken); res.status(201).json({ok:true,user:{email:user.email,role:user.role},csrfToken:s.csrfToken}); } catch(e) { if(e.code === "23505") return res.status(409).json({ok:false,code:"email_exists"}); next(e); } }); - app.post("/api/auth/bootstrap", rateLimit("bootstrap", 4, 60_000), async(req,res,next)=>{ try { const bootstrap=String(req.body.setupToken||""); if (!env.BOOTSTRAP_SETUP_TOKEN || bootstrap.length !== env.BOOTSTRAP_SETUP_TOKEN.length || !crypto.timingSafeEqual(Buffer.from(bootstrap),Buffer.from(env.BOOTSTRAP_SETUP_TOKEN))) return res.status(403).json({ok:false,code:"invalid_setup_token"}); if (emailOf(req.body.email)!==ADMIN_EMAIL || !PASSWORD_OK(req.body.password)) return res.status(400).json({ok:false,code:"invalid_credentials"}); const exists=await db.query("SELECT 1 FROM users WHERE email=$1",[ADMIN_EMAIL]); if(exists.rowCount) return res.status(409).json({ok:false,code:"bootstrap_used"}); const user=(await db.query("INSERT INTO users(email,password_hash,role) VALUES($1,$2,'admin') RETURNING id,email,role",[ADMIN_EMAIL,await bcrypt.hash(req.body.password,12)])).rows[0]; const s=await createSession(user); setSessionCookie(res,s.raw,14*864e5,s.csrfToken); res.status(201).json({ok:true,user:{email:user.email,role:user.role},csrfToken:s.csrfToken}); } catch(e){next(e);} }); - app.post("/api/auth/login", rateLimit("login", 10, 60_000), async(req,res,next)=>{ try { const user=(await db.query("SELECT id,email,role,password_hash FROM users WHERE email=$1",[emailOf(req.body.email)])).rows[0]; if(!user || !(await bcrypt.compare(String(req.body.password||""),user.password_hash))) return res.status(401).json({ok:false,code:"invalid_credentials"}); const s=await createSession(user); setSessionCookie(res,s.raw,14*864e5,s.csrfToken); res.json({ok:true,user:{email:user.email,role:user.role},csrfToken:s.csrfToken}); }catch(e){next(e);} }); - app.get("/api/auth/me", requireAuth, (req,res)=>res.json({ok:true,user:{id:req.user.id,email:req.user.email,role:req.user.role}})); - app.post("/api/auth/logout", requireAuth, csrf, async(req,res,next)=>{try{await db.query("DELETE FROM sessions WHERE token_hash=$1",[hash(cookie(req,"rp_session"))]); setSessionCookie(res,"",0,"");res.json({ok:true});}catch(e){next(e)}}); - app.get("/api/plans", requireAuth, async(req,res,next)=>{try{res.json({ok:true,plans:(await db.query("SELECT id,plan,created_at,updated_at FROM roast_plans WHERE user_id=$1 ORDER BY updated_at DESC",[req.user.id])).rows});}catch(e){next(e)}}); - app.post("/api/plans", requireAuth, csrf, async(req,res,next)=>{try{if(!req.body.plan || typeof req.body.plan!=="object") return res.status(400).json({ok:false,code:"bad_plan"}); const p=(await db.query("INSERT INTO roast_plans(user_id,plan) VALUES($1,$2) RETURNING id,plan,created_at,updated_at",[req.user.id,req.body.plan])).rows[0];res.status(201).json({ok:true,plan:p});}catch(e){next(e)}}); - app.put("/api/plans/:id", requireAuth, csrf, async(req,res,next)=>{try{const r=await db.query("UPDATE roast_plans SET plan=$1,updated_at=now() WHERE id=$2 AND user_id=$3 RETURNING id,plan,updated_at",[req.body.plan,req.params.id,req.user.id]); if(!r.rowCount)return res.status(404).json({ok:false,code:"not_found"});res.json({ok:true,plan:r.rows[0]});}catch(e){next(e)}}); - app.get("/api/admin/users", requireAuth, admin, async(req,res,next)=>{try{res.json({ok:true,signupEnabled:(await db.query("SELECT value FROM app_settings WHERE key='signup_enabled'")).rows[0]?.value==="true",users:(await db.query("SELECT u.id,u.email,u.role,u.created_at,count(p.id)::int AS plan_count FROM users u LEFT JOIN roast_plans p ON p.user_id=u.id GROUP BY u.id ORDER BY u.created_at")).rows});}catch(e){next(e)}}); - app.get("/api/admin/plans", requireAuth, admin, async(req,res,next)=>{try{res.json({ok:true,plans:(await db.query("SELECT p.id,p.plan,p.updated_at,u.email FROM roast_plans p JOIN users u ON u.id=p.user_id ORDER BY p.updated_at DESC")).rows});}catch(e){next(e)}}); - app.put("/api/admin/signup-enabled", requireAuth, csrf, admin, async(req,res,next)=>{try{if(typeof req.body.enabled!=="boolean")return res.status(400).json({ok:false,code:"bad_request"});await db.query("UPDATE app_settings SET value=$1 WHERE key='signup_enabled'",[String(req.body.enabled)]);res.json({ok:true});}catch(e){next(e)}}); - // Existing integrations remain authenticated but CSRF-protected for writes. - app.post("/api/prefill", requireAuth, csrf, async(req,res)=>{const url=typeof req.body?.url==="string"?req.body.url.trim():"";if(!url)return res.status(400).json({ok:false,code:"bad_url",error:"Missing url."});try{const result=await runPrefill(await fetchPageText(url));res.json({ok:true,...result});}catch(err){const code=err.code??"prefill_failed";res.status(code==="fetch_timeout"?504:code==="bad_url"?400:code==="no_model"?503:422).json({ok:false,code,error:err.message});}}); - app.post("/api/alog", requireAuth, csrf, (req,res)=>{try{if(typeof req.body?.content!=="string"||!req.body.content.trim())return res.status(400).json({ok:false,code:"bad_request"});res.json({ok:true,...parseAlog(req.body.content,req.body.filename??"upload.alog")});}catch(err){res.status(422).json({ok:false,code:"unparseable_alog",error:err.message});}}); - app.get("/api/alog/library",requireAuth,async(_q,res)=>res.json({ok:true,files:await listAlogLibrary()})); app.get("/api/alog/library/:filename",requireAuth,async(req,res)=>{try{res.json({ok:true,...await readAlogFromLibrary(req.params.filename)});}catch(e){res.status(e.code==="not_found"?404:400).json({ok:false,code:e.code,error:e.message});}}); - app.get("/",(_q,res)=>res.sendFile(path.join(root,"public","landing.html"))); app.get("/app",requireAuth,(_q,res)=>res.sendFile(path.join(root,"public","index.html"))); app.get("/admin",requireAuth,admin,(_q,res)=>res.sendFile(path.join(root,"public","admin.html"))); app.use(express.static(path.join(root,"public"))); app.use("/shared",express.static(path.join(root,"shared"))); - app.use((err,_req,res,_next)=>{console.error(err);res.status(500).json({ok:false,code:"internal_error"});}); return app; + const app = express(); + const production = env.NODE_ENV === "production"; + const cookieSecure = env.COOKIE_SECURE + ? env.COOKIE_SECURE === "true" + : production; + const origin = env.APP_ORIGIN || (production ? "https://roast.srmr.xyz" : ""); + const buckets = new Map(); + const MAX_RATE_BUCKETS = 10_000; + const rateLimit = (name, max, windowMs) => { + if (!Number.isInteger(max) || max < 1 || max > 1_000 || !Number.isInteger(windowMs) || windowMs < 1_000 || windowMs > 3_600_000) + throw new Error("Invalid rate-limit configuration"); + return (req, res, next) => { + const key = `${name}:${req.ip}`; + const now = Date.now(); + for (const [bucketKey, bucket] of buckets) { + if (bucket.reset <= now) buckets.delete(bucketKey); + } + if (buckets.size >= MAX_RATE_BUCKETS && !buckets.has(key)) + return res.status(429).json({ ok: false, code: "rate_limited" }); + const bucket = buckets.get(key) || { count: 0, reset: now + windowMs }; + bucket.count++; + buckets.set(key, bucket); + res.set("RateLimit-Limit", String(max)); + res.set("RateLimit-Reset", String(Math.ceil(bucket.reset / 1_000))); + if (bucket.count > max) + return res.status(429).json({ ok: false, code: "rate_limited" }); + next(); + }; + }; + app.disable("x-powered-by"); + // Do not accept client-supplied forwarding headers unless the deployment explicitly + // identifies its proxy. A numeric hop count is unsafe when the topology changes. + app.set("trust proxy", env.TRUST_PROXY || false); + app.use((req, res, next) => { + if (req.path.startsWith("/api/") || req.path === "/app" || req.path === "/admin") + res.set("Cache-Control", "no-store, private"); + res.set({ + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Permissions-Policy": "camera=(), microphone=(), geolocation=()", + "Cross-Origin-Opener-Policy": "same-origin", + "Content-Security-Policy": + "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; connect-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:", + }); + next(); + }); + app.use(express.json({ limit: "1mb" })); + const cookie = (req, name) => + Object.fromEntries( + (req.headers.cookie || "") + .split(";") + .map((x) => x.trim().split("=")) + .filter((x) => x[0]), + )[name]; + const setSessionCookie = (res, value, maxAge, csrfToken = "") => { + res.cookie("rp_session", value, { + httpOnly: true, + secure: cookieSecure, + sameSite: "lax", + path: "/", + maxAge, + }); + res.cookie("rp_csrf", csrfToken, { + httpOnly: false, + secure: cookieSecure, + sameSite: "lax", + path: "/", + maxAge, + }); + }; + async function session(req) { + const raw = cookie(req, "rp_session"); + if (!raw) return null; + const r = await db.query( + "SELECT s.csrf_hash,u.id,u.email,u.role FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=$1 AND s.expires_at>now()", + [hash(raw)], + ); + return r.rows[0] || null; + } + async function requireAuth(req, res, next) { + try { + req.user = await session(req); + if (!req.user) + return res.status(401).json({ ok: false, code: "unauthorized" }); + next(); + } catch (e) { + next(e); + } + } + const csrf = (req, res, next) => { + if (origin && req.get("origin") && req.get("origin") !== origin) + return res.status(403).json({ ok: false, code: "bad_origin" }); + const value = req.get("x-csrf-token"); + if ( + !value || + value !== cookie(req, "rp_csrf") || + !req.user || + !crypto.timingSafeEqual( + Buffer.from(hash(value)), + Buffer.from(req.user.csrf_hash), + ) + ) + return res.status(403).json({ ok: false, code: "csrf_failed" }); + next(); + }; + const admin = (req, res, next) => + req.user.role === "admin" + ? next() + : res.status(403).json({ ok: false, code: "forbidden" }); + const createSession = async (user) => { + const raw = token(), + csrfToken = token(); + await db.query( + "INSERT INTO sessions(token_hash,user_id,csrf_hash,expires_at) VALUES($1,$2,$3,now()+interval '14 days')", + [hash(raw), user.id, hash(csrfToken)], + ); + return { raw, csrfToken }; + }; + app.post( + "/api/auth/signup", + rateLimit("signup", 8, 60_000), + async (req, res, next) => { + try { + const email = emailOf(req.body.email), + password = req.body.password; + if (!/^\S+@\S+\.\S+$/.test(email) || !PASSWORD_OK(password)) + return res + .status(400) + .json({ + ok: false, + code: "invalid_credentials", + error: + "Use a valid email and a password of at least 12 characters.", + }); + const setting = await db.query( + "SELECT value FROM app_settings WHERE key='signup_enabled'", + ); + if (setting.rows[0]?.value !== "true") + return res.status(403).json({ ok: false, code: "signup_disabled" }); + const password_hash = await bcrypt.hash(password, 12); + const user = ( + await db.query( + "INSERT INTO users(email,password_hash) VALUES($1,$2) RETURNING id,email,role", + [email, password_hash], + ) + ).rows[0]; + const s = await createSession(user); + setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken); + res + .status(201) + .json({ + ok: true, + user: { email: user.email, role: user.role }, + csrfToken: s.csrfToken, + }); + } catch (e) { + if (e.code === "23505") + return res.status(409).json({ ok: false, code: "email_exists" }); + next(e); + } + }, + ); + app.post( + "/api/auth/bootstrap", + rateLimit("bootstrap", 4, 60_000), + async (req, res, next) => { + try { + const exists = await db.query("SELECT 1 FROM users WHERE email=$1", [ + ADMIN_EMAIL, + ]); + // Once the administrator exists, the deployment no longer needs to retain + // the bootstrap secret. Do not reveal whether a supplied token was valid. + if (exists.rowCount) + return res.status(409).json({ ok: false, code: "bootstrap_used" }); + const bootstrap = String(req.body.setupToken || ""); + if (!env.BOOTSTRAP_SETUP_TOKEN) + return res.status(503).json({ ok: false, code: "bootstrap_unavailable" }); + if ( + bootstrap.length !== env.BOOTSTRAP_SETUP_TOKEN.length || + !crypto.timingSafeEqual( + Buffer.from(bootstrap), + Buffer.from(env.BOOTSTRAP_SETUP_TOKEN), + ) + ) + return res + .status(403) + .json({ ok: false, code: "invalid_setup_token" }); + if ( + emailOf(req.body.email) !== ADMIN_EMAIL || + !PASSWORD_OK(req.body.password) + ) + return res + .status(400) + .json({ ok: false, code: "invalid_credentials" }); + const user = ( + await db.query( + "INSERT INTO users(email,password_hash,role) VALUES($1,$2,'admin') RETURNING id,email,role", + [ADMIN_EMAIL, await bcrypt.hash(req.body.password, 12)], + ) + ).rows[0]; + const s = await createSession(user); + setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken); + res + .status(201) + .json({ + ok: true, + user: { email: user.email, role: user.role }, + csrfToken: s.csrfToken, + }); + } catch (e) { + next(e); + } + }, + ); + app.post( + "/api/auth/login", + rateLimit("login", 10, 60_000), + async (req, res, next) => { + try { + const user = ( + await db.query( + "SELECT id,email,role,password_hash FROM users WHERE email=$1", + [emailOf(req.body.email)], + ) + ).rows[0]; + if ( + !user || + !(await bcrypt.compare( + String(req.body.password || ""), + user.password_hash, + )) + ) + return res + .status(401) + .json({ ok: false, code: "invalid_credentials" }); + const s = await createSession(user); + setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken); + res.json({ + ok: true, + user: { email: user.email, role: user.role }, + csrfToken: s.csrfToken, + }); + } catch (e) { + next(e); + } + }, + ); + app.get("/api/auth/me", requireAuth, (req, res) => + res.json({ + ok: true, + user: { id: req.user.id, email: req.user.email, role: req.user.role }, + }), + ); + app.post("/api/auth/logout", requireAuth, csrf, async (req, res, next) => { + try { + await db.query("DELETE FROM sessions WHERE token_hash=$1", [ + hash(cookie(req, "rp_session")), + ]); + setSessionCookie(res, "", 0, ""); + res.json({ ok: true }); + } catch (e) { + next(e); + } + }); + app.get("/api/plans", requireAuth, async (req, res, next) => { + try { + res.json({ + ok: true, + plans: ( + await db.query( + "SELECT id,plan,created_at,updated_at FROM roast_plans WHERE user_id=$1 ORDER BY updated_at DESC", + [req.user.id], + ) + ).rows, + }); + } catch (e) { + next(e); + } + }); + app.post("/api/plans", requireAuth, csrf, async (req, res, next) => { + try { + if (!req.body.plan || typeof req.body.plan !== "object") + return res.status(400).json({ ok: false, code: "bad_plan" }); + const p = ( + await db.query( + "INSERT INTO roast_plans(user_id,plan) VALUES($1,$2) RETURNING id,plan,created_at,updated_at", + [req.user.id, req.body.plan], + ) + ).rows[0]; + res.status(201).json({ ok: true, plan: p }); + } catch (e) { + next(e); + } + }); + app.put("/api/plans/:id", requireAuth, csrf, async (req, res, next) => { + try { + const r = await db.query( + "UPDATE roast_plans SET plan=$1,updated_at=now() WHERE id=$2 AND user_id=$3 RETURNING id,plan,updated_at", + [req.body.plan, req.params.id, req.user.id], + ); + if (!r.rowCount) + return res.status(404).json({ ok: false, code: "not_found" }); + res.json({ ok: true, plan: r.rows[0] }); + } catch (e) { + next(e); + } + }); + app.get("/api/admin/users", requireAuth, admin, async (req, res, next) => { + try { + res.json({ + ok: true, + signupEnabled: + ( + await db.query( + "SELECT value FROM app_settings WHERE key='signup_enabled'", + ) + ).rows[0]?.value === "true", + users: ( + await db.query( + "SELECT u.id,u.email,u.role,u.created_at,count(p.id)::int AS plan_count FROM users u LEFT JOIN roast_plans p ON p.user_id=u.id GROUP BY u.id ORDER BY u.created_at", + ) + ).rows, + }); + } catch (e) { + next(e); + } + }); + app.get("/api/admin/plans", requireAuth, admin, async (req, res, next) => { + try { + res.json({ + ok: true, + plans: ( + await db.query( + "SELECT p.id,p.plan,p.updated_at,u.email FROM roast_plans p JOIN users u ON u.id=p.user_id ORDER BY p.updated_at DESC", + ) + ).rows, + }); + } catch (e) { + next(e); + } + }); + app.put( + "/api/admin/signup-enabled", + requireAuth, + csrf, + admin, + async (req, res, next) => { + try { + if (typeof req.body.enabled !== "boolean") + return res.status(400).json({ ok: false, code: "bad_request" }); + await db.query( + "UPDATE app_settings SET value=$1 WHERE key='signup_enabled'", + [String(req.body.enabled)], + ); + res.json({ ok: true }); + } catch (e) { + next(e); + } + }, + ); + // Existing integrations remain authenticated but CSRF-protected for writes. + app.post("/api/prefill", requireAuth, csrf, async (req, res) => { + const url = typeof req.body?.url === "string" ? req.body.url.trim() : ""; + if (!url) + return res + .status(400) + .json({ ok: false, code: "bad_url", error: "Missing url." }); + try { + const result = await runPrefill(await fetchPageText(url)); + res.json({ ok: true, ...result }); + } catch (err) { + const code = err.code ?? "prefill_failed"; + res + .status( + code === "fetch_timeout" + ? 504 + : code === "bad_url" + ? 400 + : code === "no_model" + ? 503 + : 422, + ) + .json({ ok: false, code, error: err.message }); + } + }); + app.post("/api/alog", requireAuth, csrf, (req, res) => { + try { + if (typeof req.body?.content !== "string" || !req.body.content.trim()) + return res.status(400).json({ ok: false, code: "bad_request" }); + res.json({ + ok: true, + ...parseAlog(req.body.content, req.body.filename ?? "upload.alog"), + }); + } catch (err) { + res + .status(422) + .json({ ok: false, code: "unparseable_alog", error: err.message }); + } + }); + app.get("/api/alog/library", requireAuth, async (_q, res) => + res.json({ ok: true, files: await listAlogLibrary() }), + ); + app.get("/api/alog/library/:filename", requireAuth, async (req, res) => { + try { + res.json({ + ok: true, + ...(await readAlogFromLibrary(req.params.filename)), + }); + } catch (e) { + res + .status(e.code === "not_found" ? 404 : 400) + .json({ ok: false, code: e.code, error: e.message }); + } + }); + app.get("/", (_q, res) => + res.sendFile(path.join(root, "public", "landing.html")), + ); + app.get("/app", requireAuth, (_q, res) => + res.sendFile(path.join(root, "public", "index.html")), + ); + app.get("/admin", requireAuth, admin, (_q, res) => + res.sendFile(path.join(root, "public", "admin.html")), + ); + app.use(express.static(path.join(root, "public"))); + app.use("/shared", express.static(path.join(root, "shared"))); + app.use((err, _req, res, _next) => { + console.error(err); + res.status(500).json({ ok: false, code: "internal_error" }); + }); + return app; } diff --git a/server/db.js b/server/db.js index 4e3613d..f793b66 100644 --- a/server/db.js +++ b/server/db.js @@ -4,21 +4,52 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; export function createDb(connectionString = process.env.DATABASE_URL) { - if (!connectionString) throw new Error("DATABASE_URL is required"); - const pool = new pg.Pool({ connectionString, max: 10, ssl: process.env.DATABASE_SSL === "true" ? { rejectUnauthorized: true } : undefined }); - return { query: (...args) => pool.query(...args), close: () => pool.end() }; + if (!connectionString) throw new Error("DATABASE_URL is required"); + const pool = new pg.Pool({ + connectionString, + max: 10, + ssl: + process.env.DATABASE_SSL === "true" + ? { rejectUnauthorized: true } + : undefined, + }); + return { query: (...args) => pool.query(...args), close: () => pool.end() }; } /** Apply each versioned SQL file once; failed migrations are not recorded. */ export async function migrate(db) { - await db.query("CREATE TABLE IF NOT EXISTS schema_migrations (filename text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())"); - const directory = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "db", "migrations"); - const files = (await fs.readdir(directory)).filter((file) => file.endsWith(".sql")).sort(); - for (const filename of files) { - if ((await db.query("SELECT 1 FROM schema_migrations WHERE filename=$1", [filename])).rowCount) continue; - const sql = await fs.readFile(path.join(directory, filename), "utf8"); - await db.query("BEGIN"); - try { await db.query(sql); await db.query("INSERT INTO schema_migrations(filename) VALUES($1)", [filename]); await db.query("COMMIT"); } - catch (error) { await db.query("ROLLBACK"); throw error; } - } + await db.query( + "CREATE TABLE IF NOT EXISTS schema_migrations (filename text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())", + ); + const directory = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "db", + "migrations", + ); + const files = (await fs.readdir(directory)) + .filter((file) => file.endsWith(".sql")) + .sort(); + for (const filename of files) { + if ( + ( + await db.query("SELECT 1 FROM schema_migrations WHERE filename=$1", [ + filename, + ]) + ).rowCount + ) + continue; + const sql = await fs.readFile(path.join(directory, filename), "utf8"); + await db.query("BEGIN"); + try { + await db.query(sql); + await db.query("INSERT INTO schema_migrations(filename) VALUES($1)", [ + filename, + ]); + await db.query("COMMIT"); + } catch (error) { + await db.query("ROLLBACK"); + throw error; + } + } } diff --git a/server/index.js b/server/index.js index f88258a..7897415 100644 --- a/server/index.js +++ b/server/index.js @@ -7,4 +7,6 @@ const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); const port = Number(process.env.PORT) || 8090; const db = createDb(); await migrate(db); -createApp({ db, root }).listen(port, () => console.log(`Roast planner listening on ${port}`)); +createApp({ db, root }).listen(port, () => + console.log(`Roast planner listening on ${port}`), +); diff --git a/test/auth.test.js b/test/auth.test.js index b82b10f..3fb2773 100644 --- a/test/auth.test.js +++ b/test/auth.test.js @@ -1,15 +1,158 @@ import test from "node:test"; import assert from "node:assert/strict"; +import crypto from "node:crypto"; import path from "node:path"; import { fileURLToPath } from "node:url"; import request from "supertest"; import { newDb } from "pg-mem"; import { createApp } from "../server/app.js"; -async function setup() { const mem=newDb(); mem.public.registerFunction({name:"gen_random_uuid",returns:"uuid",implementation:()=>crypto.randomUUID(), impure:true}); const pg=mem.adapters.createPg(); const db=new pg.Pool(); await db.query(`CREATE TABLE users(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),email text UNIQUE NOT NULL,password_hash text NOT NULL,role text NOT NULL DEFAULT 'user',created_at timestamptz DEFAULT now()); CREATE TABLE sessions(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id),csrf_hash text NOT NULL,expires_at timestamptz NOT NULL,created_at timestamptz DEFAULT now()); CREATE TABLE roast_plans(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id),plan jsonb NOT NULL,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now()); CREATE TABLE app_settings(key text PRIMARY KEY,value text NOT NULL); INSERT INTO app_settings VALUES('signup_enabled','true')`); return request.agent(createApp({db,root:path.resolve(path.dirname(fileURLToPath(import.meta.url)),".."),env:{NODE_ENV:"test",BOOTSTRAP_SETUP_TOKEN:"a-secure-bootstrap-token"}})); } -async function signup(agent,email) { const r=await agent.post('/api/auth/signup').send({email,password:'this is a long password'}); return {r,csrf:r.body.csrfToken}; } -test('headers, auth, csrf, ownership, admin and signup toggle',async()=>{const a=await setup(), b=await setup(); const root=await a.get('/'); assert.equal(root.status,200);assert.match(root.headers['content-security-policy'],/default-src 'self'/); assert.equal((await a.get('/api/plans')).status,401); assert.match((await a.get('/sw.js')).text, /Never serve authenticated/); assert.equal((await a.get('/app')).status,401); - const one=await signup(a,'one@example.com'); assert.equal(one.r.status,201); const plan=await a.post('/api/plans').set('x-csrf-token',one.csrf).send({plan:{fields:{'0.1':'Private'}}});assert.equal(plan.status,201); assert.equal((await a.put(`/api/plans/${plan.body.plan.id}`).send({plan:{}})).status,403); - const other=await signup(b,'two@example.com'); assert.equal((await b.put(`/api/plans/${plan.body.plan.id}`).set('x-csrf-token',other.csrf).send({plan:{}})).status,404); - const admin=await a.post('/api/auth/bootstrap').send({email:'snowspeeder@gmail.com',password:'this is an admin password',setupToken:'a-secure-bootstrap-token'}); assert.equal(admin.status,201); const c=admin.body.csrfToken; assert.equal((await a.put('/api/admin/signup-enabled').set('x-csrf-token',c).send({enabled:false})).status,200); assert.equal((await a.post('/api/auth/signup').send({email:'blocked@example.com',password:'this is a long password'})).status,403); assert.equal((await a.get('/api/admin/users')).status,200);}); -test('bootstrap rejects invalid token and cannot be reused',async()=>{const a=await setup();assert.equal((await a.post('/api/auth/bootstrap').send({email:'snowspeeder@gmail.com',password:'this is an admin password',setupToken:'wrong'})).status,403);const r=await a.post('/api/auth/bootstrap').send({email:'snowspeeder@gmail.com',password:'this is an admin password',setupToken:'a-secure-bootstrap-token'});assert.equal(r.status,201);assert.equal((await a.post('/api/auth/bootstrap').send({email:'snowspeeder@gmail.com',password:'this is an admin password',setupToken:'a-secure-bootstrap-token'})).status,409);}); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const password = "this is a long password"; + +async function setup() { + const mem = newDb(); + mem.public.registerFunction({ + name: "gen_random_uuid", + returns: "uuid", + implementation: () => crypto.randomUUID(), + impure: true, + }); + const pg = mem.adapters.createPg(); + const db = new pg.Pool(); + await db.query(`CREATE TABLE users(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),email text UNIQUE NOT NULL,password_hash text NOT NULL,role text NOT NULL DEFAULT 'user',created_at timestamptz DEFAULT now()); CREATE TABLE sessions(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id),csrf_hash text NOT NULL,expires_at timestamptz NOT NULL,created_at timestamptz DEFAULT now()); CREATE TABLE roast_plans(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id),plan jsonb NOT NULL,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now()); CREATE TABLE app_settings(key text PRIMARY KEY,value text NOT NULL); INSERT INTO app_settings VALUES('signup_enabled','true')`); + const app = createApp({ + db, + root, + env: { NODE_ENV: "test", BOOTSTRAP_SETUP_TOKEN: "a-secure-bootstrap-token" }, + }); + return { db, app, agent: request.agent(app) }; +} + +async function signup(agent, email) { + const response = await agent + .post("/api/auth/signup") + .send({ email, password }); + return { response, csrf: response.body.csrfToken }; +} + +test("strict CSP/static modules, no-store data, auth lifecycle, and ownership share one database", async () => { + const { db, app, agent: first } = await setup(); + const second = request.agent(app); + const anonymous = request.agent(app); + + const landing = await anonymous.get("/"); + assert.equal(landing.status, 200); + assert.match(landing.headers["content-security-policy"], /default-src 'self'/); + assert.doesNotMatch( + landing.headers["content-security-policy"], + /(?:default-src|script-src)[^;]*unsafe-inline/, + ); + assert.match(landing.text, /