26 changed files with 6452 additions and 1806 deletions
+1
View File
@@ -5,3 +5,4 @@ node_modules
npm-debug.log* npm-debug.log*
Dockerfile Dockerfile
README.md README.md
appdata/
+8
View File
@@ -0,0 +1,8 @@
# Generate each with: openssl rand -base64 48
POSTGRES_PASSWORD=
# 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
View File
@@ -1,4 +1,5 @@
node_modules/ node_modules
appdata//
data/ data/
*.log *.log
.DS_Store .DS_Store
+7
View File
@@ -0,0 +1,7 @@
# Development and tests
Run `npm test` before every commit and whenever changing authentication, API authorization, service-worker behavior, or persistence. Add a regression test under `test/` for every new route and for every security/ownership bug. Tests use an in-memory PostgreSQL-compatible database; production migration is run automatically at startup against `DATABASE_URL`.
Every change pushed to the remote must be deployed to the Unraid Docker service immediately afterward. Verify the deployed container is healthy and the production endpoint responds before reporting completion.
For local development, copy `.env.example`, start `docker compose up --build`, then visit `http://localhost:8090`. Production must set `APP_ORIGIN=https://roast.srmr.xyz`, `COOKIE_SECURE=true`, a strong `POSTGRES_PASSWORD`, and a random `BOOTSTRAP_SETUP_TOKEN`. Bootstrap `[email protected]` exactly once at `POST /api/auth/bootstrap`, then remove `BOOTSTRAP_SETUP_TOKEN` from deployment configuration. Never commit credentials or a bootstrap password/token.
+4
View File
@@ -9,7 +9,11 @@ RUN npm ci --omit=dev
COPY public ./public COPY public ./public
COPY server ./server COPY server ./server
COPY shared ./shared 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 USER node
EXPOSE 8090 EXPOSE 8090
CMD ["node", "server/index.js"] CMD ["node", "server/index.js"]
+25 -3
View File
@@ -21,10 +21,33 @@ A fillable, live-computing web version of the manual coffee roast plan worksheet
## Run it ## Run it
```bash ```bash
cp .env.example .env # set strong secrets
npm install npm install
npm start # http://localhost:8090 (set PORT to override) npm test
# local PostgreSQL stack (database is not published to the host)
docker compose --env-file .env up --build
``` ```
The public landing page is at `/`; plans require an account at `/app`. Production is configured for `https://roast.srmr.xyz`: retain `APP_ORIGIN=https://roast.srmr.xyz` and `COOKIE_SECURE=true` behind its HTTPS proxy. Database migrations in `db/migrations/` run at application startup exactly once.
### 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; 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 ## Mobile and PWA use
The planner is responsive and caches its app shell for offline use after the first visit. Browser installation and service-worker caching require HTTPS in production (localhost is exempt). Put the Docker container behind an HTTPS reverse proxy before using it as an installable PWA on a phone. The planner is responsive and caches its app shell for offline use after the first visit. Browser installation and service-worker caching require HTTPS in production (localhost is exempt). Put the Docker container behind an HTTPS reverse proxy before using it as an installable PWA on a phone.
@@ -52,8 +75,7 @@ If those tables change in the paper worksheet, port the change here too.
## Known gaps (v1) ## Known gaps (v1)
- No automated test suite yet (the ledger math and `.alog` parser were verified manually - Offline drafts are intentionally scoped to the authenticated browser account and are cleared on logout; account-backed plans remain the authoritative copy.
against the worksheet's worked examples and all 14 logs in `ref/roasts/`, respectively).
- Roastetta (roastetta.com) integration is intentionally out of scope — it needs a headed, - 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, 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. or point `ALOG_DIR` at wherever the `roastetta` skill already downloaded files.
+7
View File
@@ -0,0 +1,7 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
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' CHECK (role IN ('user','admin')), created_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE sessions (token_hash text PRIMARY KEY, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, csrf_hash text NOT NULL, expires_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE roast_plans (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, plan jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE app_settings (key text PRIMARY KEY, value text NOT NULL);
INSERT INTO app_settings(key,value) VALUES ('signup_enabled','true');
CREATE INDEX roast_plans_user_updated ON roast_plans(user_id, updated_at DESC);
+36
View File
@@ -0,0 +1,36 @@
services:
app:
build: .
environment:
NODE_ENV: production
DATABASE_URL: postgresql://roast:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/roast
APP_ORIGIN: https://roast.srmr.xyz
COOKIE_SECURE: "true"
# 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
# Nginx Proxy Manager routes https://roast.srmr.xyz to this host port.
ports: ["8090:8090"]
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: roast
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:
+740 -21
View File
@@ -8,20 +8,26 @@
"name": "roast-planner-webapp", "name": "roast-planner-webapp",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@earendil-works/pi-coding-agent": "^0.82.1", "@earendil-works/pi-coding-agent": "^0.83.0",
"express": "^5.0.1" "bcryptjs": "^3.0.3",
"express": "^5.0.1",
"pg": "^8.22.0"
},
"devDependencies": {
"pg-mem": "^3.0.14",
"supertest": "^7.2.2"
} }
}, },
"node_modules/@earendil-works/pi-coding-agent": { "node_modules/@earendil-works/pi-coding-agent": {
"version": "0.82.1", "version": "0.83.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.82.1.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.83.0.tgz",
"integrity": "sha512-zbkAhoIuDPMF3pKuja0ajZabrMWU29FUMV9A/XMXT/XC1yXs5xt6t6t13GogQFsDrDqbFP4DkZQO1w8rWRAzYA==", "integrity": "sha512-uYhF+FsZxogoSX/AxBcUdiY+ZklubwaXyAoEGA2eQwsHcyEAhUYIKh/WLXe/a8+k8eTCmxb+ZN2Zo9mzQtzbWw==",
"hasShrinkwrap": true, "hasShrinkwrap": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-agent-core": "^0.82.1", "@earendil-works/pi-agent-core": "^0.83.0",
"@earendil-works/pi-ai": "^0.82.1", "@earendil-works/pi-ai": "^0.83.0",
"@earendil-works/pi-tui": "^0.82.1", "@earendil-works/pi-tui": "^0.83.0",
"@silvia-odwyer/photon-node": "0.3.4", "@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2", "chalk": "5.6.2",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
@@ -34,7 +40,7 @@
"minimatch": "10.2.5", "minimatch": "10.2.5",
"proper-lockfile": "4.1.2", "proper-lockfile": "4.1.2",
"semver": "7.8.0", "semver": "7.8.0",
"typebox": "1.1.38", "typebox": "1.3.7",
"undici": "8.5.0", "undici": "8.5.0",
"yaml": "2.9.0" "yaml": "2.9.0"
}, },
@@ -484,14 +490,14 @@
} }
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": {
"version": "0.82.1", "version": "0.83.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.82.1.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.83.0.tgz",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-ai": "^0.82.1", "@earendil-works/pi-ai": "^0.83.0",
"diff": "8.0.4", "diff": "8.0.4",
"ignore": "7.0.5", "ignore": "7.0.5",
"typebox": "1.1.38", "typebox": "1.3.7",
"yaml": "2.9.0" "yaml": "2.9.0"
}, },
"engines": { "engines": {
@@ -499,8 +505,8 @@
} }
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": {
"version": "0.82.1", "version": "0.83.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.82.1.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.83.0.tgz",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "0.91.1", "@anthropic-ai/sdk": "0.91.1",
@@ -513,7 +519,7 @@
"https-proxy-agent": "7.0.6", "https-proxy-agent": "7.0.6",
"openai": "6.26.0", "openai": "6.26.0",
"partial-json": "0.1.7", "partial-json": "0.1.7",
"typebox": "1.1.38" "typebox": "1.3.7"
}, },
"bin": { "bin": {
"pi-ai": "dist/cli.js" "pi-ai": "dist/cli.js"
@@ -523,8 +529,8 @@
} }
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": {
"version": "0.82.1", "version": "0.83.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.82.1.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.83.0.tgz",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"get-east-asian-width": "1.6.0", "get-east-asian-width": "1.6.0",
@@ -1718,9 +1724,9 @@
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": {
"version": "1.1.38", "version": "1.3.7",
"resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz",
"integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": {
@@ -1831,6 +1837,29 @@
"zod": "^3.25.28 || ^4" "zod": "^3.25.28 || ^4"
} }
}, },
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@paralleldrive/cuid2": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.1.5"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -1844,6 +1873,29 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"dev": true,
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@@ -1890,6 +1942,25 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"get-intrinsic": "^1.3.0",
"set-function-length": "^1.2.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-bind-apply-helpers": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1919,6 +1990,36 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true,
"license": "MIT"
},
"node_modules/component-emitter": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -1959,6 +2060,13 @@
"node": ">=6.6.0" "node": ">=6.6.0"
} }
}, },
"node_modules/cookiejar": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1976,6 +2084,34 @@
} }
} }
}, },
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1985,6 +2121,24 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/dezalgo": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
"dev": true,
"license": "ISC",
"dependencies": {
"asap": "^2.0.0",
"wrappy": "1"
}
},
"node_modules/discontinuous-range": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz",
"integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==",
"dev": true,
"license": "MIT"
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2044,6 +2198,22 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -2102,6 +2272,13 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"dev": true,
"license": "MIT"
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -2123,6 +2300,64 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/formidable": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
"integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@paralleldrive/cuid2": "^2.2.2",
"dezalgo": "^1.0.4",
"once": "^1.4.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"url": "https://ko-fi.com/tunnckoCore/commissions"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -2150,6 +2385,13 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/functional-red-black-tree": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
"integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==",
"dev": true,
"license": "MIT"
},
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -2199,6 +2441,19 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": { "node_modules/has-symbols": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -2211,6 +2466,22 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -2259,6 +2530,13 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/immutable": {
"version": "4.3.9",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz",
"integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==",
"dev": true,
"license": "MIT"
},
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -2280,6 +2558,56 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/isarray": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"isarray": "^2.0.5",
"jsonify": "^0.0.1",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/jsonify": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
"dev": true,
"license": "Public Domain",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2314,6 +2642,29 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
"dev": true,
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/mime-db": { "node_modules/mime-db": {
"version": "1.54.0", "version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -2339,12 +2690,52 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/moo": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz",
"integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/nearley": {
"version": "2.20.1",
"resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz",
"integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"commander": "^2.19.0",
"moo": "^0.5.0",
"railroad-diagrams": "^1.0.0",
"randexp": "0.4.6"
},
"bin": {
"nearley-railroad": "bin/nearley-railroad.js",
"nearley-test": "bin/nearley-test.js",
"nearley-unparse": "bin/nearley-unparse.js",
"nearleyc": "bin/nearleyc.js"
},
"funding": {
"type": "individual",
"url": "https://nearley.js.org/#give-to-nearley"
}
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@@ -2354,6 +2745,16 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -2366,6 +2767,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -2406,6 +2817,204 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-mem": {
"version": "3.0.14",
"resolved": "https://registry.npmjs.org/pg-mem/-/pg-mem-3.0.14.tgz",
"integrity": "sha512-G9m8OD0A+YS083smidSUJddTX2dEDPT8mRMG3sQGNiGfS/mkvAgd9Kf1/onD5633bFN7HcQK/Tn2x7qjBMFRUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"functional-red-black-tree": "^1.0.1",
"immutable": "^4.3.4",
"json-stable-stringify": "^1.0.1",
"lru-cache": "^6.0.0",
"moment": "^2.27.0",
"object-hash": "^2.0.3",
"pgsql-ast-parser": "^12.0.2"
},
"peerDependencies": {
"@mikro-orm/core": ">=4.5.3",
"@mikro-orm/postgresql": ">=4.5.3",
"knex": ">=0.20",
"kysely": ">=0.26",
"pg-promise": ">=10.8.7",
"pg-server": "^0.1.5",
"postgres": "^3.4.4",
"slonik": ">=23.0.1",
"typeorm": ">=0.2.29"
},
"peerDependenciesMeta": {
"@mikro-orm/core": {
"optional": true
},
"@mikro-orm/postgresql": {
"optional": true
},
"knex": {
"optional": true
},
"kysely": {
"optional": true
},
"mikro-orm": {
"optional": true
},
"pg-promise": {
"optional": true
},
"pg-server": {
"optional": true
},
"postgres": {
"optional": true
},
"slonik": {
"optional": true
},
"typeorm": {
"optional": true
}
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/pgsql-ast-parser": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/pgsql-ast-parser/-/pgsql-ast-parser-12.0.2.tgz",
"integrity": "sha512-1WWa96Sw6h4uv9GLw98EzH/+xoBTC8j2TwV/AMW3E+Ir/fHOu/jLLbj6kPiz3y2bGISTKNYvKWwHoqvQ5FLuAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"moo": "^0.5.1",
"nearley": "^2.19.5"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -2435,6 +3044,27 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/railroad-diagrams": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz",
"integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/randexp": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz",
"integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"discontinuous-range": "1.0.0",
"ret": "~0.1.10"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
@@ -2463,6 +3093,16 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/ret": {
"version": "0.1.15",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
"integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12"
}
},
"node_modules/router": { "node_modules/router": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -2530,6 +3170,24 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -2608,6 +3266,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -2617,6 +3284,42 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/superagent": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
"integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"component-emitter": "^1.3.1",
"cookiejar": "^2.1.4",
"debug": "^4.3.7",
"fast-safe-stringify": "^2.1.1",
"form-data": "^4.0.5",
"formidable": "^3.5.4",
"methods": "^1.1.2",
"mime": "2.6.0",
"qs": "^6.14.1"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/supertest": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz",
"integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cookie-signature": "^1.2.2",
"methods": "^1.1.2",
"superagent": "^10.3.0"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/toidentifier": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -2680,6 +3383,22 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true,
"license": "ISC"
} }
} }
} }
+9 -2
View File
@@ -6,10 +6,17 @@
"description": "Fillable web version of the manual roast plan worksheet, with URL prefill and .alog reference curves.", "description": "Fillable web version of the manual roast plan worksheet, with URL prefill and .alog reference curves.",
"scripts": { "scripts": {
"start": "node server/index.js", "start": "node server/index.js",
"dev": "node --watch server/index.js" "dev": "node --watch server/index.js",
"test": "node --test"
}, },
"dependencies": { "dependencies": {
"@earendil-works/pi-coding-agent": "^0.83.0",
"bcryptjs": "^3.0.3",
"express": "^5.0.1", "express": "^5.0.1",
"@earendil-works/pi-coding-agent": "^0.82.1" "pg": "^8.22.0"
},
"devDependencies": {
"pg-mem": "^3.0.14",
"supertest": "^7.2.2"
} }
} }
+24
View File
@@ -0,0 +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" src="/js/admin.js"></script>
</body>
</html>
+1486 -368
View File
File diff suppressed because it is too large Load Diff
+1747 -407
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
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();
+59 -46
View File
@@ -1,79 +1,92 @@
// Wires the ".alog reference curve" panel: local file upload, and browsing a server-side const csrf = () =>
// library directory (e.g. wherever the roastetta skill already downloaded logs). 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 }) { export function initAlogPanel({ state, recompute }) {
const fileInput = document.getElementById("alog-file"); const fileInput = document.getElementById("alog-file"),
const libraryBtn = document.getElementById("alog-library-refresh"); libraryBtn = document.getElementById("alog-library-refresh"),
const libraryList = document.getElementById("alog-library-list"); libraryList = document.getElementById("alog-library-list"),
const resultEl = document.getElementById("alog-result"); resultEl = document.getElementById("alog-result");
fileInput.addEventListener("change", async (e) => { fileInput.addEventListener("change", async (e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
resultEl.innerHTML = "<p>Parsing…</p>"; setText(resultEl, "Parsing…");
try { try {
const content = await file.text();
const res = await fetch("/api/alog", { const res = await fetch("/api/alog", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json", "x-csrf-token": csrf() },
body: JSON.stringify({ filename: file.name, content }), body: JSON.stringify({
filename: file.name,
content: await file.text(),
}),
}); });
const body = await res.json(); applyResult(await res.json());
applyResult(body);
} catch (err) { } catch (err) {
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(err.message)}</p>`; setText(resultEl, err.message, true);
} }
}); });
libraryBtn.addEventListener("click", async () => { libraryBtn.addEventListener("click", async () => {
libraryList.classList.remove("hidden"); libraryList.classList.remove("hidden");
libraryList.innerHTML = "<li>Loading…</li>"; libraryList.replaceChildren(
Object.assign(document.createElement("li"), { textContent: "Loading…" }),
);
try { try {
const res = await fetch("/api/alog/library"); const body = await (await fetch("/api/alog/library")).json();
const body = await res.json(); if (!body.ok || !body.files.length) {
if (!body.ok || body.files.length === 0) { libraryList.replaceChildren(
libraryList.innerHTML = "<li>No .alog files found. Set ALOG_DIR or drop files in ~/Roastetta.</li>"; Object.assign(document.createElement("li"), {
textContent:
"No .alog files found. Set ALOG_DIR or drop files in ~/Roastetta.",
}),
);
return; return;
} }
libraryList.innerHTML = ""; libraryList.replaceChildren(
for (const f of body.files) { ...body.files.map((f) => {
const li = document.createElement("li"); const li = document.createElement("li");
li.textContent = `${f.filename} (${Math.round(f.sizeBytes / 1024)} KB)`; li.textContent = `${f.filename} (${Math.round(f.sizeBytes / 1024)} KB)`;
li.addEventListener("click", async () => { li.onclick = async () => {
resultEl.innerHTML = "<p>Loading…</p>"; setText(resultEl, "Loading…");
const r = await fetch(`/api/alog/library/${encodeURIComponent(f.filename)}`); applyResult(
applyResult(await r.json()); await (
}); await fetch(
libraryList.appendChild(li); `/api/alog/library/${encodeURIComponent(f.filename)}`,
} )
).json(),
);
};
return li;
}),
);
} catch (err) { } catch (err) {
libraryList.innerHTML = `<li style="color:#a8371a">${escapeHtml(err.message)}</li>`; const li = document.createElement("li");
li.textContent = err.message;
li.style.color = "#a8371a";
libraryList.replaceChildren(li);
} }
}); });
function applyResult(body) { function applyResult(body) {
if (!body.ok) { if (!body.ok) {
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(body.error ?? body.code)}</p>`; setText(resultEl, body.error ?? body.code, true);
return; return;
} }
state.plan.reference = body; state.plan.reference = body;
recompute(); recompute();
const warnings = (body.warnings ?? []).map((w) => `<li>${escapeHtml(w)}</li>`).join(""); setText(
resultEl.innerHTML = ` resultEl,
<p><strong>${escapeHtml(body.roast.title)}</strong> — ${body.roast.roastDate || "no date"} · `${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 ?? "—"}%.`,
first crack ${fmt(body.derived?.firstCrackS)} · development ${fmt(body.derived?.developmentS)} · );
drop ${fmt(body.derived?.dropS)} · DTR ${body.derived?.dtrPct ?? "—"}%</p>
${warnings ? `<ul class="warnings">${warnings}</ul>` : ""}
`;
} }
} }
function fmt(seconds) { function fmt(seconds) {
if (seconds === null || seconds === undefined) return "—"; if (seconds == null) return "—";
const s = Math.round(seconds); const s = Math.round(seconds);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
} }
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
+25
View File
@@ -0,0 +1,25 @@
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", () => {
const setupToken = form.elements.setupToken.value;
submit(setupToken ? "/api/auth/bootstrap" : "/api/auth/signup");
});
+510 -85
View File
@@ -1,6 +1,10 @@
import { FIELD_IDS, blankPlan } from "/shared/fields.js"; import { FIELD_IDS, blankPlan } from "/shared/fields.js";
import { computeLedger } from "/shared/ledger.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 { CULTIVARS, findCultivar } from "/shared/reference-data.js";
import { buildPlanCurve, pointsToPathD, tToX, tempToY } from "/shared/curve.js"; import { buildPlanCurve, pointsToPathD, tToX, tempToY } from "/shared/curve.js";
import { initPrefillPanel } from "./prefill-ui.js"; import { initPrefillPanel } from "./prefill-ui.js";
@@ -8,13 +12,115 @@ import { initAlogPanel } from "./alog-ui.js";
import { initPrint } from "./print.js"; import { initPrint } from "./print.js";
const FIELD_ID_SET = new Set(FIELD_IDS); const FIELD_ID_SET = new Set(FIELD_IDS);
const STORAGE_KEY = "roastPlannerPlan.v1"; const STORAGE_PREFIX = "roastPlannerPlan.v2";
let storageKey = null;
let remotePlanId = null;
let plans = [];
let lastDrawerOpener = null;
let deferredInstallPrompt = 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 BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the Machine Plan section 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"); const form = document.getElementById("plan-form");
const FIELD_HELP = {
5.1: "Optional moisture percentage. Find it on a supplier certificate of analysis (COA) or measure it with a calibrated meter. Leave it blank when unknown: it does not automatically change your roast timing.",
5.2: "Optional green-bean density in g/L. Get it from a supplier COA or measure a known volume. Leave it blank when unknown: it does not automatically change your roast timing.",
5.6: "A documented timing adjustment in m:ss, normally no more than ±0:15. Use only after an observation or comparison roast; moisture and density never create this value automatically.",
1.4: "Your first-crack anchor in m:ss. It is the starting point for the Time Ledger; use a cultivar reference or a previous comparable roast.",
4.3: "Development base in m:ss. Together with the processing and cultivar modifiers it determines development and drop time.",
};
function helpText(input) {
if (FIELD_HELP[input.name]) return FIELD_HELP[input.name];
const unit = input
.closest(".unit-input")
?.querySelector(".unit")?.textContent;
const label =
input
.closest(".field")
?.querySelector(".field-label")
?.textContent?.trim() ||
input.getAttribute("aria-label") ||
input.placeholder ||
"This value";
return `${label} records your plan or roast observation${unit ? ` in ${unit}` : ""}. Use the format shown; leave it blank when you do not know it, then refine it from a supplier record or your next roast.`;
}
function wireFieldHelp(root = document) {
for (const input of root.querySelectorAll(
"input[name], select[name], textarea[name], #prefill-url",
)) {
if (!input.closest("#plan-form") && input.id !== "prefill-url") continue;
const field = input.closest(".field");
if (
input.dataset.helpWired ||
(input.type === "radio" && field?.dataset.radioHelp === input.name)
)
continue;
input.dataset.helpWired = "true";
if (input.type === "radio" && field) field.dataset.radioHelp = input.name;
const id = `field-help-${Math.random().toString(36).slice(2)}`;
const help = document.createElement("span");
help.id = id;
help.className = "field-help-text";
help.hidden = true;
help.textContent = helpText(input);
const button = document.createElement("button");
button.type = "button";
button.className = "field-help";
button.setAttribute("aria-expanded", "false");
button.setAttribute("aria-controls", id);
button.setAttribute("aria-label", `Learn about ${input.name}`);
button.textContent = "?";
button.addEventListener("click", () => {
const open = help.hidden;
help.hidden = !open;
button.setAttribute("aria-expanded", String(open));
});
const milestone = input
.closest(".milestone-row")
?.querySelector(".milestone-label")
?.textContent?.trim();
const fieldLabel = field
?.querySelector(".field-label")
?.textContent?.trim();
input.setAttribute(
"aria-label",
input.getAttribute("aria-label") ||
milestone ||
fieldLabel ||
input.placeholder ||
"Plan value",
);
input.setAttribute(
"aria-describedby",
[input.getAttribute("aria-describedby"), id].filter(Boolean).join(" "),
);
const host = field || input.closest(".unit-input") || input;
let wrapper = host?.parentElement?.classList.contains("field-help-host")
? host.parentElement
: null;
if (!wrapper && host) {
wrapper = document.createElement("div");
wrapper.className = "field-help-host";
host.replaceWith(wrapper);
wrapper.append(host);
}
// Labels cannot contain another interactive control, so the help button is a sibling.
wrapper?.append(button, help);
}
}
// The print worksheet (#print-sheet) sits outside #plan-form on purpose — see index.html — // The print worksheet (#print-sheet) sits outside #plan-form on purpose — see index.html —
// so its radios don't fight the screen form's identically-named radios for exclusivity. // 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. // Every sync pass therefore has to reach both containers explicitly.
@@ -33,7 +139,8 @@ function setPath(obj, path, value) {
const parts = path.split("."); const parts = path.split(".");
let node = obj; let node = obj;
for (let i = 0; i < parts.length - 1; i++) { 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 = node[parts[i]];
} }
node[parts[parts.length - 1]] = value; node[parts[parts.length - 1]] = value;
@@ -59,34 +166,38 @@ function setValueForName(name, value) {
// which is kept in sync by renderFormFromPlan() and only needs to look right on paper. // which is kept in sync by renderFormFromPlan() and only needs to look right on paper.
function renderBlendPrintRows() { function renderBlendPrintRows() {
const tbody = document.getElementById("blend-rows"); const tbody = document.getElementById("blend-rows");
tbody.innerHTML = ""; tbody.replaceChildren();
state.plan.blendComponents.forEach((_, i) => { state.plan.blendComponents.forEach((_, i) => {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.innerHTML = ` 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 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: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><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}.sharePct"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="blendComponents.${i}.fcAnchor"><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); tbody.appendChild(tr);
}); });
} }
function renderBlendCards() { function renderBlendCards() {
const wrap = document.getElementById("blend-cards"); const wrap = document.getElementById("blend-cards");
wrap.innerHTML = ""; wrap.replaceChildren();
state.plan.blendComponents.forEach((_, i) => { state.plan.blendComponents.forEach((_, i) => {
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "blend-card"; card.className = "blend-card";
card.innerHTML = ` 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">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">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">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">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> <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> <button type="button" class="blend-remove" data-remove-blend="${i}" aria-label="Remove component" ${state.plan.blendComponents.length <= 1 ? "disabled" : ""}>✕</button>
`; `),
);
wrap.appendChild(card); wrap.appendChild(card);
}); });
for (const btn of wrap.querySelectorAll("[data-remove-blend]")) { for (const btn of wrap.querySelectorAll("[data-remove-blend]")) {
@@ -102,7 +213,10 @@ function renderBlendCards() {
} }
function updateBlendTotal() { 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 fill = document.getElementById("blend-total-fill");
const label = document.getElementById("blend-total-label"); const label = document.getElementById("blend-total-label");
if (!fill || !label) return; if (!fill || !label) return;
@@ -113,38 +227,44 @@ function updateBlendTotal() {
} }
function updateBlendVisibility(mode) { function updateBlendVisibility(mode) {
document.getElementById("blend-body").classList.toggle("collapsed", mode !== "blend"); document
.getElementById("blend-body")
.classList.toggle("collapsed", mode !== "blend");
} }
function renderBlend() { function renderBlend() {
renderBlendPrintRows(); renderBlendPrintRows();
renderBlendCards(); renderBlendCards();
wireFieldHelp(document.getElementById("blend-cards"));
updateBlendTotal(); updateBlendTotal();
} }
function renderActuatorPrintRows() { function renderActuatorPrintRows() {
const tbody = document.getElementById("actuator-rows"); const tbody = document.getElementById("actuator-rows");
tbody.innerHTML = ""; tbody.replaceChildren();
state.plan.actuators.forEach((_, i) => { state.plan.actuators.forEach((_, i) => {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.innerHTML = ` 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}.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}.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}.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 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> <td><input class="f ws-input" style="min-width:78mm" name="actuators.${i}.why"><span class="pv"></span></td>
`; `),
);
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
} }
function renderActuatorTimeline() { function renderActuatorTimeline() {
const wrap = document.getElementById("actuator-timeline"); const wrap = document.getElementById("actuator-timeline");
wrap.innerHTML = ""; wrap.replaceChildren();
state.plan.actuators.forEach((_, i) => { state.plan.actuators.forEach((_, i) => {
const step = document.createElement("div"); const step = document.createElement("div");
step.className = "actuator-step"; step.className = "actuator-step";
step.innerHTML = ` step.append(
document.createRange().createContextualFragment(`
<div class="actuator-rail"><div class="actuator-dot"></div><div class="actuator-line"></div></div> <div class="actuator-rail"><div class="actuator-dot"></div><div class="actuator-line"></div></div>
<div class="actuator-card"> <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> <label class="field"><span class="field-label">Time</span><input class="field-input sm" name="actuators.${i}.time" placeholder="m:ss"></label>
@@ -154,7 +274,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"><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> <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> </div>
`; `),
);
wrap.appendChild(step); wrap.appendChild(step);
}); });
for (const btn of wrap.querySelectorAll("[data-remove-actuator]")) { for (const btn of wrap.querySelectorAll("[data-remove-actuator]")) {
@@ -172,6 +293,7 @@ function renderActuatorTimeline() {
function renderActuators() { function renderActuators() {
renderActuatorPrintRows(); renderActuatorPrintRows();
renderActuatorTimeline(); renderActuatorTimeline();
wireFieldHelp(document.getElementById("actuator-timeline"));
} }
// ---- populate every control in the form from state.plan // ---- populate every control in the form from state.plan
@@ -189,11 +311,13 @@ export function renderFormFromPlan() {
} }
} }
updateBlendVisibility(state.plan.fields["2.1"]); 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) { 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() { function renderLedger() {
@@ -219,10 +343,22 @@ function renderLedger() {
fmtOut("check-dtr", pct(ledger.checks.dtr.pct)); fmtOut("check-dtr", pct(ledger.checks.dtr.pct));
fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS)); fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS));
const warnings = document.getElementById("ledger-warnings");
warnings.replaceChildren(
...ledger.warnings.map((warning) => {
const item = document.createElement("p");
item.textContent = warning;
return item;
}),
);
warnings.classList.toggle("hidden", ledger.warnings.length === 0);
for (const [key, check] of Object.entries(ledger.checks)) { for (const [key, check] of Object.entries(ledger.checks)) {
for (const cell of document.querySelectorAll(`[data-pass="${key}"]`)) { for (const cell of document.querySelectorAll(`[data-pass="${key}"]`)) {
cell.classList.remove("pass", "fail", "unknown"); 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",
);
} }
} }
@@ -237,8 +373,10 @@ function renderLedger() {
fmtOut("pa-fc", d(ledger.A)); fmtOut("pa-fc", d(ledger.A));
fmtOut("pa-drop", d(ledger.D)); fmtOut("pa-drop", d(ledger.D));
document.getElementById("back-coffee-name").textContent = state.plan.fields["0.1"] || ""; document.getElementById("back-coffee-name").textContent =
document.getElementById("header-coffee-name").textContent = state.plan.fields["0.1"] || "New plan"; state.plan.fields["0.1"] || "";
document.getElementById("header-coffee-name").textContent =
state.plan.fields["0.1"] || "New plan";
return ledger; return ledger;
} }
@@ -279,7 +417,7 @@ function renderBandMarkers() {
} }
function paintCurveInto(planGroup, refGroup, planPoints, ref) { function paintCurveInto(planGroup, refGroup, planPoints, ref) {
planGroup.innerHTML = ""; planGroup.replaceChildren();
if (planPoints.length > 0) { if (planPoints.length > 0) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", pointsToPathD(planPoints)); path.setAttribute("d", pointsToPathD(planPoints));
@@ -289,7 +427,10 @@ function paintCurveInto(planGroup, refGroup, planPoints, ref) {
path.style.color = "var(--ink, #1a1512)"; path.style.color = "var(--ink, #1a1512)";
planGroup.appendChild(path); planGroup.appendChild(path);
for (const p of planPoints) { 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("cx", tToX(p.timeS).toFixed(1));
c.setAttribute("cy", tempToY(p.tempC).toFixed(1)); c.setAttribute("cy", tempToY(p.tempC).toFixed(1));
c.setAttribute("r", "3.2"); c.setAttribute("r", "3.2");
@@ -299,11 +440,14 @@ function paintCurveInto(planGroup, refGroup, planPoints, ref) {
} }
} }
refGroup.innerHTML = ""; refGroup.replaceChildren();
if (ref?.curve?.length) { if (ref?.curve?.length) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const d = ref.curve 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(" "); .join(" ");
path.setAttribute("d", d); path.setAttribute("d", d);
path.setAttribute("fill", "none"); path.setAttribute("fill", "none");
@@ -315,7 +459,10 @@ function paintCurveInto(planGroup, refGroup, planPoints, ref) {
refGroup.appendChild(path); refGroup.appendChild(path);
for (const m of ref.milestones ?? []) { for (const m of ref.milestones ?? []) {
if (m.tempC === null) continue; 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("cx", tToX(m.timeS).toFixed(1));
c.setAttribute("cy", tempToY(m.tempC).toFixed(1)); c.setAttribute("cy", tempToY(m.tempC).toFixed(1));
c.setAttribute("r", "2.6"); c.setAttribute("r", "2.6");
@@ -353,44 +500,119 @@ export function recompute() {
autosave(); autosave();
} }
let autosaveAgeTimer = null; function setSaveStatus(status) {
function autosave() {
const chip = document.getElementById("autosave-status"); const chip = document.getElementById("autosave-status");
const text = chip.querySelector(".autosave-text"); const text = chip.querySelector(".autosave-text");
chip.classList.remove("saved"); chip.classList.remove("saving", "saved", "failed");
chip.classList.add("saving"); chip.classList.add(status);
text.textContent = "Saving…"; text.textContent =
{
saving: "Saving locally…",
saved: "Synced",
failed: "Saved locally — sync failed",
local: "Saved locally — waiting to sync",
}[status] || "Not saved yet";
}
let syncQueue = Promise.resolve();
function syncPlan(snapshot = structuredClone(state.plan)) {
if (!navigator.onLine || !csrfToken()) {
setSaveStatus("local");
return Promise.resolve();
}
syncQueue = syncQueue.then(async () => {
try {
const response = await protectedFetch(
remotePlanId ? `/api/plans/${remotePlanId}` : "/api/plans",
{
method: remotePlanId ? "PUT" : "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ plan: snapshot }),
},
);
if (!response.ok) throw new Error("sync_failed");
const body = await response.json();
remotePlanId = body.plan.id;
if (storageKey)
localStorage.setItem(
storageKey,
JSON.stringify({ plan: snapshot, remotePlanId }),
);
history.replaceState(
null,
"",
`/app?plan=${encodeURIComponent(remotePlanId)}`,
);
setSaveStatus("saved");
await loadPlans();
} catch {
setSaveStatus("failed");
}
});
return syncQueue;
}
async function flushCurrentPlan() {
clearTimeout(autosave._t);
const snapshot = structuredClone(state.plan);
try {
if (storageKey)
localStorage.setItem(
storageKey,
JSON.stringify({ plan: snapshot, remotePlanId }),
);
} catch {
setSaveStatus("failed");
return false;
}
await syncPlan(snapshot);
return true;
}
function autosave() {
setSaveStatus("saving");
clearTimeout(autosave._t); clearTimeout(autosave._t);
autosave._t = setTimeout(() => { autosave._t = setTimeout(() => {
try { flushCurrentPlan();
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.plan));
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); }, 400);
} }
function loadFromStorage() { function loadFromStorage(userId) {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); storageKey = `${STORAGE_PREFIX}:${userId}`;
return raw ? JSON.parse(raw) : null; localStorage.setItem(`${STORAGE_PREFIX}:last-user`, 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 !== `${STORAGE_PREFIX}:last-user`) ||
key === "roastPlannerPlan.v1"
)
localStorage.removeItem(key);
}
const raw = localStorage.getItem(storageKey);
if (!raw) return null;
const draft = JSON.parse(raw);
if (draft?.plan) {
remotePlanId = draft.remotePlanId || null;
return draft.plan;
}
return draft; // legacy v2 draft: retain it once, then upgrade on next save
} catch { } catch {
return null; return null;
} }
} }
function clearDraft() {
if (storageKey) localStorage.removeItem(storageKey);
try {
localStorage.removeItem(`${STORAGE_PREFIX}:last-user`);
} catch {
/* unavailable storage */
}
storageKey = null;
remotePlanId = null;
}
function cultivarAutofill(name) { function cultivarAutofill(name) {
const row = findCultivar(name); const row = findCultivar(name);
if (!row) return; if (!row) return;
@@ -403,7 +625,13 @@ function cultivarAutofill(name) {
function wireCultivarDatalist() { function wireCultivarDatalist() {
const list = document.getElementById("cultivar-list"); const list = document.getElementById("cultivar-list");
list.innerHTML = CULTIVARS.map((c) => `<option value="${c.name}">`).join(""); list.replaceChildren(
...CULTIVARS.map((c) => {
const option = document.createElement("option");
option.value = c.name;
return option;
}),
);
} }
function wireForm() { function wireForm() {
@@ -421,33 +649,118 @@ function wireForm() {
function wireDrawers() { function wireDrawers() {
const overlay = document.getElementById("drawer-overlay"); const overlay = document.getElementById("drawer-overlay");
const prefill = document.getElementById("panel-prefill"); const drawers = [...document.querySelectorAll(".drawer")];
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() { function closeAll() {
for (const p of [prefill, alog]) { for (const panel of drawers) {
panel.classList.add("hidden");
panel.setAttribute("aria-hidden", "true");
}
overlay.classList.add("hidden");
lastDrawerOpener?.focus();
}
function open(panel, opener) {
lastDrawerOpener = opener;
for (const p of drawers) {
p.classList.add("hidden"); p.classList.add("hidden");
p.setAttribute("aria-hidden", "true"); p.setAttribute("aria-hidden", "true");
} }
overlay.classList.add("hidden"); panel.classList.remove("hidden");
panel.setAttribute("aria-hidden", "false");
overlay.classList.remove("hidden");
panel.querySelector("button, input, [href]")?.focus();
} }
for (const [buttonId, panelId] of [
document.getElementById("btn-toggle-prefill").addEventListener("click", () => { ["btn-toggle-prefill", "panel-prefill"],
prefill.classList.contains("hidden") ? open(prefill) : closeAll(); ["btn-toggle-alog", "panel-alog"],
}); ["btn-plans", "panel-plans"],
document.getElementById("btn-toggle-alog").addEventListener("click", () => { ["btn-settings", "panel-settings"],
alog.classList.contains("hidden") ? open(alog) : closeAll(); ])
}); document
.getElementById(buttonId)
.addEventListener("click", (event) =>
open(document.getElementById(panelId), event.currentTarget),
);
overlay.addEventListener("click", closeAll); overlay.addEventListener("click", closeAll);
for (const btn of document.querySelectorAll("[data-close-drawer]")) { for (const btn of document.querySelectorAll("[data-close-drawer]"))
btn.addEventListener("click", closeAll); btn.addEventListener("click", closeAll);
document.addEventListener("keydown", (event) => {
const activeDrawer = drawers.find(
(drawer) => !drawer.classList.contains("hidden"),
);
if (!activeDrawer) return;
if (event.key === "Escape") {
closeAll();
return;
} }
if (event.key !== "Tab") return;
const focusable = [
...activeDrawer.querySelectorAll(
"button:not([disabled]), input:not([disabled]), [href]",
),
];
const first = focusable[0],
last = focusable.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
});
return { open, closeAll };
}
async function loadPlans() {
if (!navigator.onLine) return;
const response = await fetch("/api/plans");
if (!response.ok) return;
plans = (await response.json()).plans || [];
const list = document.getElementById("plan-list");
list.replaceChildren(
...plans.map((plan) => {
const item = document.createElement("li");
const button = document.createElement("button");
button.type = "button";
button.className = "plan-list-item";
button.classList.toggle("active", plan.id === remotePlanId);
const title = document.createElement("strong");
title.textContent = plan.plan?.fields?.["0.1"] || "Untitled plan";
const updated = document.createElement("span");
updated.textContent = `Updated ${new Date(plan.updated_at).toLocaleDateString()}`;
button.append(title, updated);
button.addEventListener("click", () => selectPlan(plan));
item.append(button);
return item;
}),
);
}
async function selectPlan(plan) {
if (!(await flushCurrentPlan())) return;
remotePlanId = plan.id;
state.plan = { ...blankPlan(), ...plan.plan };
history.replaceState(
null,
"",
`/app?plan=${encodeURIComponent(remotePlanId)}`,
);
renderBlend();
renderActuators();
renderFormFromPlan();
recompute();
document.querySelector("[data-close-drawer]")?.click();
}
async function newPlan() {
if (!confirm("Start a new plan? Your current plan is already saved locally."))
return;
if (!(await flushCurrentPlan())) return;
remotePlanId = null;
state.plan = blankPlan();
history.replaceState(null, "", "/app");
renderBlend();
renderActuators();
renderFormFromPlan();
recompute();
} }
function wireToolbar() { function wireToolbar() {
@@ -457,14 +770,20 @@ function wireToolbar() {
}); });
document.getElementById("btn-save").addEventListener("click", () => { 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"); const a = document.createElement("a");
a.href = URL.createObjectURL(blob); a.href = URL.createObjectURL(blob);
a.download = `${(state.plan.fields["0.1"] || "roast-plan").replace(/[^\w-]+/g, "_")}.json`; a.download = `${(state.plan.fields["0.1"] || "roast-plan").replace(/[^\w-]+/g, "_")}.json`;
a.click(); a.click();
URL.revokeObjectURL(a.href); 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) => { document.getElementById("file-load").addEventListener("change", async (e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
@@ -480,14 +799,35 @@ function wireToolbar() {
} }
}); });
document.getElementById("btn-new-plan").addEventListener("click", newPlan);
document.getElementById("btn-add-blend").addEventListener("click", () => { 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(); renderBlend();
renderFormFromPlan(); renderFormFromPlan();
recompute(); recompute();
}); });
document.getElementById("btn-logout").addEventListener("click", async () => {
try {
await protectedFetch("/api/auth/logout", { method: "POST" });
} finally {
clearDraft();
location.replace("/");
}
});
document.getElementById("btn-add-actuator").addEventListener("click", () => { 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(); renderActuators();
renderFormFromPlan(); renderFormFromPlan();
recompute(); recompute();
@@ -499,20 +839,58 @@ function wirePwa() {
const renderConnection = () => { const renderConnection = () => {
const offline = !navigator.onLine; const offline = !navigator.onLine;
status.classList.toggle("hidden", !offline); 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); window.addEventListener("online", () => {
renderConnection();
syncPlan();
});
window.addEventListener("offline", renderConnection); window.addEventListener("offline", renderConnection);
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault();
deferredInstallPrompt = event;
document.getElementById("btn-install").classList.remove("hidden");
});
document.getElementById("btn-install").addEventListener("click", async () => {
if (!deferredInstallPrompt) return;
deferredInstallPrompt.prompt();
await deferredInstallPrompt.userChoice;
deferredInstallPrompt = null;
document.getElementById("btn-install").classList.add("hidden");
});
renderConnection(); renderConnection();
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
window.addEventListener("load", () => { window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch((error) => { navigator.serviceWorker
console.warn("Service worker registration failed:", error); .register("/sw.js")
}); .then((registration) => {
const showUpdate = () =>
document.getElementById("btn-refresh").classList.remove("hidden");
if (registration.waiting) showUpdate();
registration.addEventListener("updatefound", () =>
registration.installing?.addEventListener("statechange", () => {
if (registration.waiting && navigator.serviceWorker.controller)
showUpdate();
}),
);
})
.catch((error) =>
console.warn("Service worker registration failed:", error),
);
}); });
navigator.serviceWorker.addEventListener("controllerchange", () =>
location.reload(),
);
} }
document.getElementById("btn-refresh").addEventListener("click", async () => {
if (!(await flushCurrentPlan())) return;
const registration = await navigator.serviceWorker.getRegistration();
registration?.waiting?.postMessage("SKIP_WAITING");
});
} }
function wireSectionNav() { function wireSectionNav() {
@@ -527,7 +905,11 @@ function wireSectionNav() {
for (const entry of entries) { for (const entry of entries) {
if (!entry.isIntersecting) continue; if (!entry.isIntersecting) continue;
const id = `#${entry.target.id}`; const id = `#${entry.target.id}`;
for (const a of links) a.classList.toggle("active", a.getAttribute("href") === id); for (const a of links) {
const active = a.getAttribute("href") === id;
a.classList.toggle("active", active);
a.toggleAttribute("aria-current", active);
}
} }
}, },
{ rootMargin: "-20% 0px -70% 0px" }, { rootMargin: "-20% 0px -70% 0px" },
@@ -535,10 +917,47 @@ function wireSectionNav() {
for (const s of sections) observer.observe(s); for (const s of sections) observer.observe(s);
} }
function init() { async function init() {
try {
const meResponse = await fetch("/api/auth/me");
if (!meResponse.ok) {
location.replace("/");
return;
}
const { user } = await meResponse.json();
document.getElementById("account-email").textContent = user.email;
document.getElementById("settings-email").textContent = user.email;
document.getElementById("account-summary").textContent =
user.email.split("@")[0];
if (user.role === "admin")
document.getElementById("menu-admin").classList.remove("hidden");
const localDraft = loadFromStorage(user.id);
state.plan = localDraft ?? blankPlan();
await loadPlans();
const requestedId = new URLSearchParams(location.search).get("plan");
const selected = plans.find((plan) => plan.id === requestedId);
if (selected) {
state.plan = selected.plan;
remotePlanId = selected.id;
} else if (!localDraft && !requestedId && plans[0]) {
state.plan = plans[0].plan;
remotePlanId = plans[0].id;
}
} catch {
// The cached app shell contains no user data. A successful online sign-in records the
// last account only until logout, allowing that account's local draft to reopen offline.
try {
const offlineUserId = localStorage.getItem(`${STORAGE_PREFIX}:last-user`);
if (offlineUserId && csrfToken())
state.plan = loadFromStorage(offlineUserId) ?? blankPlan();
} catch {
/* no local draft is available */
}
}
renderBlend(); renderBlend();
renderActuators(); renderActuators();
wireCultivarDatalist(); wireCultivarDatalist();
wireFieldHelp();
renderBandRanges(); renderBandRanges();
renderFormFromPlan(); renderFormFromPlan();
wireForm(); wireForm();
@@ -546,7 +965,13 @@ function init() {
wireToolbar(); wireToolbar();
wirePwa(); wirePwa();
wireSectionNav(); wireSectionNav();
initPrefillPanel({ state, renderBlendRows: renderBlend, renderActuatorRows: renderActuators, renderFormFromPlan, recompute }); initPrefillPanel({
state,
renderBlendRows: renderBlend,
renderActuatorRows: renderActuators,
renderFormFromPlan,
recompute,
});
initAlogPanel({ state, recompute }); initAlogPanel({ state, recompute });
initPrint({ beforePrint: renderFormFromPlan }); initPrint({ beforePrint: renderFormFromPlan });
recompute(); recompute();
+58 -47
View File
@@ -1,89 +1,100 @@
// Wires the "Prefill from URL" panel. Applies the returned field patch only into empty // URL prefill UI; output is always built with DOM nodes so remote text never becomes markup.
// fields by default (checkbox to overwrite), tracks a snapshot for undo, and never touches const csrf = () =>
// the form on any error. 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 }) { export function initPrefillPanel({ state, renderFormFromPlan, recompute }) {
const urlInput = document.getElementById("prefill-url"); const urlInput = document.getElementById("prefill-url"),
const overwriteBox = document.getElementById("prefill-overwrite"); overwriteBox = document.getElementById("prefill-overwrite"),
const goBtn = document.getElementById("prefill-go"); goBtn = document.getElementById("prefill-go"),
const undoBtn = document.getElementById("prefill-undo"); undoBtn = document.getElementById("prefill-undo"),
const resultEl = document.getElementById("prefill-result"); resultEl = document.getElementById("prefill-result");
let snapshot = null; let snapshot = null;
goBtn.addEventListener("click", async () => { goBtn.addEventListener("click", async () => {
const url = urlInput.value.trim(); const url = urlInput.value.trim();
if (!url) return; if (!url) return;
goBtn.disabled = true; goBtn.disabled = true;
resultEl.innerHTML = `<p>Fetching…</p>`; message(resultEl, "Fetching…");
try { try {
const res = await fetch("/api/prefill", { const res = await fetch("/api/prefill", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json", "x-csrf-token": csrf() },
body: JSON.stringify({ url }), body: JSON.stringify({ url }),
}); });
const body = await res.json(); const body = await res.json();
if (!body.ok) { if (!body.ok) {
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(body.error ?? body.code)}</p>`; message(resultEl, body.error ?? body.code, true);
return; return;
} }
snapshot = structuredClone(state.plan);
snapshot = JSON.parse(JSON.stringify(state.plan));
const overwrite = overwriteBox.checked;
let applied = 0; let applied = 0;
for (const [id, value] of Object.entries(body.fields ?? {})) { for (const [id, value] of Object.entries(body.fields ?? {})) {
const current = state.plan.fields[id] ?? ""; if (!overwriteBox.checked && (state.plan.fields[id] ?? "") !== "")
if (!overwrite && current !== "") continue; continue;
state.plan.fields[id] = value; state.plan.fields[id] = value;
applied++; applied++;
} }
renderFormFromPlan(); renderFormFromPlan();
markPrefilled(Object.keys(body.fields ?? {}), body.provenance ?? {}); markPrefilled(Object.keys(body.fields ?? {}), body.provenance ?? {});
recompute(); recompute();
undoBtn.disabled = false; undoBtn.disabled = false;
const p = document.createElement("p"),
const warnings = (body.warnings ?? []).map((w) => `<li>${escapeHtml(w)}</li>`).join(""); link = document.createElement("a");
resultEl.innerHTML = ` link.href = body.source.finalUrl;
<p>Applied ${applied} field${applied === 1 ? "" : "s"} from <a href="${escapeHtml(body.source.finalUrl)}" target="_blank" rel="noopener">${escapeHtml(body.source.finalUrl)}</a>.</p> link.target = "_blank";
${warnings ? `<ul class="warnings">${warnings}</ul>` : ""} 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) { } catch (err) {
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(err.message)}</p>`; message(resultEl, err.message, true);
} finally { } finally {
goBtn.disabled = false; goBtn.disabled = false;
} }
}); });
undoBtn.addEventListener("click", () => { undoBtn.addEventListener("click", () => {
if (!snapshot) return; if (!snapshot) return;
state.plan = snapshot; state.plan = snapshot;
snapshot = null; snapshot = null;
undoBtn.disabled = true; undoBtn.disabled = true;
renderFormFromPlan(); renderFormFromPlan();
clearPrefilledMarks();
recompute();
resultEl.innerHTML = "<p>Prefill undone.</p>";
});
function markPrefilled(ids, provenance) {
for (const id of ids) {
const el = document.querySelector(`[name="${CSS.escape(id)}"]`);
if (!el) continue;
el.classList.add("prefilled");
const prov = provenance[id];
if (prov) el.title = `from: ${prov}`;
}
}
function clearPrefilledMarks() {
for (const el of document.querySelectorAll(".prefilled")) { for (const el of document.querySelectorAll(".prefilled")) {
el.classList.remove("prefilled"); el.classList.remove("prefilled");
el.removeAttribute("title"); 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]}`;
}
}
} }
} }
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
+53
View File
@@ -0,0 +1,53 @@
<!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
><label
>Initial admin setup token (first account only)
<input
class="field-input"
type="password"
name="setupToken"
autocomplete="off" /></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>
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "Roast Planner", "name": "Roast Planner",
"short_name": "Roast Planner", "short_name": "Roast Planner",
"description": "Plan, record, and compare coffee roasts.", "description": "Plan, record, and compare coffee roasts.",
"start_url": "/", "start_url": "/app",
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
"background_color": "#FBF8F4", "background_color": "#FBF8F4",
+45 -13
View File
@@ -1,12 +1,15 @@
const CACHE_NAME = "roast-planner-static-v1"; const CACHE_NAME = "roast-planner-static-v4";
const APP_SHELL = [ const APP_SHELL = [
"/", "/",
"/index.html", "/landing.html",
"/app",
"/app.css", "/app.css",
"/worksheet.css", "/worksheet.css",
"/manifest.webmanifest", "/manifest.webmanifest",
"/icon.svg", "/icon.svg",
"/js/main.js", "/js/main.js",
"/js/landing.js",
"/js/admin.js",
"/js/prefill-ui.js", "/js/prefill-ui.js",
"/js/alog-ui.js", "/js/alog-ui.js",
"/js/print.js", "/js/print.js",
@@ -18,13 +21,22 @@ const APP_SHELL = [
]; ];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))); event.waitUntil(
self.skipWaiting(); caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)),
);
}); });
self.addEventListener("activate", (event) => { self.addEventListener("activate", (event) => {
event.waitUntil( 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(); self.clients.claim();
}); });
@@ -37,22 +49,42 @@ self.addEventListener("fetch", (event) => {
} catch { } catch {
return; 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") { if (request.mode === "navigate") {
event.respondWith(fetch(request).catch(() => caches.match("/index.html"))); // /app is a data-free authenticated shell: draft data lives only in account-scoped
// local storage and logout clears that namespace. This makes offline relaunch useful
// without ever caching account data or API responses.
event.respondWith(
fetch(request).catch(() =>
url.pathname === "/app"
? caches.match("/app")
: caches.match("/landing.html"),
),
);
return; return;
} }
// Prefer fresh JavaScript and styles so installed clients receive UI/security updates
// immediately; use the cache only when offline.
event.respondWith( event.respondWith(
caches.match(request).then((cached) => { fetch(request)
const update = fetch(request)
.then((response) => { .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; return response;
}) })
.catch(() => cached); .catch(() => caches.match(request)),
return cached ?? update;
}),
); );
}); });
self.addEventListener("message", (event) => {
if (event.data === "SKIP_WAITING") self.skipWaiting();
});
+467
View File
@@ -0,0 +1,467 @@
import express from "express";
import path from "node:path";
import crypto from "node:crypto";
import bcrypt from "bcryptjs";
import { fetchPageText } from "./fetch-page.js";
import { runPrefill } from "./prefill.js";
import { parseAlog } from "./alog.js";
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;
/** 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 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("/", async (req, res, next) => {
try {
// Returning users should not be left on the sign-in page after a successful login.
if (await session(req)) return res.status(302).location("/app").end();
res.set("Cache-Control", "no-store, private");
res.sendFile(path.join(root, "public", "landing.html"));
} catch (error) {
next(error);
}
});
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;
}
+55
View File
@@ -0,0 +1,55 @@
import pg from "pg";
import fs from "node:fs/promises";
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() };
}
/** 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;
}
}
}
+9 -74
View File
@@ -1,77 +1,12 @@
import express from "express";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { fetchPageText } from "./fetch-page.js"; import { createDb, migrate } from "./db.js";
import { runPrefill } from "./prefill.js"; import { createApp } from "./app.js";
import { parseAlog } from "./alog.js";
import { listAlogLibrary, readAlogFromLibrary } from "./alog-library.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const ROOT = path.join(__dirname, ".."); const port = Number(process.env.PORT) || 8090;
const PORT = Number(process.env.PORT) || 8090; const db = createDb();
await migrate(db);
const app = express(); createApp({ db, root }).listen(port, () =>
app.use(express.json({ limit: "2mb" })); console.log(`Roast planner listening on ${port}`),
);
app.use(express.static(path.join(ROOT, "public")));
app.use("/shared", express.static(path.join(ROOT, "shared")));
app.post("/api/prefill", 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." });
let page;
try {
page = await fetchPageText(url);
} catch (err) {
const code = err.code ?? "fetch_failed";
const status = code === "fetch_timeout" ? 504 : code === "bad_url" ? 400 : 502;
return res.status(status).json({ ok: false, code, error: err.message });
}
try {
const result = await runPrefill(page);
res.json({ ok: true, ...result });
} catch (err) {
console.error("prefill failed:", err);
const code = err.code ?? "prefill_failed";
const status = code === "no_model" ? 503 : code === "unparseable_model_output" ? 422 : 500;
res.status(status).json({ ok: false, code, error: err.message });
}
});
app.post("/api/alog", (req, res) => {
const content = req.body?.content;
if (typeof content !== "string" || content.trim() === "") {
return res.status(400).json({ ok: false, code: "bad_request", error: "Missing .alog file content." });
}
try {
const result = parseAlog(content, req.body?.filename ?? "upload.alog");
res.json({ ok: true, ...result });
} catch (err) {
console.error("alog parse failed:", err);
res.status(422).json({ ok: false, code: "unparseable_alog", error: err.message });
}
});
app.get("/api/alog/library", async (_req, res) => {
try {
res.json({ ok: true, files: await listAlogLibrary() });
} catch (err) {
res.status(500).json({ ok: false, code: "library_failed", error: err.message });
}
});
app.get("/api/alog/library/:filename", async (req, res) => {
try {
const result = await readAlogFromLibrary(req.params.filename);
res.json({ ok: true, ...result });
} catch (err) {
const status = err.code === "not_found" ? 404 : err.code === "bad_filename" ? 400 : 422;
res.status(status).json({ ok: false, code: err.code ?? "library_read_failed", error: err.message });
}
});
app.listen(PORT, () => {
console.log(`Roast planner webapp listening on http://localhost:${PORT}`);
});
+200
View File
@@ -0,0 +1,200 @@
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";
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\)/);
const serviceWorker = (await anonymous.get("/sw.js")).text;
assert.match(serviceWorker, /data-free authenticated shell/);
assert.match(
serviceWorker,
/url\.pathname === "\/app"[\s\S]*caches\.match\("\/app"\)/,
);
assert.match(serviceWorker, /logout clears that namespace/);
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,
);
});
+89
View File
@@ -0,0 +1,89 @@
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");
});