feat: harden authenticated deployment
This commit is contained in:
@@ -5,3 +5,4 @@ node_modules
|
||||
npm-debug.log*
|
||||
Dockerfile
|
||||
README.md
|
||||
appdata/
|
||||
|
||||
+5
-1
@@ -1,4 +1,8 @@
|
||||
# Generate each with: openssl rand -base64 48
|
||||
POSTGRES_PASSWORD=
|
||||
# One-use secret to create [email protected]; remove after bootstrap.
|
||||
# One-use secret to create [email protected]; 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=
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
node_modules
|
||||
appdata//
|
||||
data/
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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, `[email protected]`, 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, `[email protected]`, 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.
|
||||
|
||||
+15
-2
@@ -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:
|
||||
|
||||
+24
-1
@@ -1 +1,24 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Roast Planner Admin</title><link rel="stylesheet" href="/app.css"></head><body><main class="auth-page"><section class="panel-card auth-card"><a href="/app">← Plans</a><h1>Administration</h1><label><input id="signup" type="checkbox"> Allow new signups</label><button id="save" class="primary-btn">Save setting</button><h2>Users</h2><ul id="users"></ul><h2>Recent roast plans</h2><ul id="plans"></ul></section></main><script type="module">const csrf=()=>document.cookie.split('; ').find(v=>v.startsWith('rp_csrf='))?.split('=')[1]||'';const users=document.querySelector('#users'),plans=document.querySelector('#plans');async function load(){const b=await(await fetch('/api/admin/users')).json();document.querySelector('#signup').checked=b.signupEnabled;users.replaceChildren(...b.users.map(u=>Object.assign(document.createElement('li'),{textContent:`${u.email} (${u.role}) — ${u.plan_count} plans`})));const p=await(await fetch('/api/admin/plans')).json();plans.replaceChildren(...p.plans.map(x=>Object.assign(document.createElement('li'),{textContent:`${x.email}: ${x.plan?.fields?.['0.1']||'Untitled plan'}`})));}document.querySelector('#save').onclick=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();</script></body></html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Roast Planner Admin</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="auth-page">
|
||||
<section class="panel-card auth-card">
|
||||
<a href="/app">← Plans</a>
|
||||
<h1>Administration</h1>
|
||||
<label><input id="signup" type="checkbox" /> Allow new signups</label
|
||||
><button id="save" class="primary-btn">Save setting</button>
|
||||
<h2>Users</h2>
|
||||
<ul id="users"></ul>
|
||||
<h2>Recent roast plans</h2>
|
||||
<ul id="plans"></ul>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="/js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1584
-350
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
+90
-7
@@ -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")}`;}
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
+180
-47
@@ -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");
|
||||
|
||||
@@ -36,7 +49,8 @@ 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]] = {};
|
||||
if (node[parts[i]] === undefined || node[parts[i]] === null)
|
||||
node[parts[i]] = {};
|
||||
node = node[parts[i]];
|
||||
}
|
||||
node[parts[parts.length - 1]] = value;
|
||||
@@ -65,13 +79,15 @@ function renderBlendPrintRows() {
|
||||
tbody.replaceChildren();
|
||||
state.plan.blendComponents.forEach((_, i) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.append(document.createRange().createContextualFragment(`
|
||||
tr.append(
|
||||
document.createRange().createContextualFragment(`
|
||||
<td><input class="f w ws-input" name="blendComponents.${i}.cultivar"><span class="pv"></span></td>
|
||||
<td><input class="f ws-input" style="min-width:18mm" name="blendComponents.${i}.group"><span class="pv"></span></td>
|
||||
<td><input class="f ws-input" style="min-width:20mm" name="blendComponents.${i}.process"><span class="pv"></span></td>
|
||||
<td class="num"><input class="f n ws-input" name="blendComponents.${i}.sharePct"><span class="pv"></span></td>
|
||||
<td class="num"><input class="f n ws-input" name="blendComponents.${i}.fcAnchor"><span class="pv"></span></td>
|
||||
`));
|
||||
`),
|
||||
);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
@@ -82,14 +98,16 @@ function renderBlendCards() {
|
||||
state.plan.blendComponents.forEach((_, i) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "blend-card";
|
||||
card.append(document.createRange().createContextualFragment(`
|
||||
card.append(
|
||||
document.createRange().createContextualFragment(`
|
||||
<label class="field"><span class="field-label">Cultivar</span><input class="field-input" name="blendComponents.${i}.cultivar" placeholder="e.g. Caturra"></label>
|
||||
<label class="field"><span class="field-label">Group</span><input class="field-input" name="blendComponents.${i}.group"></label>
|
||||
<label class="field"><span class="field-label">Process</span><input class="field-input" name="blendComponents.${i}.process"></label>
|
||||
<label class="field"><span class="field-label">Share %</span><input class="field-input sm" name="blendComponents.${i}.sharePct"></label>
|
||||
<label class="field"><span class="field-label">FC anchor</span><input class="field-input sm" name="blendComponents.${i}.fcAnchor"></label>
|
||||
<button type="button" class="blend-remove" data-remove-blend="${i}" aria-label="Remove component" ${state.plan.blendComponents.length <= 1 ? "disabled" : ""}>✕</button>
|
||||
`));
|
||||
`),
|
||||
);
|
||||
wrap.appendChild(card);
|
||||
});
|
||||
for (const btn of wrap.querySelectorAll("[data-remove-blend]")) {
|
||||
@@ -105,7 +123,10 @@ function renderBlendCards() {
|
||||
}
|
||||
|
||||
function updateBlendTotal() {
|
||||
const total = state.plan.blendComponents.reduce((sum, c) => sum + (Number.parseFloat(c.sharePct) || 0), 0);
|
||||
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;
|
||||
@@ -116,7 +137,9 @@ function updateBlendTotal() {
|
||||
}
|
||||
|
||||
function updateBlendVisibility(mode) {
|
||||
document.getElementById("blend-body").classList.toggle("collapsed", mode !== "blend");
|
||||
document
|
||||
.getElementById("blend-body")
|
||||
.classList.toggle("collapsed", mode !== "blend");
|
||||
}
|
||||
|
||||
function renderBlend() {
|
||||
@@ -130,13 +153,15 @@ function renderActuatorPrintRows() {
|
||||
tbody.replaceChildren();
|
||||
state.plan.actuators.forEach((_, i) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.append(document.createRange().createContextualFragment(`
|
||||
tr.append(
|
||||
document.createRange().createContextualFragment(`
|
||||
<td class="num"><input class="f n ws-input" name="actuators.${i}.time"><span class="pv"></span></td>
|
||||
<td class="num"><input class="f n ws-input" name="actuators.${i}.heatPct"><span class="pv"></span></td>
|
||||
<td class="num"><input class="f n ws-input" name="actuators.${i}.fanPct"><span class="pv"></span></td>
|
||||
<td class="num"><input class="f n ws-input" name="actuators.${i}.expectedBt"><span class="pv"></span></td>
|
||||
<td><input class="f ws-input" style="min-width:78mm" name="actuators.${i}.why"><span class="pv"></span></td>
|
||||
`));
|
||||
`),
|
||||
);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
@@ -147,7 +172,8 @@ function renderActuatorTimeline() {
|
||||
state.plan.actuators.forEach((_, i) => {
|
||||
const step = document.createElement("div");
|
||||
step.className = "actuator-step";
|
||||
step.append(document.createRange().createContextualFragment(`
|
||||
step.append(
|
||||
document.createRange().createContextualFragment(`
|
||||
<div class="actuator-rail"><div class="actuator-dot"></div><div class="actuator-line"></div></div>
|
||||
<div class="actuator-card">
|
||||
<label class="field"><span class="field-label">Time</span><input class="field-input sm" name="actuators.${i}.time" placeholder="m:ss"></label>
|
||||
@@ -157,7 +183,8 @@ function renderActuatorTimeline() {
|
||||
<label class="field"><span class="field-label">Expected BT</span><input class="field-input sm" name="actuators.${i}.expectedBt"></label>
|
||||
<label class="field actuator-why"><span class="field-label">Why this change</span><input class="field-input" name="actuators.${i}.why" placeholder="What you're watching for"></label>
|
||||
</div>
|
||||
`));
|
||||
`),
|
||||
);
|
||||
wrap.appendChild(step);
|
||||
});
|
||||
for (const btn of wrap.querySelectorAll("[data-remove-actuator]")) {
|
||||
@@ -192,11 +219,13 @@ export function renderFormFromPlan() {
|
||||
}
|
||||
}
|
||||
updateBlendVisibility(state.plan.fields["2.1"]);
|
||||
document.getElementById("header-coffee-name").textContent = state.plan.fields["0.1"] || "New plan";
|
||||
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() {
|
||||
@@ -225,7 +254,9 @@ function renderLedger() {
|
||||
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");
|
||||
cell.classList.add(
|
||||
check.pass === null ? "unknown" : check.pass ? "pass" : "fail",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,8 +271,10 @@ function renderLedger() {
|
||||
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;
|
||||
}
|
||||
@@ -292,7 +325,10 @@ function paintCurveInto(planGroup, refGroup, planPoints, ref) {
|
||||
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");
|
||||
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");
|
||||
@@ -306,7 +342,10 @@ function paintCurveInto(planGroup, refGroup, planPoints, ref) {
|
||||
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)}`)
|
||||
.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");
|
||||
@@ -318,7 +357,10 @@ function paintCurveInto(planGroup, refGroup, planPoints, ref) {
|
||||
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");
|
||||
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");
|
||||
@@ -367,21 +409,35 @@ function autosave() {
|
||||
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 (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 */ });
|
||||
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`;
|
||||
text.textContent =
|
||||
secs < 8
|
||||
? "Saved just now"
|
||||
: secs < 60
|
||||
? `Saved ${secs}s ago`
|
||||
: `Saved ${Math.round(secs / 60)}m ago`;
|
||||
};
|
||||
tick();
|
||||
clearInterval(autosaveAgeTimer);
|
||||
@@ -393,15 +449,28 @@ function autosave() {
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function loadFromStorage() {
|
||||
function loadFromStorage(userId) {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
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;
|
||||
@@ -414,11 +483,13 @@ function cultivarAutofill(name) {
|
||||
|
||||
function wireCultivarDatalist() {
|
||||
const list = document.getElementById("cultivar-list");
|
||||
list.replaceChildren(...CULTIVARS.map((c) => {
|
||||
list.replaceChildren(
|
||||
...CULTIVARS.map((c) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = c.name;
|
||||
return option;
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function wireForm() {
|
||||
@@ -453,7 +524,9 @@ function wireDrawers() {
|
||||
overlay.classList.add("hidden");
|
||||
}
|
||||
|
||||
document.getElementById("btn-toggle-prefill").addEventListener("click", () => {
|
||||
document
|
||||
.getElementById("btn-toggle-prefill")
|
||||
.addEventListener("click", () => {
|
||||
prefill.classList.contains("hidden") ? open(prefill) : closeAll();
|
||||
});
|
||||
document.getElementById("btn-toggle-alog").addEventListener("click", () => {
|
||||
@@ -472,14 +545,20 @@ function wireToolbar() {
|
||||
});
|
||||
|
||||
document.getElementById("btn-save").addEventListener("click", () => {
|
||||
const blob = new Blob([JSON.stringify(state.plan, null, 2)], { type: "application/json" });
|
||||
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("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;
|
||||
@@ -496,13 +575,33 @@ function wireToolbar() {
|
||||
});
|
||||
|
||||
document.getElementById("btn-add-blend").addEventListener("click", () => {
|
||||
state.plan.blendComponents.push({ cultivar: "", group: "", process: "", sharePct: "", fcAnchor: "" });
|
||||
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: "" });
|
||||
state.plan.actuators.push({
|
||||
time: "",
|
||||
heatPct: "",
|
||||
fanPct: "",
|
||||
expectedBt: "",
|
||||
why: "",
|
||||
});
|
||||
renderActuators();
|
||||
renderFormFromPlan();
|
||||
recompute();
|
||||
@@ -514,7 +613,9 @@ function wirePwa() {
|
||||
const renderConnection = () => {
|
||||
const offline = !navigator.onLine;
|
||||
status.classList.toggle("hidden", !offline);
|
||||
status.textContent = offline ? "Offline — changes continue saving on this device." : "";
|
||||
status.textContent = offline
|
||||
? "Offline — changes continue saving on this device."
|
||||
: "";
|
||||
};
|
||||
|
||||
window.addEventListener("online", renderConnection);
|
||||
@@ -542,7 +643,8 @@ function wireSectionNav() {
|
||||
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);
|
||||
for (const a of links)
|
||||
a.classList.toggle("active", a.getAttribute("href") === id);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "-20% 0px -70% 0px" },
|
||||
@@ -552,9 +654,25 @@ function wireSectionNav() {
|
||||
|
||||
async function init() {
|
||||
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 { /* offline starts from the local cache */ }
|
||||
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();
|
||||
@@ -565,10 +683,25 @@ async function init() {
|
||||
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 });
|
||||
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();
|
||||
|
||||
+93
-14
@@ -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;
|
||||
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…");
|
||||
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; }
|
||||
const res = await fetch("/api/prefill", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-csrf-token": csrf() },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
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 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]}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+46
-1
@@ -1 +1,46 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Roast Planner</title><link rel="stylesheet" href="/app.css"><meta name="theme-color" content="#A8481A"></head><body><main class="auth-page"><section class="panel-card auth-card"><h1>Roast Planner</h1><p>Build, save, and revisit your coffee roast plans.</p><div id="message" role="status"></div><form id="auth-form"><label>Email <input class="field-input" required type="email" name="email" autocomplete="email"></label><label>Password <input class="field-input" required type="password" minlength="12" name="password" autocomplete="current-password"></label><button class="primary-btn" type="submit">Log in</button><button class="ghost-btn" type="button" id="signup">Create account</button></form><p class="muted">Accounts require a password of at least 12 characters.</p></section></main><script type="module">const f=document.querySelector('#auth-form'),m=document.querySelector('#message');async function submit(url){const r=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(Object.fromEntries(new FormData(f)))});const b=await r.json();if(!r.ok){m.textContent=b.error||b.code;return;}location='/app';}f.onsubmit=e=>{e.preventDefault();submit('/api/auth/login')};document.querySelector('#signup').onclick=()=>submit('/api/auth/signup');</script></body></html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="auth-page">
|
||||
<section class="panel-card auth-card">
|
||||
<h1>Roast Planner</h1>
|
||||
<p>Build, save, and revisit your coffee roast plans.</p>
|
||||
<div id="message" role="status"></div>
|
||||
<form id="auth-form">
|
||||
<label
|
||||
>Email
|
||||
<input
|
||||
class="field-input"
|
||||
required
|
||||
type="email"
|
||||
name="email"
|
||||
autocomplete="email" /></label
|
||||
><label
|
||||
>Password
|
||||
<input
|
||||
class="field-input"
|
||||
required
|
||||
type="password"
|
||||
minlength="12"
|
||||
name="password"
|
||||
autocomplete="current-password" /></label
|
||||
><button class="primary-btn" type="submit">Log in</button
|
||||
><button class="ghost-btn" type="button" id="signup">
|
||||
Create account
|
||||
</button>
|
||||
</form>
|
||||
<p class="muted">
|
||||
Accounts require a password of at least 12 characters.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="/js/landing.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+28
-6
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = "roast-planner-static-v1";
|
||||
const CACHE_NAME = "roast-planner-static-v2";
|
||||
const APP_SHELL = [
|
||||
"/",
|
||||
"/landing.html",
|
||||
@@ -7,6 +7,8 @@ const APP_SHELL = [
|
||||
"/manifest.webmanifest",
|
||||
"/icon.svg",
|
||||
"/js/main.js",
|
||||
"/js/landing.js",
|
||||
"/js/admin.js",
|
||||
"/js/prefill-ui.js",
|
||||
"/js/alog-ui.js",
|
||||
"/js/print.js",
|
||||
@@ -18,13 +20,23 @@ const APP_SHELL = [
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)));
|
||||
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)))),
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((key) => key !== CACHE_NAME)
|
||||
.map((key) => caches.delete(key)),
|
||||
),
|
||||
),
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
@@ -37,11 +49,18 @@ self.addEventListener("fetch", (event) => {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) 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")));
|
||||
event.respondWith(
|
||||
fetch(request).catch(() => caches.match("/landing.html")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -49,7 +68,10 @@ self.addEventListener("fetch", (event) => {
|
||||
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()));
|
||||
if (response.ok)
|
||||
caches
|
||||
.open(CACHE_NAME)
|
||||
.then((cache) => cache.put(request, response.clone()));
|
||||
return response;
|
||||
})
|
||||
.catch(() => cached);
|
||||
|
||||
+429
-33
@@ -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 = "[email protected]";
|
||||
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 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();
|
||||
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");
|
||||
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(); });
|
||||
// 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 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)}});
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
+38
-7
@@ -5,20 +5,51 @@ 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 });
|
||||
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();
|
||||
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;
|
||||
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; }
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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}`),
|
||||
);
|
||||
|
||||
+150
-7
@@ -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,'[email protected]'); 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,'[email protected]'); 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:'[email protected]',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:'[email protected]',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:'[email protected]',password:'this is an admin password',setupToken:'wrong'})).status,403);const r=await a.post('/api/auth/bootstrap').send({email:'[email protected]',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:'[email protected]',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, /<script type="module" src="\/js\/landing\.js"><\/script>/);
|
||||
assert.doesNotMatch(landing.text, /<script type="module">/);
|
||||
const adminHtml = await anonymous.get("/admin");
|
||||
assert.equal(adminHtml.status, 401);
|
||||
assert.equal(adminHtml.headers["cache-control"], "no-store, private");
|
||||
assert.equal((await anonymous.get("/api/plans")).status, 401);
|
||||
assert.equal((await anonymous.get("/api/plans")).headers["cache-control"], "no-store, private");
|
||||
assert.match((await anonymous.get("/js/admin.js")).text, /async function load/);
|
||||
const mainScript = await anonymous.get("/js/main.js");
|
||||
assert.match(mainScript.text, /roastPlannerPlan\.v2/);
|
||||
assert.match(mainScript.text, /localStorage\.removeItem\(key\)/);
|
||||
assert.match((await anonymous.get("/sw.js")).text, /Never serve authenticated/);
|
||||
|
||||
const one = await signup(first, "[email protected]");
|
||||
const two = await signup(second, "[email protected]");
|
||||
assert.equal(one.response.status, 201);
|
||||
assert.equal(two.response.status, 201);
|
||||
const plan = await first
|
||||
.post("/api/plans")
|
||||
.set("x-csrf-token", one.csrf)
|
||||
.send({ plan: { fields: { "0.1": "Private" } } });
|
||||
assert.equal(plan.status, 201);
|
||||
assert.equal((await first.get("/api/plans")).headers["cache-control"], "no-store, private");
|
||||
assert.equal(
|
||||
(
|
||||
await second
|
||||
.put(`/api/plans/${plan.body.plan.id}`)
|
||||
.set("x-csrf-token", two.csrf)
|
||||
.send({ plan: {} })
|
||||
).status,
|
||||
404,
|
||||
);
|
||||
assert.equal((await second.get("/api/plans")).body.plans.length, 0);
|
||||
|
||||
const login = request.agent(app);
|
||||
assert.equal(
|
||||
(await login.post("/api/auth/login").send({ email: "[email protected]", password })).status,
|
||||
200,
|
||||
);
|
||||
assert.equal((await login.get("/api/auth/me")).status, 200);
|
||||
const loginCsrf = (await login.post("/api/auth/login").send({ email: "[email protected]", password })).body.csrfToken;
|
||||
assert.equal(
|
||||
(await login.post("/api/auth/logout").set("x-csrf-token", loginCsrf)).status,
|
||||
200,
|
||||
);
|
||||
assert.equal((await login.get("/api/auth/me")).status, 401);
|
||||
|
||||
const admin = await first.post("/api/auth/bootstrap").send({
|
||||
email: "[email protected]",
|
||||
password,
|
||||
setupToken: "a-secure-bootstrap-token",
|
||||
});
|
||||
assert.equal(admin.status, 201);
|
||||
assert.equal((await first.get("/api/admin/users")).status, 200);
|
||||
assert.equal((await second.get("/api/admin/users")).status, 403);
|
||||
assert.equal(
|
||||
(
|
||||
await first
|
||||
.put("/api/admin/signup-enabled")
|
||||
.set("x-csrf-token", admin.body.csrfToken)
|
||||
.send({ enabled: false })
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(await anonymous.post("/api/auth/signup").send({ email: "[email protected]", password })).status,
|
||||
403,
|
||||
);
|
||||
assert.equal((await db.query("SELECT count(*)::int AS count FROM users")).rows[0].count, 3);
|
||||
});
|
||||
|
||||
test("bootstrap token is optional after first setup and unavailable before setup without one", async () => {
|
||||
const { app, db } = await setup();
|
||||
const agent = request.agent(app);
|
||||
assert.equal(
|
||||
(
|
||||
await agent.post("/api/auth/bootstrap").send({
|
||||
email: "[email protected]",
|
||||
password,
|
||||
setupToken: "wrong",
|
||||
})
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
const noTokenApp = createApp({ db, root, env: { NODE_ENV: "test" } });
|
||||
assert.equal(
|
||||
(
|
||||
await request(noTokenApp).post("/api/auth/bootstrap").send({
|
||||
email: "[email protected]",
|
||||
password,
|
||||
})
|
||||
).status,
|
||||
503,
|
||||
);
|
||||
await db.query("INSERT INTO users(email,password_hash,role) VALUES($1,$2,'admin')", [
|
||||
"[email protected]",
|
||||
"not-used-in-this-test",
|
||||
]);
|
||||
assert.equal(
|
||||
(
|
||||
await request(noTokenApp).post("/api/auth/bootstrap").send({
|
||||
email: "[email protected]",
|
||||
password,
|
||||
})
|
||||
).status,
|
||||
409,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const root = path.resolve(import.meta.dirname, "..");
|
||||
|
||||
async function dockerAvailable() {
|
||||
try {
|
||||
await execFileAsync("docker", ["info"], { timeout: 15_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test("Docker image starts against PostgreSQL and applies migrations", async (t) => {
|
||||
if (!(await dockerAvailable())) {
|
||||
t.skip("Docker daemon is unavailable");
|
||||
return;
|
||||
}
|
||||
const temp = await mkdtemp(path.join(os.tmpdir(), "roast-planner-container-"));
|
||||
const agentDir = path.join(temp, "pi-agent");
|
||||
await mkdir(agentDir);
|
||||
const envFile = path.join(temp, "compose.env");
|
||||
await writeFile(
|
||||
envFile,
|
||||
`POSTGRES_PASSWORD=container-test-password\nBOOTSTRAP_SETUP_TOKEN=\nPI_AGENT_CONFIG_DIR=${agentDir}\n`,
|
||||
);
|
||||
const project = `roastplanner${Date.now()}`;
|
||||
const compose = (args, options = {}) =>
|
||||
execFileAsync(
|
||||
"docker",
|
||||
["compose", "--project-name", project, "--env-file", envFile, ...args],
|
||||
{ cwd: root, timeout: 120_000, ...options },
|
||||
);
|
||||
t.after(async () => {
|
||||
try {
|
||||
await compose(["down", "--volumes", "--remove-orphans"]);
|
||||
} catch {
|
||||
// Preserve the startup failure rather than masking it with cleanup.
|
||||
}
|
||||
});
|
||||
await compose(["up", "--build", "--detach"]);
|
||||
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
try {
|
||||
await compose([
|
||||
"exec",
|
||||
"-T",
|
||||
"app",
|
||||
"node",
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
"const response = await fetch('http://127.0.0.1:8090/'); process.exit(response.ok ? 0 : 1)",
|
||||
]);
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
}
|
||||
}
|
||||
assert.equal(lastError, null, "application never became ready in its container");
|
||||
const migration = await compose([
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"psql",
|
||||
"-U",
|
||||
"roast",
|
||||
"-d",
|
||||
"roast",
|
||||
"-tAc",
|
||||
"SELECT count(*) FROM schema_migrations WHERE filename = '001_auth.sql'",
|
||||
]);
|
||||
assert.equal(migration.stdout.trim(), "1");
|
||||
});
|
||||
Reference in New Issue
Block a user