Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99d7e8f121 | ||
|
|
8f82000e31 | ||
|
|
089ee6916f | ||
|
|
122427d30d | ||
|
|
f56dc8efa9 | ||
|
|
22e6e721a7 | ||
|
|
892479dceb | ||
|
|
432dd2176f |
@@ -5,3 +5,4 @@ node_modules
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
Dockerfile
|
Dockerfile
|
||||||
README.md
|
README.md
|
||||||
|
appdata/
|
||||||
|
|||||||
@@ -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
@@ -1,4 +1,5 @@
|
|||||||
node_modules/
|
node_modules
|
||||||
|
appdata//
|
||||||
data/
|
data/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 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`.
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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:
|
||||||
Generated
+740
-21
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
+1347
-368
File diff suppressed because it is too large
Load Diff
+1637
-399
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
+191
-42
@@ -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,10 +12,22 @@ 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;
|
||||||
|
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");
|
||||||
|
|
||||||
@@ -33,7 +49,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 +76,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 +123,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,7 +137,9 @@ 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() {
|
||||||
@@ -124,27 +150,30 @@ function renderBlend() {
|
|||||||
|
|
||||||
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 +183,8 @@ function renderActuatorTimeline() {
|
|||||||
<label class="field"><span class="field-label">Expected BT</span><input class="field-input sm" name="actuators.${i}.expectedBt"></label>
|
<label class="field"><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]")) {
|
||||||
@@ -189,11 +219,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() {
|
||||||
@@ -222,7 +254,9 @@ function renderLedger() {
|
|||||||
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 +271,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 +315,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 +325,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 +338,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 +357,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");
|
||||||
@@ -364,13 +409,36 @@ function autosave() {
|
|||||||
clearTimeout(autosave._t);
|
clearTimeout(autosave._t);
|
||||||
autosave._t = setTimeout(() => {
|
autosave._t = setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.plan));
|
if (storageKey)
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(state.plan));
|
||||||
|
// Local storage is namespaced per authenticated account; the account copy is authoritative when online.
|
||||||
|
if (navigator.onLine && csrfToken()) {
|
||||||
|
const method = remotePlanId ? "PUT" : "POST";
|
||||||
|
const url = remotePlanId ? `/api/plans/${remotePlanId}` : "/api/plans";
|
||||||
|
protectedFetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ plan: state.plan }),
|
||||||
|
})
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((body) => {
|
||||||
|
if (body?.plan?.id) remotePlanId = body.plan.id;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* local copy remains available */
|
||||||
|
});
|
||||||
|
}
|
||||||
const savedAt = Date.now();
|
const savedAt = Date.now();
|
||||||
chip.classList.remove("saving");
|
chip.classList.remove("saving");
|
||||||
chip.classList.add("saved");
|
chip.classList.add("saved");
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const secs = Math.round((Date.now() - savedAt) / 1000);
|
const secs = Math.round((Date.now() - savedAt) / 1000);
|
||||||
text.textContent = secs < 8 ? "Saved just now" : secs < 60 ? `Saved ${secs}s ago` : `Saved ${Math.round(secs / 60)}m ago`;
|
text.textContent =
|
||||||
|
secs < 8
|
||||||
|
? "Saved just now"
|
||||||
|
: secs < 60
|
||||||
|
? `Saved ${secs}s ago`
|
||||||
|
: `Saved ${Math.round(secs / 60)}m ago`;
|
||||||
};
|
};
|
||||||
tick();
|
tick();
|
||||||
clearInterval(autosaveAgeTimer);
|
clearInterval(autosaveAgeTimer);
|
||||||
@@ -382,15 +450,31 @@ function autosave() {
|
|||||||
}, 400);
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadFromStorage() {
|
function loadFromStorage(userId) {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
storageKey = `${STORAGE_PREFIX}:${userId}`;
|
||||||
|
// A shared browser must never retain a previous account's local-only draft.
|
||||||
|
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (
|
||||||
|
(key?.startsWith(`${STORAGE_PREFIX}:`) && key !== storageKey) ||
|
||||||
|
key === "roastPlannerPlan.v1"
|
||||||
|
)
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
const raw = localStorage.getItem(storageKey);
|
||||||
return raw ? JSON.parse(raw) : null;
|
return raw ? JSON.parse(raw) : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearDraft() {
|
||||||
|
if (storageKey) localStorage.removeItem(storageKey);
|
||||||
|
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 +487,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() {
|
||||||
@@ -438,7 +528,9 @@ function wireDrawers() {
|
|||||||
overlay.classList.add("hidden");
|
overlay.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("btn-toggle-prefill").addEventListener("click", () => {
|
document
|
||||||
|
.getElementById("btn-toggle-prefill")
|
||||||
|
.addEventListener("click", () => {
|
||||||
prefill.classList.contains("hidden") ? open(prefill) : closeAll();
|
prefill.classList.contains("hidden") ? open(prefill) : closeAll();
|
||||||
});
|
});
|
||||||
document.getElementById("btn-toggle-alog").addEventListener("click", () => {
|
document.getElementById("btn-toggle-alog").addEventListener("click", () => {
|
||||||
@@ -457,14 +549,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;
|
||||||
@@ -481,13 +579,33 @@ function wireToolbar() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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,7 +617,9 @@ 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);
|
||||||
@@ -527,7 +647,8 @@ 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)
|
||||||
|
a.classList.toggle("active", a.getAttribute("href") === id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ rootMargin: "-20% 0px -70% 0px" },
|
{ rootMargin: "-20% 0px -70% 0px" },
|
||||||
@@ -535,7 +656,29 @@ 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();
|
||||||
|
if (user.role === "admin")
|
||||||
|
document.getElementById("menu-admin").classList.remove("hidden");
|
||||||
|
state.plan = loadFromStorage(user.id) ?? blankPlan();
|
||||||
|
const response = await fetch("/api/plans");
|
||||||
|
if (response.ok) {
|
||||||
|
const body = await response.json();
|
||||||
|
const latest = body.plans?.[0];
|
||||||
|
if (latest) {
|
||||||
|
state.plan = latest.plan;
|
||||||
|
remotePlanId = latest.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// An authenticated user can still use their own namespaced local draft offline.
|
||||||
|
}
|
||||||
renderBlend();
|
renderBlend();
|
||||||
renderActuators();
|
renderActuators();
|
||||||
wireCultivarDatalist();
|
wireCultivarDatalist();
|
||||||
@@ -546,7 +689,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
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
+34
-12
@@ -1,12 +1,14 @@
|
|||||||
const CACHE_NAME = "roast-planner-static-v1";
|
const CACHE_NAME = "roast-planner-static-v3";
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
"/",
|
"/",
|
||||||
"/index.html",
|
"/landing.html",
|
||||||
"/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 +20,23 @@ 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(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)),
|
||||||
|
);
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
||||||
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,32 @@ 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")));
|
// Never serve authenticated application HTML from cache after logout.
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request).catch(() => 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;
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
+467
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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}`);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
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\)/);
|
||||||
|
assert.match(
|
||||||
|
(await anonymous.get("/sw.js")).text,
|
||||||
|
/Never serve authenticated/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const one = await signup(first, "[email protected]");
|
||||||
|
const two = await signup(second, "[email protected]");
|
||||||
|
assert.equal(one.response.status, 201);
|
||||||
|
assert.equal(two.response.status, 201);
|
||||||
|
const plan = await first
|
||||||
|
.post("/api/plans")
|
||||||
|
.set("x-csrf-token", one.csrf)
|
||||||
|
.send({ plan: { fields: { 0.1: "Private" } } });
|
||||||
|
assert.equal(plan.status, 201);
|
||||||
|
assert.equal(
|
||||||
|
(await first.get("/api/plans")).headers["cache-control"],
|
||||||
|
"no-store, private",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await second
|
||||||
|
.put(`/api/plans/${plan.body.plan.id}`)
|
||||||
|
.set("x-csrf-token", two.csrf)
|
||||||
|
.send({ plan: {} })
|
||||||
|
).status,
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
assert.equal((await second.get("/api/plans")).body.plans.length, 0);
|
||||||
|
|
||||||
|
const login = request.agent(app);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await login
|
||||||
|
.post("/api/auth/login")
|
||||||
|
.send({ email: "[email protected]", password })
|
||||||
|
).status,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
assert.equal((await login.get("/api/auth/me")).status, 200);
|
||||||
|
const loginCsrf = (
|
||||||
|
await login
|
||||||
|
.post("/api/auth/login")
|
||||||
|
.send({ email: "[email protected]", password })
|
||||||
|
).body.csrfToken;
|
||||||
|
assert.equal(
|
||||||
|
(await login.post("/api/auth/logout").set("x-csrf-token", loginCsrf))
|
||||||
|
.status,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
assert.equal((await login.get("/api/auth/me")).status, 401);
|
||||||
|
|
||||||
|
const admin = await first.post("/api/auth/bootstrap").send({
|
||||||
|
email: "[email protected]",
|
||||||
|
password,
|
||||||
|
setupToken: "a-secure-bootstrap-token",
|
||||||
|
});
|
||||||
|
assert.equal(admin.status, 201);
|
||||||
|
assert.equal((await first.get("/api/admin/users")).status, 200);
|
||||||
|
assert.equal((await second.get("/api/admin/users")).status, 403);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await first
|
||||||
|
.put("/api/admin/signup-enabled")
|
||||||
|
.set("x-csrf-token", admin.body.csrfToken)
|
||||||
|
.send({ enabled: false })
|
||||||
|
).status,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await anonymous
|
||||||
|
.post("/api/auth/signup")
|
||||||
|
.send({ email: "[email protected]", password })
|
||||||
|
).status,
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await db.query("SELECT count(*)::int AS count FROM users")).rows[0].count,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bootstrap token is optional after first setup and unavailable before setup without one", async () => {
|
||||||
|
const { app, db } = await setup();
|
||||||
|
const agent = request.agent(app);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await agent.post("/api/auth/bootstrap").send({
|
||||||
|
email: "[email protected]",
|
||||||
|
password,
|
||||||
|
setupToken: "wrong",
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
const noTokenApp = createApp({ db, root, env: { NODE_ENV: "test" } });
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await request(noTokenApp).post("/api/auth/bootstrap").send({
|
||||||
|
email: "[email protected]",
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
503,
|
||||||
|
);
|
||||||
|
await db.query(
|
||||||
|
"INSERT INTO users(email,password_hash,role) VALUES($1,$2,'admin')",
|
||||||
|
["[email protected]", "not-used-in-this-test"],
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await request(noTokenApp).post("/api/auth/bootstrap").send({
|
||||||
|
email: "[email protected]",
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,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");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user