Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a657572bee | ||
|
|
0dcc221a1d |
@@ -214,7 +214,7 @@ sudo docker exec -it redefined-designs-db-syn psql -U redefined -d redefined -c
|
||||
## Conventions
|
||||
|
||||
- **Never commit directly to `main`.** Always branch, open a PR, merge; the branch auto-deletes (repo setting is on).
|
||||
- **Branches follow [Conventional Branch](https://conventional-branch.github.io/), with the issue number carried for Gitea:** `<type>/<issue-number>-<short-slug>`, e.g. `feature/48-my-account-modal`, `bugfix/57-cart-total-wrong`. Types are `feature`, `bugfix`, `hotfix`, `release`, `chore` — the same `feature/` prefix this repo has always used, so nothing in the existing history is wrong. **Every branch and every PR has an issue behind it — no exceptions.** When work arrives through conversation rather than the tracker, file the issue first and branch from it; do not start a branch meaning to retrofit an issue later. The escape hatch that used to sit here turned "no issue yet" into "no issue ever", and two branches went that way before it was removed (#70 and #76). The type should agree with the Conventional Commit type of the work it carries.
|
||||
- **Branches follow [Conventional Branch](https://conventional-branch.github.io/), with the issue number carried for Gitea:** `<type>/<issue-number>-<short-slug>`, e.g. `feature/48-my-account-modal`, `bugfix/57-cart-total-wrong`. Types are `feature`, `bugfix`, `hotfix`, `release`, `chore` — the same `feature/` prefix this repo has always used, so nothing in the existing history is wrong. Drop the number when there is no issue behind the work (`chore/tidy-dead-routes`). The type should agree with the Conventional Commit type of the work it carries.
|
||||
- **Commits follow [Conventional Commits](https://www.conventionalcommits.org/)** — `feat:`, `fix:`, `chore:`, `docs:`, `test:`, `ci:`, `refactor:` — with the issue number appended to the subject: `feat(account): open My Account as a modal (#48)`.
|
||||
- **Put `Closes #48` in the commit body**, on its own line, for the commit that completes the issue (`Refs #48` when it only contributes). This is what actually closes the issue on merge, independently of whether the PR description repeats it.
|
||||
- **Be clear about which part does the linking.** Gitea creates the reference from a `#48` appearing in a *commit message or PR* — never from the branch name. The branch name is for humans reading `git branch`; the reference is what ties the work to the issue. Both are wanted, but only one of them links.
|
||||
@@ -251,10 +251,6 @@ Take a green SonarQube job as weak evidence. It exits success on a partially-fai
|
||||
- **Unit tests**: `cd backend && npm run test:unit` — no DB required
|
||||
- **Integration tests**: `npm run db:test:up` (disposable tmpfs Postgres via `docker-compose.test.yml`) → `npm run migrate:up` → `npm run test:integration` → `npm run db:test:down`
|
||||
- **E2e (Playwright)**: needs backend running against a migrated DB; `cd frontend && npm run test:e2e`
|
||||
- **Coverage**: `npm run test:unit:cov` and `npm run test:integration:cov` in `backend` write `coverage/unit/lcov.info` and `coverage/integration/lcov.info` — separate directories because jest writes `coverage/lcov.info` by default and the second run would otherwise overwrite the first. Frontend coverage is `npm run test:e2e:cov` then `npm run coverage:report`. Added in #61.
|
||||
- **Frontend coverage comes from Playwright through an istanbul-instrumented dev server**, so read it with suspicion: istanbul marks a line covered when the browser ran it, meaning a component rendered during an end-to-end test reports as covered with nothing asserting anything about it. Backend coverage, coming from tests that assert on responses, means considerably more per percentage point. The 80% gate on new code is correspondingly easier to clear on frontend changes.
|
||||
- **Instrumentation is gated behind `COVERAGE=true`** and must stay that way — an instrumented bundle is larger, slower, and publishes the source structure through `window.__coverage__`. The Dockerfile runs a plain `npm run build`, which never sets it. `vite-plugin-istanbul` is also loaded by dynamic import because it is ESM-only while `vite.config.ts` evaluates as CommonJS; a static import fails the build outright.
|
||||
- **`npm run coverage:report` fails when nothing was collected** rather than writing an empty report. If Playwright reuses an uninstrumented dev server every test still passes while gathering nothing, and the resulting 0% reads as "the tests stopped covering things" rather than "collection was never switched on".
|
||||
- CI (`tests.yml`) runs all three as separate jobs, each posting a pass/fail summary to Gitea's job Summary tab. The `frontend-e2e` job runs `node migrate.js up` against a Postgres service container — same migration mechanism as everywhere else, no schema duplication anywhere in the project anymore.
|
||||
|
||||
### The local Node version will not run the integration or e2e suites
|
||||
@@ -270,7 +266,7 @@ Neither failure mentions the Node version as the cause, and the first one reads
|
||||
export PATH="/c/Users/tlamb/AppData/Local/nvm/v24.13.1:$PATH"
|
||||
```
|
||||
|
||||
Thom is fine with the scripts switching the active version for a test run: they use the pinned `NODE_VERSION` (26.7.0) in `scripts/NodeVersion.ps1` and restore 18.16.1 when finished, including on failure. Do not run those scripts from an agent shell — they prompt for elevation and can leave the machine with no Node at all.
|
||||
Thom is fine with switching the active version for a test run — `nvm use latest`, then **`nvm use 18.16.1` when finished**, which is not optional since the app's own tooling expects 18.
|
||||
|
||||
Unit tests and `tsc` run fine on 18, so a green `npm test` says nothing about whether the other two suites can even start.
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Clean up old workflow runs
|
||||
|
||||
# Manual only, and dry run by default (#324).
|
||||
#
|
||||
# Gitea 1.27.3 expires a run's logs and artifacts but never the run record
|
||||
# itself, so the Actions list grows without limit and fills with entries whose
|
||||
# logs are already gone. This removes those entries.
|
||||
#
|
||||
# Deliberately not on a schedule. Deleting a run cannot be undone, the list is
|
||||
# an annoyance rather than a problem, and a cron that quietly removes history
|
||||
# should be a decision taken on its own rather than the default that arrives
|
||||
# with the tool.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
keep_days:
|
||||
description: 'Keep runs newer than this many days'
|
||||
required: false
|
||||
default: '7'
|
||||
apply:
|
||||
description: 'Type true to actually delete. Anything else reports only.'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
# No npm install: the script uses only node's own https module, so there
|
||||
# is nothing to fetch and nothing that can break when a dependency moves.
|
||||
- name: Delete old runs
|
||||
env:
|
||||
# A dedicated secret rather than the automatic per-job token, because
|
||||
# deleting a run may be beyond what that token is allowed to do. If it
|
||||
# turns out to be sufficient, this and the secret can both go.
|
||||
GITEA_ACCESS_TOKEN: ${{ secrets.ACTIONS_CLEANUP_TOKEN }}
|
||||
# Taken from the run's own context so this file carries no hostname
|
||||
# and works unchanged if the instance ever moves — which #313 may yet
|
||||
# make happen.
|
||||
GITEA_HOST: ${{ github.server_url }}
|
||||
GITEA_REPO: ${{ github.repository }}
|
||||
KEEP_DAYS: ${{ github.event.inputs.keep_days }}
|
||||
APPLY: ${{ github.event.inputs.apply }}
|
||||
run: node scripts/cleanup-workflow-runs.js
|
||||
@@ -1,46 +0,0 @@
|
||||
name: Linting
|
||||
|
||||
# Split out of tests.yml so a lint failure is legible on its own: it is the
|
||||
# fastest check in the pipeline and the one most often broken, and it used to be
|
||||
# reported as one job among the suites rather than as its own result.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# Fails only on the rules with real defect-catching value — unhandled
|
||||
# promises, hook dependencies, missing alt text. Everything else is a warning
|
||||
# and does not block, which is why no --max-warnings flag appears here:
|
||||
# ESLint exits non-zero on errors and zero on warnings on its own. The split,
|
||||
# and the measurements behind it, are in
|
||||
# docs/superpowers/specs/2026-08-19-eslint-design.md.
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install backend deps
|
||||
run: npm install
|
||||
working-directory: backend
|
||||
|
||||
- name: Lint backend
|
||||
run: npm run lint
|
||||
working-directory: backend
|
||||
|
||||
- name: Install frontend deps
|
||||
run: npm install
|
||||
working-directory: frontend
|
||||
|
||||
- name: Lint frontend
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
@@ -1,17 +1,5 @@
|
||||
name: SonarQube Analysis
|
||||
|
||||
# The only workflow that runs the test suites. tests.yml used to run the unit
|
||||
# and end-to-end suites as well, on identical triggers, so every pull request
|
||||
# installed, migrated, built, started the backend and ran the whole end-to-end
|
||||
# suite twice. With one runner the second copy did not run in parallel, it
|
||||
# queued. It was deleted and its two summarize steps folded in here. See #123.
|
||||
#
|
||||
# Note that the integration suite DOES run here, on every push and pull request,
|
||||
# despite backend-integration.yml describing it as manual. That quarantine only
|
||||
# ever applied to tests.yml. It is survivable here because test:integration:cov
|
||||
# passes --forceExit, which papers over the post-run hang, and because of the
|
||||
# timeout below.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -22,55 +10,6 @@ on:
|
||||
jobs:
|
||||
sonarqube:
|
||||
runs-on: ubuntu-latest
|
||||
# The scanner needs every coverage report in one workspace, so the suites run
|
||||
# here rather than being passed between jobs as artifacts. That makes this the
|
||||
# long job. The timeout is a stop, not a budget: the integration suite has
|
||||
# hung after completing before (see backend-integration.yml) and cost three
|
||||
# hours of runner time.
|
||||
timeout-minutes: 30
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: redefined_test
|
||||
POSTGRES_PASSWORD: redefined_test
|
||||
POSTGRES_DB: redefined_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U redefined_test"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
env:
|
||||
# Consumed by the integration suite's setup files.
|
||||
TEST_PGHOST: postgres
|
||||
TEST_PGPORT: 5432
|
||||
TEST_PGUSER: redefined_test
|
||||
TEST_PGPASSWORD: redefined_test
|
||||
TEST_PGDATABASE: redefined_test
|
||||
# Consumed by the backend process the end-to-end run drives.
|
||||
PGHOST: postgres
|
||||
PGPORT: 5432
|
||||
PGUSER: redefined_test
|
||||
PGPASSWORD: redefined_test
|
||||
PGDATABASE: redefined_test
|
||||
PORT: 3000
|
||||
DEMO_MODE: 'true'
|
||||
UPLOADS_DIR: /tmp/redefined-uploads
|
||||
# Set so the background-removal controls render at all: both the
|
||||
# submission page's checkbox and the review queue's per-photo button are
|
||||
# hidden unless the server reports the feature as configured, and three
|
||||
# end-to-end tests assert they are there (#287).
|
||||
#
|
||||
# Deliberately a URL that does not resolve. Nothing in the suite reaches
|
||||
# the sidecar — the worker only cuts a background out after a draft is
|
||||
# written, and drafting needs an ANTHROPIC_API_KEY this job does not have.
|
||||
# A real rembg here would mean a 4.24 GB image and forty seconds of
|
||||
# startup to prove a control is on screen.
|
||||
REMBG_URL: http://127.0.0.1:7000
|
||||
# NODE_ENV is deliberately unset: `npm install` omits devDependencies when
|
||||
# NODE_ENV=production, which strips tsc/vite/@playwright/test and breaks the
|
||||
# build. It would also flip the session cookie to Secure, which the e2e run
|
||||
# serves over plain http.
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -98,211 +37,19 @@ jobs:
|
||||
run: npm run build
|
||||
working-directory: frontend
|
||||
|
||||
# The frontend's build was the only thing this workspace ran, so the unit
|
||||
# suite #188 added over the filter dimensions was run by nothing but the
|
||||
# author's terminal. A suite CI never runs decays into a record of what
|
||||
# the code used to do, and its value is highest exactly here: chips() is
|
||||
# pure, and the end-to-end run reaches it only through a browser.
|
||||
#
|
||||
# Guarded and named in the gate like every suite: a failing test should
|
||||
# fail the job at the end, not abort it and take the scan with it.
|
||||
- name: Frontend unit tests
|
||||
id: frontend_unit
|
||||
continue-on-error: true
|
||||
run: npm run test:unit
|
||||
working-directory: frontend
|
||||
|
||||
# frontend/tsconfig.sonar.json is a standalone copy that cannot use
|
||||
# `extends`, so it can drift. Drift does not fail the scan — it silently
|
||||
# returns to skipping the frontend while still reporting success, which is
|
||||
# the failure #67 was about. Checked before scanning, so the scan is never
|
||||
# the thing that discovers it.
|
||||
- name: Check the Sonar tsconfig has not drifted
|
||||
run: node scripts/check-sonar-tsconfig.js
|
||||
|
||||
- name: Run migrations
|
||||
run: node migrate.js up
|
||||
working-directory: backend
|
||||
|
||||
# --json/--outputFile appended rather than baked into the script: the
|
||||
# coverage run and the results file are wanted together here, and nowhere
|
||||
# else. summarize-jest.js below reads that file.
|
||||
- name: Backend unit tests with coverage
|
||||
id: unit
|
||||
continue-on-error: true
|
||||
run: npm run test:unit:cov -- --json --outputFile=unit-results.json
|
||||
working-directory: backend
|
||||
|
||||
# Covers everything in src/routes, which the unit suite does not touch —
|
||||
# without this the backend reports around 11% rather than the ~73% it
|
||||
# actually has.
|
||||
#
|
||||
# Guarded like the other two suites. It was the only one that was not, so
|
||||
# it was the only one whose failure aborted the job — taking the scan, the
|
||||
# end-to-end run and the coverage merge with it. That is what #154 has
|
||||
# cost on every push since it started: not a degraded analysis, none at
|
||||
# all. See #174.
|
||||
- name: Backend integration tests with coverage
|
||||
id: integration
|
||||
continue-on-error: true
|
||||
run: npm run test:integration:cov -- --json --outputFile=integration-results.json
|
||||
working-directory: backend
|
||||
|
||||
# Guarded because the scan does not depend on it. A backend that will not
|
||||
# start fails the end-to-end run below on its own, and the gate records
|
||||
# both — there is no reason for it to also cost the analysis.
|
||||
- name: Start backend for the end-to-end run
|
||||
id: backend
|
||||
continue-on-error: true
|
||||
run: |
|
||||
mkdir -p /tmp/redefined-uploads
|
||||
node dist/server.js > /tmp/backend.log 2>&1 &
|
||||
for i in $(seq 1 30); do
|
||||
if node -e "require('http').get('http://localhost:3000/api/config', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"; then
|
||||
echo "Backend ready after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Backend did not become ready within 30s:"
|
||||
cat /tmp/backend.log
|
||||
exit 1
|
||||
working-directory: backend
|
||||
|
||||
# A network fetch, and so the least interesting way to lose an analysis.
|
||||
- name: Install Playwright browsers
|
||||
id: browsers
|
||||
continue-on-error: true
|
||||
run: npx playwright install --with-deps chromium
|
||||
working-directory: frontend
|
||||
|
||||
# Runs against an istanbul-instrumented dev server, which is what produces
|
||||
# window.__coverage__ for the fixture to collect.
|
||||
- name: Frontend end-to-end tests with coverage
|
||||
id: e2e
|
||||
continue-on-error: true
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME: playwright-results.json
|
||||
# list as well as json, so the log still shows which test failed rather
|
||||
# than only a file nobody reads until the summary step.
|
||||
run: npm run test:e2e:cov -- --reporter=list,json
|
||||
working-directory: frontend
|
||||
|
||||
# Keyed off the step outcome rather than failure(). continue-on-error above
|
||||
# means the job is not in a failed state at this point, so failure() would
|
||||
# never fire and the log that explains an end-to-end failure would go
|
||||
# unprinted precisely when it is wanted.
|
||||
- name: Backend log
|
||||
if: steps.e2e.outcome == 'failure' || steps.backend.outcome == 'failure'
|
||||
run: cat /tmp/backend.log
|
||||
|
||||
# Fails when nothing was collected rather than writing an empty report. An
|
||||
# uninstrumented dev server lets every test pass while gathering nothing,
|
||||
# and the resulting 0% reads as "the tests stopped covering things".
|
||||
- name: Merge frontend coverage
|
||||
id: coverage
|
||||
continue-on-error: true
|
||||
run: npm run coverage:report
|
||||
working-directory: frontend
|
||||
|
||||
# Guarded so that a scanner error does not take the summaries below with
|
||||
# it. The gate still records it, so a failed scan fails the job.
|
||||
#
|
||||
# If a coverage report is missing — the end-to-end run collecting nothing,
|
||||
# say — this scans anyway and SonarQube reports those files as uncovered,
|
||||
# which reads as a regression rather than as a missing input. Accepted:
|
||||
# jest still writes coverage for a suite whose tests fail, so the failure
|
||||
# actually occurring produces all three reports; there is no
|
||||
# sonar.qualitygate.wait, so a degraded run marks the dashboard and is
|
||||
# overwritten by the next good one rather than blocking anything; and the
|
||||
# job fails regardless, so no run in this state reads as clean.
|
||||
# Not on pull requests. SonarQube Community has no branch analysis: every
|
||||
# scan published under a project key replaces that project's single
|
||||
# analysis, whatever revision it came from. So a pull request scan
|
||||
# overwrote the dashboard's picture of main with the branch, silently, and
|
||||
# the new-code period, gate result, coverage and hotspot list then all
|
||||
# described whatever was scanned last with nothing saying which revision
|
||||
# that was. #197 caught it in the act — the dashboard describing a feature
|
||||
# branch while reporting hotspot line numbers that landed on a blank line
|
||||
# in main.
|
||||
#
|
||||
# scripts/scan-local.sh already refuses to do this, defaulting to a
|
||||
# scratch key for exactly this reason. CI walked into the hazard that
|
||||
# script guards against; now the two tell the same story.
|
||||
#
|
||||
# The suites above still run on pull requests, which is where their value
|
||||
# is. Only publishing is restricted.
|
||||
# Scan settings live in sonar-project.properties at the repo root, so a
|
||||
# local scan and this one analyse the same thing. Only the host and token
|
||||
# come from secrets.
|
||||
- name: SonarQube Scan
|
||||
id: scan
|
||||
if: github.event_name != 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: sonarsource/sonarqube-scan-action@v4
|
||||
env:
|
||||
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
# Readable pass and fail counts, which the raw jest and Playwright output
|
||||
# does not give at a glance. Both run with if: always() so a failing suite
|
||||
# is still summarised — which is the case they exist for.
|
||||
- name: Summarize unit tests
|
||||
if: always()
|
||||
run: node scripts/summarize-jest.js backend/unit-results.json "Backend Unit Test"
|
||||
|
||||
# The suite this workflow has been failing on for weeks, and the only one
|
||||
# that had no summary — so it presented as 36 assertion errors about
|
||||
# categories and price filters rather than as a count. #154 records how
|
||||
# expensive that misdirection was to read.
|
||||
- name: Summarize integration tests
|
||||
if: always()
|
||||
run: node scripts/summarize-jest.js backend/integration-results.json "Backend Integration Test"
|
||||
|
||||
- name: Summarize end-to-end tests
|
||||
if: always()
|
||||
run: node scripts/summarize-playwright.js frontend/playwright-results.json
|
||||
|
||||
# The measures, printed into the log because that is the only place this
|
||||
# project can read them. SonarQube 9.9 Community has no Bearer auth, so the
|
||||
# official MCP cannot connect, and the host is a CI secret — so security
|
||||
# hotspots, duplication, debt and coverage lived solely on a dashboard, and
|
||||
# "reduce the debt" was an instruction nobody could act on without opening a
|
||||
# browser. The scanner masks the URL and token; the measures are not secret.
|
||||
#
|
||||
# Deliberately not guarded with continue-on-error. The script exits 0 on
|
||||
# every path, so it cannot fail the job anyway, and guarding it would
|
||||
# oblige it to appear in the gate below — which exists to fail the job,
|
||||
# the opposite of what a report should do. See #261.
|
||||
# Skipped alongside the scan on pull requests. With nothing published, this
|
||||
# would report main's numbers under a pull request's log, which is noise
|
||||
# at best and misread as the branch's own at worst.
|
||||
- name: Report SonarQube measures
|
||||
if: always() && github.event_name != 'pull_request'
|
||||
env:
|
||||
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
run: node scripts/summarize-sonar.js
|
||||
|
||||
# Last, so a failing step still produces coverage, a scan and every
|
||||
# summary first. Without this step continue-on-error above would turn a
|
||||
# failing suite into a passing job, which is the one way this change could
|
||||
# do real damage.
|
||||
#
|
||||
# Every guarded step is listed. That is the invariant — a step carrying
|
||||
# continue-on-error and missing from here cannot fail the job at all — and
|
||||
# tests/unit/workflowGate.test.ts asserts it, because the integration
|
||||
# suite going unlisted is exactly how #174 happened.
|
||||
#
|
||||
# always() is load-bearing. A step whose `if:` omits it still implicitly
|
||||
# requires every previous step to have succeeded, so this was skipped in
|
||||
# exactly the case it exists for: the summarise step above used to exit 1
|
||||
# on a failing suite, which failed the job first and left this gate as dead
|
||||
# code. The job then reported its failure under a name that describes
|
||||
# summarising rather than testing. The summarisers exit 0 now; this is what
|
||||
# fails the run. See #142.
|
||||
- name: Fail if any guarded step failed
|
||||
if: >-
|
||||
always() && (
|
||||
steps.frontend_unit.outcome == 'failure' ||
|
||||
steps.unit.outcome == 'failure' ||
|
||||
steps.integration.outcome == 'failure' ||
|
||||
steps.backend.outcome == 'failure' ||
|
||||
steps.browsers.outcome == 'failure' ||
|
||||
steps.e2e.outcome == 'failure' ||
|
||||
steps.coverage.outcome == 'failure' ||
|
||||
steps.scan.outcome == 'failure'
|
||||
)
|
||||
run: exit 1
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
name: Tests
|
||||
|
||||
# backend-integration lives in its own manual workflow
|
||||
# (.gitea/workflows/backend-integration.yml) rather than running here. It held
|
||||
# the runner for 3h12m on 2026-08-18 — 87 seconds of tests followed by a hang
|
||||
# after the run completed — and blocked frontend-e2e behind it for the same
|
||||
# three hours. Run it from Actions before merging anything that touches the API
|
||||
# or the database.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# Fails only on the rules with real defect-catching value — unhandled
|
||||
# promises, hook dependencies, missing alt text. Everything else is a warning
|
||||
# and does not block, which is why no --max-warnings flag appears here:
|
||||
# ESLint exits non-zero on errors and zero on warnings on its own. The split,
|
||||
# and the measurements behind it, are in
|
||||
# docs/superpowers/specs/2026-08-19-eslint-design.md.
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install backend deps
|
||||
run: npm install
|
||||
working-directory: backend
|
||||
|
||||
- name: Lint backend
|
||||
run: npm run lint
|
||||
working-directory: backend
|
||||
|
||||
- name: Install frontend deps
|
||||
run: npm install
|
||||
working-directory: frontend
|
||||
|
||||
- name: Lint frontend
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
|
||||
backend-unit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install deps
|
||||
run: npm install
|
||||
working-directory: backend
|
||||
|
||||
- name: Run unit tests
|
||||
id: unit
|
||||
continue-on-error: true
|
||||
run: npm run test:unit:json
|
||||
working-directory: backend
|
||||
|
||||
- name: Summarize
|
||||
if: always()
|
||||
run: node scripts/summarize-jest.js backend/unit-results.json "Backend Unit Test"
|
||||
|
||||
- name: Fail job if tests failed
|
||||
if: steps.unit.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
||||
frontend-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: redefined_test
|
||||
POSTGRES_PASSWORD: redefined_test
|
||||
POSTGRES_DB: redefined_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U redefined_test"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
env:
|
||||
PGHOST: postgres
|
||||
PGPORT: 5432
|
||||
PGUSER: redefined_test
|
||||
PGPASSWORD: redefined_test
|
||||
PGDATABASE: redefined_test
|
||||
PORT: 3000
|
||||
DEMO_MODE: 'true'
|
||||
UPLOADS_DIR: /tmp/redefined-uploads
|
||||
# NODE_ENV is deliberately unset: `npm install` omits devDependencies when
|
||||
# NODE_ENV=production, which strips tsc/vite/@playwright/test and breaks the
|
||||
# build. It would also flip the session cookie to Secure, which the e2e run
|
||||
# serves over plain http.
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install backend deps
|
||||
run: npm install
|
||||
working-directory: backend
|
||||
|
||||
- name: Run migrations
|
||||
run: node migrate.js up
|
||||
working-directory: backend
|
||||
|
||||
- name: Build backend
|
||||
run: npm run build
|
||||
working-directory: backend
|
||||
|
||||
- name: Start backend
|
||||
run: |
|
||||
mkdir -p /tmp/redefined-uploads
|
||||
node dist/server.js > /tmp/backend.log 2>&1 &
|
||||
for i in $(seq 1 30); do
|
||||
if node -e "require('http').get('http://localhost:3000/api/config', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"; then
|
||||
echo "Backend ready after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Backend did not become ready within 30s:"
|
||||
cat /tmp/backend.log
|
||||
exit 1
|
||||
working-directory: backend
|
||||
|
||||
- name: Install frontend deps
|
||||
run: npm install
|
||||
working-directory: frontend
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
working-directory: frontend
|
||||
|
||||
- name: Run Playwright tests
|
||||
id: e2e
|
||||
continue-on-error: true
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME: playwright-results.json
|
||||
run: npx playwright test --reporter=json
|
||||
working-directory: frontend
|
||||
|
||||
- name: Backend log
|
||||
if: steps.e2e.outcome == 'failure'
|
||||
run: cat /tmp/backend.log
|
||||
|
||||
- name: Summarize
|
||||
if: always()
|
||||
run: node scripts/summarize-playwright.js frontend/playwright-results.json
|
||||
|
||||
- name: Fail job if tests failed
|
||||
if: steps.e2e.outcome == 'failure'
|
||||
run: exit 1
|
||||
-13
@@ -10,17 +10,4 @@ playwright-report/
|
||||
test-results/
|
||||
.env
|
||||
.superpowers/
|
||||
# Logs, pids and uploads written by scripts/start-local.ps1
|
||||
.local/
|
||||
.scannerwork/
|
||||
.nyc_output/
|
||||
|
||||
# Test result JSON, written by the CI test steps for the summarize scripts and
|
||||
# by anyone running the same commands locally. Regenerated every run.
|
||||
backend/unit-results.json
|
||||
backend/integration-results.json
|
||||
frontend/playwright-results.json
|
||||
|
||||
# Where an end-to-end run against the throwaway database writes its uploads.
|
||||
# Disposable with the database it belongs to (#186).
|
||||
backend/.e2e-uploads/
|
||||
|
||||
+1
-22
@@ -10,28 +10,7 @@ WORKDIR /app/backend
|
||||
COPY backend/package.json ./
|
||||
RUN npm install
|
||||
COPY backend/ ./
|
||||
# There is deliberately no `COPY .git` here. It was tried in #233 and broke
|
||||
# every Portainer deploy with `"/.git": not found` — Portainer's build context
|
||||
# does not contain the repository history, whatever a local `docker build`
|
||||
# suggests. That was the version stamp becoming the thing that stopped a
|
||||
# deploy, which is the one outcome it must never be (#235).
|
||||
#
|
||||
# The consequence is that `commit` reads "unknown" in any environment Portainer
|
||||
# builds. `builtAt` is still real, and is the half that matters most here:
|
||||
# Portainer already reports which commit it cloned, but it cannot tell you
|
||||
# whether the running container is actually that build. A build time can.
|
||||
#
|
||||
# writeBuildInfo runs against the compiled output, so it has to follow tsc, and
|
||||
# it warns rather than fails when it finds no .git.
|
||||
# Passed in rather than discovered. Whoever builds knows the commit; the build
|
||||
# does not go looking for it, which is what broke every deploy when it did
|
||||
# (#235) and what building in CI did not fix (#237). Empty by default, so a
|
||||
# build that does not pass one behaves exactly as before — Portainer cannot
|
||||
# supply it and still deploys, which is the property that must not regress.
|
||||
#
|
||||
# docker build --build-arg GIT_COMMIT="$(git rev-parse --short HEAD)" .
|
||||
ARG GIT_COMMIT=
|
||||
RUN GIT_COMMIT="$GIT_COMMIT" npm run build && GIT_COMMIT="$GIT_COMMIT" node dist/writeBuildInfo.js
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-bookworm-slim
|
||||
WORKDIR /app
|
||||
|
||||
@@ -24,45 +24,6 @@ cd redefined-designs
|
||||
|
||||
Commands below are shown for **PowerShell** (Windows). A bash equivalent is noted wherever the syntax differs.
|
||||
|
||||
### The short way
|
||||
|
||||
`scripts/start-local.ps1` does everything in this section — Postgres, migrations, the backend and the dev server — and `scripts/run-tests.ps1` runs the suites. The step-by-step instructions below are still accurate, and are what to reach for when something needs doing differently.
|
||||
|
||||
```powershell
|
||||
.\scripts\start-local.ps1 # bring the whole stack up
|
||||
.\scripts\start-local.ps1 -Fresh # ...from an empty database
|
||||
.\scripts\start-local.ps1 -Stop # stop everything
|
||||
|
||||
.\scripts
|
||||
un-tests.ps1 -Suite unit
|
||||
.\scripts
|
||||
un-tests.ps1 -Suite integration
|
||||
.\scripts
|
||||
un-tests.ps1 -Suite e2e
|
||||
.\scripts
|
||||
un-tests.ps1 -Suite all
|
||||
```
|
||||
|
||||
|
||||
Run the end-to-end suite against a throwaway database rather than your development one:
|
||||
|
||||
```powershell
|
||||
.\scripts\start-local.ps1 -E2eDb # separate container, separate port, starts empty
|
||||
.\scripts␍un-tests.ps1 -Suite e2e
|
||||
```
|
||||
|
||||
Without `-E2eDb` the suite shares the development database, which nothing truncates — every run leaves its fixtures behind, and the storefront eventually renders enough of them to outrun the assertions' timeouts. See #186.
|
||||
|
||||
Both scripts switch to the pinned Node 26.7.0 (`NODE_VERSION` in `scripts/NodeVersion.ps1`) and verify that is what actually ends up running, then put the machine back to 18.16.1 when they finish — including when they fail partway, so an interrupted run does not leave the version switched. **`nvm use` rewrites a machine-global symlink, so this changes the Node version for every terminal on the machine while a script is running, not only the one you ran it in.** Both scripts say so as they do it.
|
||||
|
||||
The Node 20 floor is not arbitrary: `node-pg-migrate` pulls in an `lru-cache` that calls `diagnostics_channel.tracingChannel()`, which does not exist before Node 19.9. On Node 18 migrations die inside minified library code with `(0 , U.tracingChannel) is not a function`, which says nothing about versions.
|
||||
|
||||
Since #226 there is a second Node floor, and it bites at **install** time rather than at run time. `sharp` declares `>=20.9.0`, and the platform binary that does its actual work is an **optional** dependency. npm silently skips an optional dependency whose engine check fails and still reports success — so `npm install` on the machine's default 18.16.1 produces a `node_modules` that looks complete and then throws `Could not load the "sharp" module using the win32-x64 runtime` at require time. That message names a runtime rather than a version and sends you looking in the wrong place.
|
||||
|
||||
Once the binary is installed, sharp loads and runs perfectly well on 18.16.1 — `engines` is advisory at run time. So this is purely about how the install was done, not about which Node runs the tests. Install through `start-local.ps1` or `run-tests.ps1`, which switch to Node 20+ first; if you have already hit it, `npm install --include=optional sharp` under Node 20+ repairs it in place.
|
||||
|
||||
`run-tests.ps1` brings up whatever a suite needs: the integration suite gets its own throwaway Postgres, started and stopped around the run (`-KeepTestDb` leaves it up, `-TestDbPort` moves it if the default is taken or Hyper-V has reserved it). The e2e suite needs the app stack, so start it with `start-local.ps1` first — the script checks and says so rather than letting every spec fail on a refused connection. `-Filter` passes through to the runner to select tests by file or name.
|
||||
|
||||
### 1. Start a local Postgres instance
|
||||
|
||||
The backend needs Postgres to talk to during local development. `backend/docker-compose.test.yml` spins up a throwaway, tmpfs-backed instance — no data persists between restarts, which is fine for local dev and tests.
|
||||
@@ -121,27 +82,6 @@ mkdir -p /tmp/redefined-uploads
|
||||
|
||||
`DEMO_MODE=true` enables a "Buy Now (Demo)" button on the storefront that completes a purchase without needing real PayPal credentials — useful for local development and for the Playwright tests below. To exercise real PayPal checkout locally, also set `PAYPAL_CLIENT_ID`, `PAYPAL_CLIENT_SECRET`, and `PAYPAL_ENV=sandbox`.
|
||||
|
||||
#### The server checks its configuration before it starts
|
||||
|
||||
`server.ts` validates the environment at boot, reports every problem at once, and exits rather than starting — the same reasoning as the container refusing to start on a failed migration. A missing variable used to be `undefined` until the first line of code that happened to need it, which could be long after the container reported healthy, and several of those failures were silent and customer-visible.
|
||||
|
||||
| | Variables |
|
||||
| --- | --- |
|
||||
| Always required | `DEMO_MODE`, `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`, `UPLOADS_DIR` |
|
||||
| Required when `DEMO_MODE=false` | `PAYPAL_CLIENT_ID`, `PAYPAL_CLIENT_SECRET`, `PAYPAL_WEBHOOK_ID`, `PAYPAL_ENV` |
|
||||
| Required when SMTP is configured | `PUBLIC_URL`, and `SMTP_USER`/`SMTP_PASSWORD` together |
|
||||
| Warned about, but not fatal | SMTP absent, `ADMIN_GATE_SECRET` absent, `MAIL_ALLOWLIST` absent while SMTP is configured |
|
||||
|
||||
**`DEMO_MODE` must be exactly `true` or `false`.** It used to mean "demo unless the value is exactly `false`", so `DEMO_MODE=False`, `0`, or any typo left demo mode on — which meant the shop quietly stopped charging anyone. It is now required and strict, so a slip is a startup failure instead.
|
||||
|
||||
`PUBLIC_URL` is required only alongside SMTP because its only job is building links in email; an environment that cannot send mail does not need it. `UPLOADS_DIR` has no such reprieve — its fallback of `/app/uploads` is correct inside the container and wrong everywhere else.
|
||||
|
||||
**Adding to the required list has reach beyond this repository.** A variable added to `ALWAYS_REQUIRED` must also be set in every environment that deploys, and there are two of those. Both are checked automatically — `backend/tests/unit/composeEnvironment.test.ts` reads the validator's own list and fails if a compose file does not set something on it, which is what #107 existed to prevent from recurring. It runs over every deployment file in the repository and hands each one's entries to `validateEnv` itself, so a file is checked against exactly what the container checks at boot.
|
||||
|
||||
Production was outside that net until #118. It ran from a stack that existed only in Portainer's web editor, which no test could read — and on 2026-08-23 it refused to boot because `UPLOADS_DIR` had no line in it, while being set in Portainer's stack variables. `docker-compose.prod.yml` is now in the repository and covered like QA's, which is also why it must be deployed as a **git repository stack** rather than pasted into the web editor: a pasted copy drifts from the checked one and the guard goes back to being decorative.
|
||||
|
||||
Note also that setting a variable in Portainer's stack environment is not the same as giving it to the container. Stack variables are interpolated into the compose file as `${VAR}`; a service receives exactly what its own `environment:` block lists. A variable with no line there never arrives, however carefully it was set in the UI.
|
||||
|
||||
**Note (PowerShell):** environment variables set with `$env:` only last for the current terminal session/tab. If you close and reopen VS Code's terminal, you'll need to re-run step 3 before starting the backend again.
|
||||
|
||||
### 4. Run the backend
|
||||
@@ -196,7 +136,7 @@ npm run db:test:down # when finished
|
||||
|
||||
### Frontend Playwright e2e tests
|
||||
|
||||
Needs the backend running against a database with the schema loaded (steps 1–4 above, or `.\scripts\start-local.ps1`), since these tests drive real registration/login/purchase flows through a live API.
|
||||
Needs the backend running against a database with the schema loaded (steps 1–4 above), since these tests drive real registration/login/purchase flows through a live API.
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
@@ -239,35 +179,10 @@ Production runs as a single Docker image (multi-stage build — the frontend is
|
||||
|
||||
The container applies pending migrations before starting the server, so deployed code can never be ahead of the database schema. A failed migration stops the container rather than letting it serve against a schema it doesn't match — check `docker logs` on the app container if it doesn't come up.
|
||||
|
||||
### The admin authorization boundary
|
||||
|
||||
Worth reading before adding any admin route, because the control is invisible from the code.
|
||||
|
||||
Authorization for the admin panel and the admin API lives in a single `auth_request` regex in the Nginx Proxy Manager config — `^/(admin|api/admin)` in production, and `location /` in QA, where the whole site is gated. That config is not in this repository. Three consequences follow, and none of them are visible from Express:
|
||||
|
||||
- **An admin route added at a path the regex does not match is not covered by it.** `/api/reports` or `/api/internal/...` would be publicly reachable the moment it shipped.
|
||||
- **Anything that reaches the container directly bypasses authentik entirely**, because the gate is in the proxy in front of it. QA publishes port 32751 on the NAS and production publishes its own.
|
||||
- **Locally there is no gate at all**, so `/admin` and the whole admin API are open by design and no developer ever sees the boundary being enforced.
|
||||
|
||||
`ADMIN_GATE_SECRET` is the application-layer half of this, and it is optional:
|
||||
|
||||
| State | Behaviour |
|
||||
| --- | --- |
|
||||
| Unset | Every admin route is reachable, exactly as before. The server logs an `[admin-gate]` warning at boot saying so, so the state is visible rather than silent. This is what local development and the test suite run in. |
|
||||
| Set | Every admin router requires an `X-Admin-Gate` header matching the value, and returns 403 without it. |
|
||||
|
||||
To turn it on, the secret has to be set in **two places at once** — the stack's environment, and a `proxy_set_header X-Admin-Gate "<secret>";` line on the gated location in Nginx Proxy Manager. Setting it in only one of them makes the admin panel return 403 until the other catches up. That failure is loud and recoverable, unlike the one it replaces.
|
||||
|
||||
The middleware is attached to each admin **router** rather than to a path prefix. That is deliberate: an admin router added later at some other path inherits the gate, and because the proxy only injects the header on paths its regex matches, that router refuses on its first request rather than being quietly public. A 403 in that situation means the proxy regex needs widening — it is the boundary telling you it has drifted.
|
||||
|
||||
### Promoting a reviewed change to production
|
||||
|
||||
Only after the change has been reviewed in QA.
|
||||
|
||||
This is the routine deploy, and it assumes the production stack already runs from `docker-compose.prod.yml` as a git repository stack. Moving it there in the first place is a different, one-time operation with a different order — see [docs/ops/production-stack-cutover.md](docs/ops/production-stack-cutover.md).
|
||||
|
||||
The scheduled backups in `docker-compose.prod.yml` do **not** replace step 1 below. They run while the stack runs, so they cannot cover a deploy that recreates it — and a nightly dump is up to a day old, where this one is seconds old. See [docs/ops/backup-and-restore.md](docs/ops/backup-and-restore.md) for what each covers and how to restore either.
|
||||
|
||||
```bash
|
||||
# 1. Back up first — the container migrates the schema on its own.
|
||||
sudo docker exec -t redefined-designs-db-syn pg_dump -U redefined -d redefined \
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# A throwaway Postgres for the end-to-end suite.
|
||||
#
|
||||
# The e2e suite used to run against the development database that
|
||||
# start-local.ps1 brings up, and nothing ever truncated it: every run seeded
|
||||
# more fixtures and left them. The unfiltered storefront grew monotonically —
|
||||
# 1,662 items by the time #186 was filed — until rendering it outran the
|
||||
# assertions' timeout. It failed locally, passed in CI where the database is
|
||||
# fresh, and got slowly worse, which is the combination nobody can act on.
|
||||
#
|
||||
# Deliberately a mirror of docker-compose.test.yml rather than a shared file.
|
||||
# The two suites must not share a database: the integration suite truncates
|
||||
# between tests, so running it while an e2e run is in flight would delete that
|
||||
# run's fixtures underneath it (#116). Different container, different port,
|
||||
# different credentials — so the mistake is impossible rather than discouraged.
|
||||
#
|
||||
# tmpfs, like the integration database: the data is worthless the moment the
|
||||
# run ends, and a container with nothing to persist starts faster and cannot
|
||||
# accumulate anything between runs.
|
||||
#
|
||||
# docker compose -f backend/docker-compose.e2e.yml up -d
|
||||
# docker compose -f backend/docker-compose.e2e.yml down
|
||||
#
|
||||
# The suite and the application both have to point at it. See
|
||||
# scripts/start-local.ps1 -E2eDb, which does that for you.
|
||||
services:
|
||||
redefined-designs-e2e-db:
|
||||
image: postgres:16
|
||||
container_name: redefined-designs-e2e-db
|
||||
environment:
|
||||
- POSTGRES_USER=redefined_e2e
|
||||
- POSTGRES_PASSWORD=redefined_e2e
|
||||
- POSTGRES_DB=redefined_e2e
|
||||
ports:
|
||||
# Not 55432 (integration) and not 55500 (development). Override with
|
||||
# E2E_DB_PORT if Hyper-V has reserved this one — it silently claims
|
||||
# ranges on Windows, which is why the integration suite has TEST_PGPORT.
|
||||
- "${E2E_DB_PORT:-55501}:5432"
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data
|
||||
@@ -30,25 +30,16 @@ const advisory = (config) => ({
|
||||
});
|
||||
|
||||
export default tseslint.config(
|
||||
// src/db-kysely/schema.ts is `kysely-codegen` output, not written by anyone
|
||||
// here. #261 hand-fixed an unused-parameter warning in the equivalent Drizzle
|
||||
// file and #217's regeneration put it straight back, which is the whole
|
||||
// argument: linting generated code buys a fix that the next regeneration
|
||||
// undoes. The hand-written files in that directory are still linted.
|
||||
{
|
||||
ignores: [
|
||||
'dist/**',
|
||||
'coverage/**',
|
||||
'eslint.config.mjs',
|
||||
'src/db-kysely/schema.ts'
|
||||
]
|
||||
},
|
||||
{ ignores: ['dist/**', 'coverage/**', 'eslint.config.mjs'] },
|
||||
|
||||
...[js.configs.recommended, ...tseslint.configs.recommended, sonarjs.configs.recommended].map(
|
||||
advisory
|
||||
),
|
||||
|
||||
{
|
||||
// `tests/` is deliberately out of scope for now: tsconfig.json only includes
|
||||
// `src`, so type-aware linting has no program for the test files, and
|
||||
// widening it is a separate change with its own violation count.
|
||||
files: ['src/**/*.ts'],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
@@ -67,73 +58,5 @@ export default tseslint.config(
|
||||
{ checksVoidReturn: { attributes: false } },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
// The test suites, in scope since #298. They had never been linted at all:
|
||||
// this config said tests were out of scope because tsconfig.json includes
|
||||
// only `src`, and that stayed true for long enough that two defects lived
|
||||
// here undetected — a unit test that opened a real TLS connection to Gmail
|
||||
// on every run, and integration tests that mocked the shared pg pool and
|
||||
// made a suite unrunnable. Neither is something lint would necessarily have
|
||||
// caught, but neither was ever looked at.
|
||||
//
|
||||
// `project` rather than `projectService`, for the reason the frontend's
|
||||
// equivalent block records: the service resolves each file to the nearest
|
||||
// tsconfig.json, which for tests/ is the one that excludes them, and every
|
||||
// file then errors as not part of a project.
|
||||
files: ['tests/**/*.ts'],
|
||||
languageOptions: {
|
||||
globals: { ...globals.node, ...globals.jest },
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.test.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// The same rule src is held to, and it matters at least as much here.
|
||||
// An unawaited promise in a test does not fail the test — it passes,
|
||||
// having asserted nothing, and the failure surfaces later as a suite that
|
||||
// will not exit.
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-misused-promises': [
|
||||
'error',
|
||||
{ checksVoidReturn: { attributes: false } },
|
||||
],
|
||||
|
||||
// Everything below is switched off for tests rather than left as a
|
||||
// warning, on #60's argument: bringing these files in scope produced 77
|
||||
// warnings, of which 60 were rules that cannot be true in a test. A rule
|
||||
// that cannot be true here is noise, and noise hides the rules that can.
|
||||
// What is left is signal — unused variables, useless escapes, a regex
|
||||
// worth a second look.
|
||||
|
||||
// 41 of the 77. Test credentials are the entire point of a test, and this
|
||||
// project's own rule is that they must live only in test paths — which is
|
||||
// here. Flagging them where they belong trains a reader to skip the rule
|
||||
// where they do not.
|
||||
'sonarjs/no-hardcoded-passwords': 'off',
|
||||
|
||||
// Stub servers and fixtures: `http://127.0.0.1:<port>`. There is no
|
||||
// transport to secure between a test and a socket it opened itself.
|
||||
'sonarjs/no-clear-text-protocols': 'off',
|
||||
|
||||
// 203.0.113.5 is TEST-NET-3, reserved by RFC 5737 for exactly this. A
|
||||
// documentation address is the correct thing to hardcode.
|
||||
'sonarjs/no-hardcoded-ip': 'off',
|
||||
|
||||
// `os.tmpdir()`, via mkdtemp, which is how these suites get a scratch
|
||||
// uploads directory they can delete afterwards.
|
||||
'sonarjs/publicly-writable-directories': 'off',
|
||||
|
||||
// Math.random for a run id. Nothing here is a secret; it only has to not
|
||||
// collide with a parallel worker.
|
||||
'sonarjs/pseudo-random': 'off',
|
||||
|
||||
// Sorting two string arrays to compare them is how several guards assert
|
||||
// set equality. The locale-aware comparator the rule wants would change
|
||||
// nothing except the reading.
|
||||
'sonarjs/no-alphabetical-sort': 'off',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,15 +2,6 @@ module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['<rootDir>/tests/integration/**/*.test.ts'],
|
||||
// Separate directory per suite: jest writes coverage/lcov.info by default, so
|
||||
// running unit and integration in the same job would have the second silently
|
||||
// overwrite the first. SonarQube is pointed at both and merges them.
|
||||
coverageDirectory: '<rootDir>/coverage/integration',
|
||||
coverageReporters: ['lcov', 'text-summary'],
|
||||
// Every source file, not just the ones a test happens to import — otherwise an
|
||||
// entirely untested file is absent from the report rather than reported as 0%,
|
||||
// which flatters the total.
|
||||
collectCoverageFrom: ['<rootDir>/src/**/*.ts'],
|
||||
setupFiles: ['<rootDir>/tests/integration/setup/env.setup.ts'],
|
||||
globalSetup: '<rootDir>/tests/integration/setup/globalSetup.ts',
|
||||
testTimeout: 20000
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['<rootDir>/tests/unit/**/*.test.ts'],
|
||||
// Separate directory per suite: jest writes coverage/lcov.info by default, so
|
||||
// running unit and integration in the same job would have the second silently
|
||||
// overwrite the first. SonarQube is pointed at both and merges them.
|
||||
coverageDirectory: '<rootDir>/coverage/unit',
|
||||
coverageReporters: ['lcov', 'text-summary'],
|
||||
// Every source file, not just the ones a test happens to import — otherwise an
|
||||
// entirely untested file is absent from the report rather than reported as 0%,
|
||||
// which flatters the total.
|
||||
collectCoverageFrom: ['<rootDir>/src/**/*.ts']
|
||||
testMatch: ['<rootDir>/tests/unit/**/*.test.ts']
|
||||
};
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- New items are staged, not published. Before this an item was live on the
|
||||
-- storefront the instant it was created, with no way to add something, look
|
||||
-- at it, and then decide it was ready.
|
||||
--
|
||||
-- Only the default changes. Existing rows keep whatever status they have —
|
||||
-- backfilling would un-publish the entire live catalogue, which is the one
|
||||
-- thing this migration must not do.
|
||||
ALTER TABLE items ALTER COLUMN status SET DEFAULT 'pending';
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`
|
||||
ALTER TABLE items ALTER COLUMN status SET DEFAULT 'available';
|
||||
`);
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- Emails greet customers, and a single 'name' column only allows the formal
|
||||
-- whole name: "Hi Thom Lamb," rather than "Hi Thom,". Splitting it is what
|
||||
-- makes an informal greeting possible.
|
||||
--
|
||||
-- Both columns are nullable even though registration now requires them. The
|
||||
-- requirement is enforced in the route, where a missing field can produce a
|
||||
-- 400 naming it. Marking these NOT NULL would mean backfilling legacy rows
|
||||
-- with empty strings, which asserts that every customer has a name — and
|
||||
-- that is not true of anyone who registered while the field was optional.
|
||||
-- The table should record what is actually the case.
|
||||
ALTER TABLE customers ADD COLUMN IF NOT EXISTS first_name TEXT;
|
||||
ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_name TEXT;
|
||||
|
||||
-- The lossy part, and there is no version of this that is not.
|
||||
--
|
||||
-- Splitting on the first space is right for "Thom Lamb" and wrong for
|
||||
-- "Mary Jane Smith", who ends up with a last name of "Jane Smith". Names do
|
||||
-- not reliably divide into two parts at all. This was chosen over leaving
|
||||
-- the columns empty because there is currently no way for a customer to
|
||||
-- correct their own name — PUT /api/customers/me exists but nothing calls
|
||||
-- it — so empty would mean permanently unpersonalised for everyone who
|
||||
-- registered before this.
|
||||
--
|
||||
-- Treat backfilled values as a best guess rather than as data the customer
|
||||
-- gave you in this shape.
|
||||
UPDATE customers
|
||||
SET first_name = CASE
|
||||
WHEN position(' ' in btrim(name)) > 0 THEN split_part(btrim(name), ' ', 1)
|
||||
ELSE btrim(name)
|
||||
END,
|
||||
last_name = CASE
|
||||
WHEN position(' ' in btrim(name)) > 0
|
||||
THEN btrim(substring(btrim(name) from position(' ' in btrim(name)) + 1))
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE name IS NOT NULL AND btrim(name) <> '';
|
||||
|
||||
-- Dropped rather than kept alongside. Two columns describing the same fact
|
||||
-- drift, and the new pair is now the only place a name lives.
|
||||
ALTER TABLE customers DROP COLUMN IF EXISTS name;
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`
|
||||
ALTER TABLE customers ADD COLUMN IF NOT EXISTS name TEXT;
|
||||
|
||||
-- Rejoins the parts. Not a perfect inverse of the split above — a name that
|
||||
-- was mangled on the way in stays mangled on the way out — but it restores
|
||||
-- a usable whole name rather than leaving the column empty.
|
||||
UPDATE customers
|
||||
SET name = btrim(concat_ws(' ', first_name, last_name))
|
||||
WHERE first_name IS NOT NULL OR last_name IS NOT NULL;
|
||||
|
||||
UPDATE customers SET name = NULL WHERE name = '';
|
||||
|
||||
ALTER TABLE customers DROP COLUMN IF EXISTS first_name;
|
||||
ALTER TABLE customers DROP COLUMN IF EXISTS last_name;
|
||||
`);
|
||||
};
|
||||
@@ -1,80 +0,0 @@
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
CREATE TABLE IF NOT EXISTS upload_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
-- The token itself is never stored, only its digest. A leaked database
|
||||
-- is then not also a leaked set of working upload links, and the admin
|
||||
-- screen can show a token exactly once — at creation — for the same
|
||||
-- reason a password reset link is not re-readable.
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
submission_count INTEGER NOT NULL DEFAULT 0,
|
||||
-- Null means no cap. A link handed to a regular contributor is
|
||||
-- open-ended; one handed out for a single box of stock is not. The
|
||||
-- route defaults this to a finite number rather than null, so an
|
||||
-- unbounded link is something asked for rather than something that
|
||||
-- happens when nobody thought about it.
|
||||
max_submissions INTEGER,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_drafts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
item_id INTEGER NOT NULL UNIQUE REFERENCES items(id) ON DELETE CASCADE,
|
||||
-- SET NULL rather than CASCADE: deleting a link must not delete the
|
||||
-- items that arrived through it. Provenance is lost; the goods are not.
|
||||
upload_link_id INTEGER REFERENCES upload_links(id) ON DELETE SET NULL,
|
||||
submitter_note TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'queued',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
model TEXT,
|
||||
ai_name TEXT,
|
||||
ai_description TEXT,
|
||||
ai_category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
||||
ai_tag_names TEXT[],
|
||||
-- Kept even though a suggestion is also copied onto the item, so what
|
||||
-- the model proposed stays readable after the admin has edited the
|
||||
-- item's price. Without it there is no way to ask later whether the
|
||||
-- model's numbers were any good.
|
||||
ai_suggested_price_cents INTEGER,
|
||||
-- ai | default | admin. Where the item's current price came from.
|
||||
-- Recorded rather than inferred: a model that happens to suggest exactly
|
||||
-- 8000, or an admin who deliberately types the model's number, both
|
||||
-- collapse any comparison-based guess.
|
||||
price_source TEXT NOT NULL DEFAULT 'default',
|
||||
ai_error TEXT,
|
||||
input_tokens INTEGER,
|
||||
output_tokens INTEGER,
|
||||
cost_micros INTEGER,
|
||||
drafted_at TIMESTAMPTZ,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- The review queue reads by state; everything else reads by item, which
|
||||
-- the UNIQUE constraint on item_id already indexes.
|
||||
CREATE INDEX IF NOT EXISTS item_drafts_state_idx ON item_drafts (state);
|
||||
|
||||
-- A submitted item is priced on arrival rather than left unpriced, so the
|
||||
-- column keeps NOT NULL and only gains a fallback. 80.00 applies when
|
||||
-- nothing else supplies a price; the drafting worker in #223 writes a
|
||||
-- model's suggestion over it when there is one.
|
||||
--
|
||||
-- The number lives here rather than in configuration deliberately.
|
||||
-- Changing a default price is a rare, deliberate act that deserves a
|
||||
-- record; an environment variable would let it drift silently between
|
||||
-- environments, and a wrong default is invisible until something has
|
||||
-- already sold at it.
|
||||
ALTER TABLE items ALTER COLUMN price_cents SET DEFAULT 8000;
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`
|
||||
ALTER TABLE items ALTER COLUMN price_cents DROP DEFAULT;
|
||||
DROP TABLE IF EXISTS item_drafts;
|
||||
DROP TABLE IF EXISTS upload_links;
|
||||
`);
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- The submitter's intent, per submission, because that is how it is
|
||||
-- expressed: one checkbox above the send button, ticked by default.
|
||||
--
|
||||
-- The worker acts on it rather than the intake route. Removing inline
|
||||
-- would make the sender wait, would put a CPU-heavy model run in a path
|
||||
-- anyone holding a link can trigger — the surface #227 exists to bound —
|
||||
-- and would force a choice, when the sidecar is unreachable, between
|
||||
-- failing their submission and silently ignoring what they asked for.
|
||||
--
|
||||
-- NOT NULL DEFAULT true so a row written before this migration, or by any
|
||||
-- path that does not mention the column, behaves like the new default.
|
||||
ALTER TABLE item_drafts
|
||||
ADD COLUMN IF NOT EXISTS remove_background BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- Where the photo came from, per image, because that is how it is undone.
|
||||
-- Null until a photo has been cut out, so it is also the answer to "can
|
||||
-- this be restored?" — one fact in one place rather than a flag that can
|
||||
-- disagree with a path.
|
||||
--
|
||||
-- Nullable and with no default: an existing image has no original other
|
||||
-- than itself, and claiming otherwise would offer a Restore that swapped a
|
||||
-- photo for a copy of itself.
|
||||
ALTER TABLE item_images
|
||||
ADD COLUMN IF NOT EXISTS original_image_path TEXT;
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`
|
||||
ALTER TABLE item_drafts DROP COLUMN IF EXISTS remove_background;
|
||||
ALTER TABLE item_images DROP COLUMN IF EXISTS original_image_path;
|
||||
`);
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- Where the link was sent (#260). Nullable, and deliberately so: links
|
||||
-- already exist in QA and a migration cannot invent an address for them, so
|
||||
-- they are grandfathered rather than backfilled with something untrue.
|
||||
--
|
||||
-- The requirement lives in the create route instead, which is where new
|
||||
-- links are actually made. A NOT NULL column would have forced a choice
|
||||
-- between inventing data and refusing to migrate.
|
||||
ALTER TABLE upload_links
|
||||
ADD COLUMN IF NOT EXISTS contact_email TEXT;
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`ALTER TABLE upload_links DROP COLUMN IF EXISTS contact_email;`);
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- Consent to the Brevo tracker, separate from marketing_consent (#56).
|
||||
--
|
||||
-- Separate because GDPR requires consent to be granular: email marketing
|
||||
-- and behavioural tracking are two purposes with two recipients, and
|
||||
-- current EDPB guidance treats bundling tracking consent with subscription
|
||||
-- consent as invalid. Quebec's Law 25 s.8.1 goes further and requires
|
||||
-- profiling technology to be off until the person switches it on.
|
||||
--
|
||||
-- DEFAULT FALSE is the part that must not be changed. Every existing
|
||||
-- customer arrives at false, which is both the honest answer — none of them
|
||||
-- were ever asked — and what Law 25 requires. A default of true would
|
||||
-- silently opt in the entire customer base to something nobody agreed to.
|
||||
ALTER TABLE customers
|
||||
ADD COLUMN IF NOT EXISTS analytics_consent BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- When they agreed, and to exactly what wording. Same shape and same
|
||||
-- reasoning as the marketing_consent pair: the stored sentence is what
|
||||
-- makes the record say what the customer actually saw, so re-wording the
|
||||
-- consent later cannot retroactively broaden anyone's.
|
||||
--
|
||||
-- Both nullable: a customer who has never consented has no date and no
|
||||
-- text, and inventing either would be a false record of consent.
|
||||
ALTER TABLE customers
|
||||
ADD COLUMN IF NOT EXISTS analytics_consent_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE customers
|
||||
ADD COLUMN IF NOT EXISTS analytics_consent_text TEXT;
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`
|
||||
ALTER TABLE customers DROP COLUMN IF EXISTS analytics_consent_text;
|
||||
ALTER TABLE customers DROP COLUMN IF EXISTS analytics_consent_at;
|
||||
ALTER TABLE customers DROP COLUMN IF EXISTS analytics_consent;
|
||||
`);
|
||||
};
|
||||
@@ -1,97 +0,0 @@
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- A registered passkey (#37). Groundwork only: nothing reads these yet.
|
||||
--
|
||||
-- Bound to the customer and deleted with them. Account deletion already
|
||||
-- removes the personal data this sits beside, and a credential that
|
||||
-- outlived its owner could authenticate as a customer who no longer exists.
|
||||
-- Disabling an account (#33) is a different question and is deliberately not
|
||||
-- a schema concern: a disabled customer keeps their credentials and is
|
||||
-- refused at the authentication ceremony instead, so re-enabling them does
|
||||
-- not mean re-registering every device.
|
||||
CREATE TABLE IF NOT EXISTS customer_credentials (
|
||||
id SERIAL PRIMARY KEY,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
|
||||
-- The credential ID as base64url text rather than bytea. It arrives from
|
||||
-- the browser in that form, is compared as an opaque string, and is never
|
||||
-- interpreted here — storing bytes would mean encoding on write and
|
||||
-- decoding on every read for no gain.
|
||||
--
|
||||
-- Unique across the table, not merely per customer: a credential ID
|
||||
-- identifies an authenticator, and the same one appearing under two
|
||||
-- accounts means something has gone wrong rather than that two people
|
||||
-- share a key.
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
|
||||
-- The COSE public key, base64url. Verified against, never parsed here.
|
||||
public_key TEXT NOT NULL,
|
||||
|
||||
-- BIGINT because the spec allows a 32-bit unsigned value, which overflows
|
||||
-- a signed INTEGER at half its range.
|
||||
--
|
||||
-- What to do when this fails to increase is NOT decided here. Many synced
|
||||
-- passkeys report 0 forever, so "a regression means cloning" is wrong for
|
||||
-- them and right for hardware keys. That policy belongs with the
|
||||
-- authentication ceremony that enforces it (#39); this column only has to
|
||||
-- be able to hold the value.
|
||||
signature_counter BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
-- How the authenticator can be reached: usb, nfc, ble, internal, hybrid.
|
||||
-- A JSON array as text, because it is passed back to the browser verbatim
|
||||
-- and never queried on.
|
||||
transports TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
-- Null until first used. Shown on the account page (#40) so a customer can
|
||||
-- recognise which device a credential belongs to, which is the only way
|
||||
-- they can tell two entries apart.
|
||||
last_used_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Listing a customer's credentials is the common read, and revocation (#40)
|
||||
-- has to scope by owner.
|
||||
CREATE INDEX IF NOT EXISTS customer_credentials_customer_id_idx
|
||||
ON customer_credentials (customer_id);
|
||||
|
||||
-- An in-flight WebAuthn challenge (#37).
|
||||
--
|
||||
-- A separate table rather than customer_tokens with a new kind, and the
|
||||
-- reason is structural rather than tidiness: customer_tokens.customer_id is
|
||||
-- NOT NULL, and an *authentication* challenge is issued before anyone is
|
||||
-- identified. A discoverable-credential sign-in has no customer to attach
|
||||
-- to at the moment the challenge is created, so it could not be stored
|
||||
-- there without making that column nullable for every other kind of token.
|
||||
CREATE TABLE IF NOT EXISTS webauthn_challenges (
|
||||
-- The challenge itself, base64url, as issued. Primary key because it is
|
||||
-- the thing looked up, and unique by construction.
|
||||
challenge TEXT PRIMARY KEY,
|
||||
|
||||
-- Null for authentication, set for registration. Registration requires an
|
||||
-- authenticated session — it is not a sign-up path — so that half always
|
||||
-- knows whose it is.
|
||||
customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE,
|
||||
|
||||
-- 'registration' or 'authentication'. Not a CHECK constraint: the values
|
||||
-- come from this codebase rather than from a request, and the project has
|
||||
-- no enum types elsewhere.
|
||||
kind TEXT NOT NULL,
|
||||
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
-- Expiry is swept by time, so the sweep reads this rather than the whole
|
||||
-- table. Single use is enforced by deleting the row on consumption, which
|
||||
-- needs no index beyond the primary key.
|
||||
CREATE INDEX IF NOT EXISTS webauthn_challenges_expires_at_idx
|
||||
ON webauthn_challenges (expires_at);
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`
|
||||
DROP TABLE IF EXISTS webauthn_challenges;
|
||||
DROP TABLE IF EXISTS customer_credentials;
|
||||
`);
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- What the customer calls this passkey (#38).
|
||||
--
|
||||
-- Not in the #37 groundwork because that issue listed the columns the
|
||||
-- ceremony needs and this one is for the person: the management screen (#40)
|
||||
-- shows a list, and "phone" against "laptop" is the only thing that makes
|
||||
-- two entries tellable apart. Without it a customer revoking a credential is
|
||||
-- choosing between identical rows.
|
||||
--
|
||||
-- NOT NULL with a default rather than nullable. Every row must be
|
||||
-- displayable, and a null would push the "or a sensible default" half of the
|
||||
-- requirement out into every read site. The route derives a better default
|
||||
-- from the authenticator's transports; this is the floor under that, and the
|
||||
-- value existing rows take.
|
||||
ALTER TABLE customer_credentials
|
||||
ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT 'Passkey';
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`ALTER TABLE customer_credentials DROP COLUMN IF EXISTS name;`);
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- An email address changed by the shop rather than by the customer (#337).
|
||||
--
|
||||
-- This exists because of what the action is. A customer who has lost access
|
||||
-- to their mailbox has no self-service route back in, and there should not
|
||||
-- be one — this shop holds no second proof of identity, and anything
|
||||
-- invented to fill that gap would be a weaker credential than the one it
|
||||
-- replaced. So the route is manual: the owner verifies the customer against
|
||||
-- order history and moves the account to an address they can reach.
|
||||
--
|
||||
-- That is also, exactly, what an account takeover looks like. The two are
|
||||
-- the same operation and differ only in whether the verification was sound.
|
||||
-- A hand-written database edit leaves nothing to tell them apart afterwards.
|
||||
-- This table is what does.
|
||||
CREATE TABLE IF NOT EXISTS customer_email_changes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
-- Cascades with the customer, deliberately. Both addresses here are
|
||||
-- personal data, so a record that outlived an erasure request would keep
|
||||
-- exactly what the erasure was for. A deleted account also has no
|
||||
-- takeover left to investigate.
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
|
||||
-- Copied rather than referenced, because the whole point is what the
|
||||
-- address *was*. The customers row holds the new one and cannot answer
|
||||
-- this question a moment after the change.
|
||||
previous_email TEXT NOT NULL,
|
||||
new_email TEXT NOT NULL,
|
||||
|
||||
-- What the operator typed, and NOT NULL because a change with no stated
|
||||
-- reason is the one this table exists to make impossible. Never shown to
|
||||
-- the customer: it is a note about how they were verified, and it can
|
||||
-- name things the customer should not be handed back.
|
||||
reason TEXT NOT NULL,
|
||||
|
||||
-- No "who". Admin access is one shared gate secret in front of a single
|
||||
-- operator (see middleware/adminGate.ts), so a column for it could only
|
||||
-- ever hold a constant, and a constant dressed up as an identity is worse
|
||||
-- than an honest absence. If per-admin identity ever arrives, that is
|
||||
-- when this gains a column and not before.
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- The read is always "what has happened to this account", so it is scoped
|
||||
-- by owner and ordered by time.
|
||||
CREATE INDEX IF NOT EXISTS customer_email_changes_customer_id_idx
|
||||
ON customer_email_changes (customer_id, changed_at DESC);
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`DROP TABLE IF EXISTS customer_email_changes;`);
|
||||
};
|
||||
@@ -1,77 +0,0 @@
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- A sign-in that belongs to somebody else's identity provider (#340).
|
||||
--
|
||||
-- A table rather than columns on customers, because one customer may hold
|
||||
-- more than one: Google today, and Apple if #332 ever decides in its
|
||||
-- favour. Columns would mean a second provider is a migration and a third
|
||||
-- is an embarrassment.
|
||||
--
|
||||
-- Same shape as customer_credentials, and for the same reason: a row that
|
||||
-- links this account to something a third party can vouch for, deleted with
|
||||
-- the customer because an identity that outlived its owner could
|
||||
-- authenticate as a customer who no longer exists.
|
||||
CREATE TABLE IF NOT EXISTS customer_identities (
|
||||
id SERIAL PRIMARY KEY,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
|
||||
-- 'google' today. Not a CHECK constraint: the values come from this
|
||||
-- codebase rather than from a request, and the project has no enum types
|
||||
-- elsewhere.
|
||||
provider TEXT NOT NULL,
|
||||
|
||||
-- The provider's subject claim, and **never the email**.
|
||||
--
|
||||
-- This is the whole security posture of the table in one column. An email
|
||||
-- is a display value that its owner can change and that a provider may
|
||||
-- reassign; a subject is opaque, stable for the life of the account, and
|
||||
-- means nothing outside the provider that issued it. Matching on the
|
||||
-- email would strand a customer who changed theirs and, far worse, hand
|
||||
-- their account to whoever inherited the old address.
|
||||
provider_sub TEXT NOT NULL,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
-- Null until first used, exactly as on a passkey. It is what tells two
|
||||
-- entries apart on an account page where the names are similar.
|
||||
last_used_at TIMESTAMPTZ,
|
||||
|
||||
-- Unique across the pair, not on the subject alone. Two providers could
|
||||
-- in principle issue the same opaque string and it would mean nothing —
|
||||
-- but the same provider issuing one subject to two accounts here means
|
||||
-- something has gone wrong rather than that two people share an identity.
|
||||
UNIQUE (provider, provider_sub)
|
||||
);
|
||||
|
||||
-- Listing what an account is linked to is the common read, and it is always
|
||||
-- scoped by owner.
|
||||
CREATE INDEX IF NOT EXISTS customer_identities_customer_id_idx
|
||||
ON customer_identities (customer_id);
|
||||
|
||||
-- The change with the widest blast radius in the whole project (#332).
|
||||
--
|
||||
-- A customer who signed up through Google has no password and never will
|
||||
-- unless they ask for one, so the column has to admit that. Making it
|
||||
-- nullable is one line; what it costs is that every read of it is now a
|
||||
-- question rather than a fact, and the three that compare against it with
|
||||
-- bcrypt have been made to ask first.
|
||||
--
|
||||
-- Nothing writes a NULL yet. The first accounts without a password arrive
|
||||
-- with the sign-up path, and this is deliberately landed before them so the
|
||||
-- schema change can be reviewed on its own.
|
||||
ALTER TABLE customers ALTER COLUMN password_hash DROP NOT NULL;
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`
|
||||
DROP TABLE IF EXISTS customer_identities;
|
||||
|
||||
-- Deliberately not restored. Re-adding NOT NULL fails outright if any
|
||||
-- passwordless customer exists by then, and a down migration that destroys
|
||||
-- accounts to satisfy a constraint would be far worse than a column that is
|
||||
-- merely more permissive than it needs to be. Reversing this properly means
|
||||
-- deciding what happens to those customers, which is not a schema decision.
|
||||
SELECT 1;
|
||||
`);
|
||||
};
|
||||
Generated
+1
-1329
File diff suppressed because it is too large
Load Diff
+3
-22
@@ -3,50 +3,32 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "dist/server.js",
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck:tests": "tsc -p tsconfig.test.json --noEmit",
|
||||
"lint": "eslint src scripts tests",
|
||||
"lint": "eslint src",
|
||||
"start": "node dist/server.js",
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"test": "npm run test:unit",
|
||||
"test:unit": "jest -c jest.unit.config.js",
|
||||
"test:unit:json": "jest -c jest.unit.config.js --json --outputFile=unit-results.json",
|
||||
"test:unit:cov": "jest -c jest.unit.config.js --coverage",
|
||||
"test:integration": "jest -c jest.integration.config.js --runInBand",
|
||||
"test:integration:json": "jest -c jest.integration.config.js --runInBand --json --outputFile=integration-results.json",
|
||||
"test:integration:cov": "jest -c jest.integration.config.js --runInBand --coverage --forceExit",
|
||||
"bench:hashing": "tsx scripts/bench-hash-latency.ts",
|
||||
"backfill:images": "node dist/backfillImageReencode.js",
|
||||
"db:e2e:up": "docker compose -f docker-compose.e2e.yml up -d",
|
||||
"db:e2e:down": "docker compose -f docker-compose.e2e.yml down",
|
||||
"db:test:up": "docker compose -f docker-compose.test.yml up -d",
|
||||
"db:test:down": "docker compose -f docker-compose.test.yml down -v",
|
||||
"migrate:up": "node migrate.js up",
|
||||
"migrate:down": "node migrate.js down",
|
||||
"migrate:create": "node-pg-migrate create --migration-file-language js",
|
||||
"db:types": "kysely-codegen --dialect postgres --exclude-pattern pgmigrations --url \"env(KYSELY_DATABASE_URL)\" --out-file src/db-kysely/schema.ts"
|
||||
"migrate:create": "node-pg-migrate create --migration-file-language js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.122.0",
|
||||
"@simplewebauthn/server": "^14.0.1",
|
||||
"@types/markdown-it": "^14.2.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^8.6.2",
|
||||
"kysely": "^0.28.17",
|
||||
"markdown-it": "^15.0.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"node-pg-migrate": "^7.6.1",
|
||||
"nodemailer": "^6.9.14",
|
||||
"pg": "^8.12.0",
|
||||
"sharp": "^0.35.4",
|
||||
"zod": "^4.5.4"
|
||||
"pg": "^8.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.5",
|
||||
@@ -64,7 +46,6 @@
|
||||
"eslint-plugin-sonarjs": "^4.2.0",
|
||||
"globals": "^17.11.0",
|
||||
"jest": "^29.7.0",
|
||||
"kysely-codegen": "^0.20.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.4",
|
||||
"tsx": "^4.16.5",
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
/**
|
||||
* What does concurrent password hashing cost a request that is not hashing?
|
||||
*
|
||||
* #163 originally claimed `bcryptjs` blocks the event loop and that every login
|
||||
* stalls every other request in flight. That claim was wrong — the asynchronous
|
||||
* API chunks its work and yields between rounds — but a smaller effect is real:
|
||||
* the chunks are coarse, and N simultaneous registrations still queue N hashes
|
||||
* worth of CPU that has to come from somewhere.
|
||||
*
|
||||
* This measures that effect rather than arguing about it, so any future change
|
||||
* to hashing is justified by a number and can be checked by re-running this.
|
||||
*
|
||||
* Method
|
||||
* ------
|
||||
* The probe is `GET /api/customers/me` with no session cookie. It is chosen for
|
||||
* doing almost nothing: it rejects on a missing cookie before touching the
|
||||
* database, so nearly all of its measured latency is time spent waiting for the
|
||||
* event loop rather than work of its own. A heavier probe would measure the
|
||||
* database instead, which is not the question.
|
||||
*
|
||||
* The load is real registrations against the real route, because the point is
|
||||
* what a deployed server does, not what bcrypt does on a bench.
|
||||
*
|
||||
* Registrations create real rows. They are deleted afterwards — this is
|
||||
* normally pointed at a development database that nothing truncates, and a
|
||||
* benchmark that quietly adds hundreds of customers every run would be its own
|
||||
* small problem.
|
||||
*
|
||||
* Usage
|
||||
* -----
|
||||
* npm run bench:hashing
|
||||
*
|
||||
* Honours BENCH_URL, BENCH_CONCURRENCY, BENCH_ROUNDS, and the usual PG* vars
|
||||
* for the cleanup connection.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const BASE_URL = process.env.BENCH_URL ?? 'http://localhost:3001';
|
||||
|
||||
// Eight, because that is what Playwright uses on this machine — half the cores
|
||||
// — and the end-to-end suite registering customers in parallel is one of the
|
||||
// two places #163 suggested the cost might actually show up.
|
||||
const CONCURRENCY = Number(process.env.BENCH_CONCURRENCY ?? 8);
|
||||
const ROUNDS = Number(process.env.BENCH_ROUNDS ?? 5);
|
||||
|
||||
// Long enough for the baseline to see past a single slow sample, short enough
|
||||
// that the whole run stays under a minute.
|
||||
const BASELINE_MS = 3000;
|
||||
|
||||
// Marks every row this script creates, so cleanup can be exact rather than
|
||||
// date-based. Anything left behind by a crashed run is removed by the next one.
|
||||
const EMAIL_PREFIX = 'bench-hash-';
|
||||
|
||||
interface Stats {
|
||||
count: number;
|
||||
p50: number;
|
||||
p95: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
function summarise(samples: number[]): Stats {
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
const at = (fraction: number): number => {
|
||||
// Nearest-rank, which needs no interpolation and cannot invent a value that
|
||||
// was never measured.
|
||||
const index = Math.min(sorted.length - 1, Math.ceil(fraction * sorted.length) - 1);
|
||||
return sorted[Math.max(0, index)] ?? 0;
|
||||
};
|
||||
return {
|
||||
count: sorted.length,
|
||||
p50: at(0.5),
|
||||
p95: at(0.95),
|
||||
max: sorted[sorted.length - 1] ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
async function probe(): Promise<number> {
|
||||
const started = performance.now();
|
||||
const res = await fetch(`${BASE_URL}/api/customers/me`);
|
||||
// Drained rather than ignored: leaving the body unread would stop the clock
|
||||
// before the response has actually arrived.
|
||||
await res.arrayBuffer();
|
||||
return performance.now() - started;
|
||||
}
|
||||
|
||||
async function register(index: number): Promise<number> {
|
||||
const started = performance.now();
|
||||
const res = await fetch(`${BASE_URL}/api/customers/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: `${EMAIL_PREFIX}${Date.now().toString(36)}-${index}@example.com`,
|
||||
// Not a credential: it hashes these accounts into existence and deletes
|
||||
// them again at the end of the run. The cost of hashing is the whole
|
||||
// point, so it cannot be shortened or faked.
|
||||
// eslint-disable-next-line sonarjs/no-hardcoded-passwords
|
||||
password: 'benchmark-password',
|
||||
firstName: 'Bench',
|
||||
lastName: 'Mark'
|
||||
})
|
||||
});
|
||||
await res.arrayBuffer();
|
||||
if (!res.ok) throw new Error(`registration failed with ${res.status}`);
|
||||
return performance.now() - started;
|
||||
}
|
||||
|
||||
/** Probes continuously until `until` resolves, so the samples span the load. */
|
||||
async function probeUntil(until: Promise<unknown>): Promise<number[]> {
|
||||
const samples: number[] = [];
|
||||
let done = false;
|
||||
void until.then(
|
||||
() => { done = true; },
|
||||
() => { done = true; }
|
||||
);
|
||||
while (!done) {
|
||||
samples.push(await probe());
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
async function measureBaseline(): Promise<number[]> {
|
||||
const samples: number[] = [];
|
||||
const deadline = performance.now() + BASELINE_MS;
|
||||
while (performance.now() < deadline) {
|
||||
samples.push(await probe());
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<number> {
|
||||
const pool = new Pool();
|
||||
try {
|
||||
// Sessions and tokens reference the customer, so they go first — the
|
||||
// registration route creates one of each.
|
||||
await pool.query(
|
||||
`DELETE FROM customer_sessions WHERE customer_id IN (SELECT id FROM customers WHERE email LIKE $1)`,
|
||||
[`${EMAIL_PREFIX}%`]
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM customer_tokens WHERE customer_id IN (SELECT id FROM customers WHERE email LIKE $1)`,
|
||||
[`${EMAIL_PREFIX}%`]
|
||||
);
|
||||
const { rowCount } = await pool.query(`DELETE FROM customers WHERE email LIKE $1`, [`${EMAIL_PREFIX}%`]);
|
||||
return rowCount ?? 0;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
function report(label: string, stats: Stats): void {
|
||||
console.info(
|
||||
`${label.padEnd(28)} n=${String(stats.count).padStart(4)} ` +
|
||||
`p50=${stats.p50.toFixed(1).padStart(7)} ms ` +
|
||||
`p95=${stats.p95.toFixed(1).padStart(7)} ms ` +
|
||||
`max=${stats.max.toFixed(1).padStart(7)} ms`
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.info(`[bench] ${BASE_URL}, ${CONCURRENCY} concurrent registrations x ${ROUNDS} rounds`);
|
||||
|
||||
// A cold first request pays for connection setup and JIT, which would land
|
||||
// entirely in the baseline and flatter the comparison.
|
||||
for (let i = 0; i < 10; i++) await probe();
|
||||
|
||||
const baseline = await measureBaseline();
|
||||
report('idle', summarise(baseline));
|
||||
|
||||
const underLoad: number[] = [];
|
||||
const registrations: number[] = [];
|
||||
|
||||
for (let round = 0; round < ROUNDS; round++) {
|
||||
const load = Promise.all(
|
||||
Array.from({ length: CONCURRENCY }, (_unused, index) => register(round * CONCURRENCY + index))
|
||||
);
|
||||
const [samples, times] = await Promise.all([probeUntil(load), load]);
|
||||
underLoad.push(...samples);
|
||||
registrations.push(...times);
|
||||
}
|
||||
|
||||
report(`under ${CONCURRENCY} registrations`, summarise(underLoad));
|
||||
report('the registrations', summarise(registrations));
|
||||
|
||||
const idle = summarise(baseline);
|
||||
const loaded = summarise(underLoad);
|
||||
console.info(
|
||||
`\n[bench] a bystander request costs ` +
|
||||
`${(loaded.p50 - idle.p50).toFixed(1)} ms more at p50, ` +
|
||||
`${(loaded.p95 - idle.p95).toFixed(1)} ms more at p95, ` +
|
||||
`worst case ${loaded.max.toFixed(1)} ms`
|
||||
);
|
||||
|
||||
const removed = await cleanup();
|
||||
console.info(`[bench] removed ${removed} benchmark customers`);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error('[bench] failed:', error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
import { pool } from './db';
|
||||
import { DEFAULT_DRAFTING_MODEL, DRAFTING_MODELS, isDraftingModel } from './intake/models';
|
||||
|
||||
/**
|
||||
* Every admin-configurable setting, in one table.
|
||||
*
|
||||
* `cart_expiry_hours` used to be read by an inline query in two places, each
|
||||
* with its own `|| '24'`. With several settings and read sites scattered across
|
||||
* routes and the cron job that stops being tenable: a default written twice is
|
||||
* a default that will eventually disagree with itself. Adding a setting means
|
||||
* adding a row here and nothing else.
|
||||
*
|
||||
* Values are stored as text, so each row declares how to read it back. Numbers
|
||||
* were the only kind until the greeting format arrived; typing it per setting
|
||||
* rather than assuming means the next string one costs nothing.
|
||||
*/
|
||||
/** A row of the key/value store this module reads. */
|
||||
interface SettingRow {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const DEFINITIONS = [
|
||||
{ key: 'cart_expiry_hours', name: 'cartExpiryHours', type: 'hours', fallback: 24 },
|
||||
{ key: 'verify_token_hours', name: 'verifyTokenHours', type: 'hours', fallback: 24 },
|
||||
{ key: 'password_reset_hours', name: 'passwordResetHours', type: 'hours', fallback: 1 },
|
||||
{ key: 'greeting_format', name: 'greetingFormat', type: 'text', fallback: 'Hi {{firstName}},' },
|
||||
{ key: 'greeting_fallback', name: 'greetingFallback', type: 'text', fallback: 'Hi,' },
|
||||
// A 'choice' rather than a 'text', so a mistyped model name is refused at the
|
||||
// edge instead of stored. It would otherwise fail on every submission and
|
||||
// show up only as drafts quietly not appearing (#223).
|
||||
{
|
||||
key: 'drafting_model',
|
||||
name: 'draftingModel',
|
||||
type: 'choice',
|
||||
fallback: DEFAULT_DRAFTING_MODEL
|
||||
},
|
||||
// Where the intake notification goes (#224). A setting rather than an
|
||||
// environment variable, for the same reason drafting_model is one: it is
|
||||
// changed by whoever runs the shop, not by whoever deploys it, and a redeploy
|
||||
// to change an address would be absurd. Empty means do not notify, which is
|
||||
// the default and a working configuration.
|
||||
{ key: 'intake_notify_email', name: 'intakeNotifyEmail', type: 'text', fallback: '', mayBeEmpty: true },
|
||||
// The whole intake surface over a rolling 24 hours, across every link (#227).
|
||||
// Per-link caps bound each link, but links accumulate — twenty links at the
|
||||
// default 25 is five hundred submissions nobody decided to accept.
|
||||
{ key: 'intake_daily_ceiling', name: 'intakeDailyCeiling', type: 'count', fallback: 100 },
|
||||
// Well below the ceiling, because this is the one that catches a leaked link
|
||||
// early — the case the revoke mechanism exists for, and which otherwise
|
||||
// depends on somebody happening to look.
|
||||
{
|
||||
key: 'intake_link_alert_threshold',
|
||||
name: 'intakeLinkAlertThreshold',
|
||||
type: 'count',
|
||||
fallback: 20
|
||||
},
|
||||
// An ISO timestamp, or empty. The count is derived from rows that exist, so a
|
||||
// reset cannot delete anything — it moves the window's start instead, which
|
||||
// makes it an auditable fact rather than a deletion.
|
||||
{ key: 'intake_ceiling_reset_at', name: 'intakeCeilingResetAt', type: 'text', fallback: '', mayBeEmpty: true }
|
||||
] as const;
|
||||
|
||||
type Definition = (typeof DEFINITIONS)[number];
|
||||
|
||||
export type SettingName = Definition['name'];
|
||||
|
||||
export type HoursSettingName = Extract<Definition, { type: 'hours' }>['name'];
|
||||
export type TextSettingName = Extract<Definition, { type: 'text' }>['name'];
|
||||
export type ChoiceSettingName = Extract<Definition, { type: 'choice' }>['name'];
|
||||
export type CountSettingName = Extract<Definition, { type: 'count' }>['name'];
|
||||
|
||||
export type AdminSettings = Record<HoursSettingName, number> &
|
||||
Record<TextSettingName, string> &
|
||||
Record<ChoiceSettingName, string> &
|
||||
Record<CountSettingName, number>;
|
||||
|
||||
export const HOURS_SETTINGS: readonly HoursSettingName[] = DEFINITIONS.filter(
|
||||
(d): d is Extract<Definition, { type: 'hours' }> => d.type === 'hours'
|
||||
).map(d => d.name);
|
||||
|
||||
/**
|
||||
* The text settings for which empty is a value rather than a mistake.
|
||||
*
|
||||
* Declared on the setting, beside its type and fallback, rather than in the
|
||||
* validator — whether a setting may be cleared is a fact about that setting,
|
||||
* and a new one should state it once in the row it already has. The blanket
|
||||
* refusal stays the default, because for a setting with a non-empty fallback
|
||||
* an empty value really is a mistake: an empty greeting format renders every
|
||||
* greeting as nothing, which reads as a broken email. See #280.
|
||||
*/
|
||||
export function mayBeEmpty(name: SettingName): boolean {
|
||||
return DEFINITIONS.some((d) => d.name === name && 'mayBeEmpty' in d && d.mayBeEmpty);
|
||||
}
|
||||
|
||||
export const TEXT_SETTINGS: readonly TextSettingName[] = DEFINITIONS.filter(
|
||||
(d): d is Extract<Definition, { type: 'text' }> => d.type === 'text'
|
||||
).map(d => d.name);
|
||||
|
||||
export const CHOICE_SETTINGS: readonly ChoiceSettingName[] = DEFINITIONS.filter(
|
||||
(d): d is Extract<Definition, { type: 'choice' }> => d.type === 'choice'
|
||||
).map(d => d.name);
|
||||
|
||||
/**
|
||||
* The values each choice setting will accept, for the route to validate against
|
||||
* and the admin UI to offer. Derived from the model catalogue rather than
|
||||
* restated, so the dropdown cannot come to disagree with what is billable.
|
||||
*/
|
||||
export const CHOICE_OPTIONS: Readonly<Record<ChoiceSettingName, readonly string[]>> = {
|
||||
draftingModel: DRAFTING_MODELS.map(m => m.id)
|
||||
};
|
||||
|
||||
export function isValidChoice(name: ChoiceSettingName, value: string): boolean {
|
||||
return name === 'draftingModel' ? isDraftingModel(value) : false;
|
||||
}
|
||||
|
||||
// One reader per type, at module level rather than branched inline. The same
|
||||
// reasoning as the definitions above: getSettings should read as "look each one
|
||||
// up and resolve it", and a third type was enough to push the inline version
|
||||
// past the complexity limit.
|
||||
|
||||
// An empty format would render every greeting as nothing at all, which reads as
|
||||
// a bug in the email rather than a setting someone cleared.
|
||||
function resolveText(raw: string | undefined, fallback: string): string {
|
||||
return raw !== undefined && raw.trim() !== '' ? raw : fallback;
|
||||
}
|
||||
|
||||
// A row that is present but unparseable falls back rather than yielding NaN,
|
||||
// which would otherwise reach Date arithmetic and mint a token with an Invalid
|
||||
// Date expiry that no query could ever match.
|
||||
function resolveHours(raw: string | undefined, fallback: number): number {
|
||||
const parsed = parseFloat(raw ?? '');
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
// Whole submissions, so a ceiling of 12.5 is a typo rather than a preference.
|
||||
// Falls back rather than yielding NaN for the same reason resolveHours does: a
|
||||
// NaN ceiling compares false against everything and would silently disable the
|
||||
// limit it was set to impose.
|
||||
function resolveCount(raw: string | undefined, fallback: number): number {
|
||||
const parsed = parseInt(raw ?? '', 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
// A stored value that is no longer offered — a model retired since it was
|
||||
// chosen — falls back rather than being handed on. Drafting with the default
|
||||
// beats drafting with a model the API will refuse.
|
||||
function resolveChoice(
|
||||
name: ChoiceSettingName,
|
||||
raw: string | undefined,
|
||||
fallback: string
|
||||
): string {
|
||||
return raw !== undefined && isValidChoice(name, raw) ? raw : fallback;
|
||||
}
|
||||
|
||||
export async function getSettings(): Promise<AdminSettings> {
|
||||
const { rows } = await pool.query<SettingRow>(`SELECT key, value FROM admin_settings`);
|
||||
const stored = new Map<string, string>(rows.map(r => [r.key, r.value]));
|
||||
|
||||
const settings = {} as Record<SettingName, number | string>;
|
||||
for (const definition of DEFINITIONS) {
|
||||
const raw = stored.get(definition.key);
|
||||
if (definition.type === 'choice') {
|
||||
settings[definition.name] = resolveChoice(definition.name, raw, definition.fallback);
|
||||
} else if (definition.type === 'count') {
|
||||
settings[definition.name] = resolveCount(raw, definition.fallback);
|
||||
} else if (definition.type === 'text') {
|
||||
settings[definition.name] = resolveText(raw, definition.fallback);
|
||||
} else {
|
||||
settings[definition.name] = resolveHours(raw, definition.fallback);
|
||||
}
|
||||
}
|
||||
return settings as AdminSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the supplied settings, ignoring names that were not sent.
|
||||
*
|
||||
* Partial rather than whole-object so a caller updating one field does not have
|
||||
* to know the current value of the others to avoid clobbering them.
|
||||
*/
|
||||
export async function updateSettings(
|
||||
values: Partial<Record<SettingName, number | string>>
|
||||
): Promise<void> {
|
||||
for (const { key, name } of DEFINITIONS) {
|
||||
const value = values[name];
|
||||
if (value === undefined) continue;
|
||||
await pool.query(
|
||||
`INSERT INTO admin_settings (key, value, updated_at) VALUES ($1, $2, now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = now()`,
|
||||
[key, String(value)]
|
||||
);
|
||||
}
|
||||
}
|
||||
+7
-77
@@ -6,30 +6,15 @@ import { router as cartCheckoutRouter, webhookRouter as cartCheckoutWebhookRoute
|
||||
import adminRouter from './routes/admin';
|
||||
import adminCustomersRouter from './routes/adminCustomers';
|
||||
import adminSettingsRouter from './routes/adminSettings';
|
||||
import adminEmailTemplatesRouter from './routes/adminEmailTemplates';
|
||||
import adminCategoriesRouter from './routes/adminCategories';
|
||||
import adminTagsRouter from './routes/adminTags';
|
||||
import adminUploadLinksRouter from './routes/adminUploadLinks';
|
||||
import adminItemDraftsRouter from './routes/adminItemDrafts';
|
||||
import intakeActionsRouter from './routes/intakeActions';
|
||||
import intakeRouter from './routes/intake';
|
||||
import adminVersionRouter from './routes/adminVersion';
|
||||
import adminConfigRouter from './routes/adminConfig';
|
||||
import filtersRouter from './routes/filters';
|
||||
import customersRouter from './routes/customers';
|
||||
import passkeysRouter from './routes/passkeys';
|
||||
import passkeyLoginRouter from './routes/passkeyLogin';
|
||||
import googleAuthRouter from './routes/googleAuth';
|
||||
import { googleConfig } from './google/config';
|
||||
import publicRouter from './routes/public';
|
||||
import cartRouter from './routes/cart';
|
||||
import shippingAddressesRouter from './routes/shippingAddresses';
|
||||
import clientErrorsRouter from './routes/clientErrors';
|
||||
import { attachCustomer } from './middleware/customerAuth';
|
||||
import { requireAdminGate } from './middleware/adminGate';
|
||||
import { asyncRoute } from './asyncRoute';
|
||||
import { uploadsRouter } from './uploads';
|
||||
import { trimTrailingSlashes } from './utils';
|
||||
|
||||
const app = express();
|
||||
// Express advertises itself in X-Powered-By by default, which hands an
|
||||
@@ -44,7 +29,7 @@ app.use(cookieParser());
|
||||
// globally an unforwarded rejection here would hang every request in the app —
|
||||
// including the routes that wrap their own handlers correctly.
|
||||
app.use(asyncRoute(attachCustomer));
|
||||
app.use('/uploads', uploadsRouter(process.env.UPLOADS_DIR || '/app/uploads'));
|
||||
app.use('/uploads', express.static(process.env.UPLOADS_DIR || '/app/uploads'));
|
||||
|
||||
app.get('/api/config', (_req, res) => {
|
||||
const clientId = process.env.PAYPAL_CLIENT_ID;
|
||||
@@ -52,76 +37,21 @@ app.get('/api/config', (_req, res) => {
|
||||
res.json({
|
||||
paypalClientId: isPlaceholder ? null : clientId,
|
||||
demoMode: process.env.DEMO_MODE !== 'false',
|
||||
currency: process.env.SITE_CURRENCY || 'USD',
|
||||
// Where uploaded images should be fetched from (#103). Empty means the
|
||||
// app's own origin, which is both the default and what local development
|
||||
// has — there is no second hostname on a laptop. Set it to a hostname of
|
||||
// its own in production and user-supplied files stop sharing an origin with
|
||||
// the application, which is the whole unit of trust in a browser.
|
||||
//
|
||||
// Sent at runtime rather than built in, so one image serves every
|
||||
// environment, the same reason paypalClientId and demoMode are here.
|
||||
//
|
||||
// Trailing slash trimmed so callers can join with a stored path, which
|
||||
// always begins with one, without producing a double.
|
||||
uploadsBaseUrl: trimTrailingSlashes(process.env.UPLOADS_BASE_URL ?? ''),
|
||||
// Brevo's Marketing Automation key (#56). Not a secret — it ships to the
|
||||
// browser by design — but it differs per environment, which is the whole
|
||||
// reason it is here rather than built in.
|
||||
//
|
||||
// Null when unset, and the tracker never loads without it. That is what
|
||||
// keeps QA out of production's Brevo account: QA sets no key, so no QA
|
||||
// browsing is ever reported, and there is no flag anyone can forget to
|
||||
// turn off. Same shape as paypalClientId above.
|
||||
brevoTrackerKey: process.env.BREVO_TRACKER_KEY?.trim() || null,
|
||||
// Whether to offer the Google button at all (#345). A boolean, never the
|
||||
// client id: the browser does not need it, because the whole flow is a
|
||||
// redirect this server builds.
|
||||
//
|
||||
// Absent rather than disabled is the point. A developer with no credentials
|
||||
// gets a storefront that works and simply does not offer the option, the
|
||||
// same choice #41 made for a browser without WebAuthn — and QA, which
|
||||
// cannot have credentials until #313, gets the same.
|
||||
googleSignIn: googleConfig().enabled
|
||||
currency: process.env.SITE_CURRENCY || 'USD'
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api/items', itemsRouter);
|
||||
app.use('/api/filters', filtersRouter);
|
||||
app.use('/api/cart', cartRouter);
|
||||
// Public and unauthenticated by design (#222). No requireAdminGate: the token
|
||||
// in the path is the whole access control, and every refusal is a 404.
|
||||
app.use('/api/intake', intakeRouter);
|
||||
app.use('/api/intake-actions', intakeActionsRouter);
|
||||
app.use('/api/checkout/cart', cartCheckoutRouter);
|
||||
// requireAdminGate is attached to each admin router rather than to a path
|
||||
// prefix. Attached to the router, an admin router added later at some other
|
||||
// path still inherits it — and since the proxy only injects the header on the
|
||||
// paths its regex matches, that router refuses loudly on its first request
|
||||
// instead of being quietly public. See middleware/adminGate.ts and #63.
|
||||
app.use('/api/admin/customers', requireAdminGate, adminCustomersRouter);
|
||||
app.use('/api/admin/settings', requireAdminGate, adminSettingsRouter);
|
||||
app.use('/api/admin/email-templates', requireAdminGate, adminEmailTemplatesRouter);
|
||||
app.use('/api/admin/categories', requireAdminGate, adminCategoriesRouter);
|
||||
app.use('/api/admin/tags', requireAdminGate, adminTagsRouter);
|
||||
app.use('/api/admin/upload-links', requireAdminGate, adminUploadLinksRouter);
|
||||
app.use('/api/admin/item-drafts', requireAdminGate, adminItemDraftsRouter);
|
||||
app.use('/api/admin/version', requireAdminGate, adminVersionRouter);
|
||||
app.use('/api/admin/config', requireAdminGate, adminConfigRouter);
|
||||
app.use('/api/admin', requireAdminGate, adminRouter);
|
||||
app.use('/api/admin/customers', adminCustomersRouter);
|
||||
app.use('/api/admin/settings', adminSettingsRouter);
|
||||
app.use('/api/admin/categories', adminCategoriesRouter);
|
||||
app.use('/api/admin/tags', adminTagsRouter);
|
||||
app.use('/api/admin', adminRouter);
|
||||
app.use('/api/customers/me/addresses', shippingAddressesRouter);
|
||||
// Before /api/customers, like the addresses router above: Express matches
|
||||
// mounts in order, so the broader prefix would swallow these otherwise (#38).
|
||||
app.use('/api/customers/me/passkeys', passkeysRouter);
|
||||
// Unauthenticated, unlike the router above: this is how a customer becomes
|
||||
// signed in, so it cannot sit behind requireCustomer (#39).
|
||||
app.use('/api/customers/passkeys', passkeyLoginRouter);
|
||||
// Its own prefix rather than under /api/customers: this is the one route a
|
||||
// third party redirects a browser into, and the callback path is registered
|
||||
// verbatim in Google's console (#341).
|
||||
app.use('/api/auth/google', googleAuthRouter);
|
||||
app.use('/api/customers', customersRouter);
|
||||
app.use('/api/client-errors', clientErrorsRouter);
|
||||
app.use('/', publicRouter);
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
/**
|
||||
* Applies #226's re-encoding to the photos that were stored before it existed.
|
||||
*
|
||||
* New uploads are handled in the request path. Everything already on the volume
|
||||
* still carries whatever the camera wrote, including the coordinates the photo
|
||||
* was taken at, and is still served publicly. This is the other half.
|
||||
*
|
||||
* The transform is lossy and there is no undo, so:
|
||||
*
|
||||
* - It reports by default and changes nothing without --apply.
|
||||
* - It is idempotent. `needsProcessing` skips a file that is already stripped
|
||||
* and already within bounds, so a second run is not a second lossy pass.
|
||||
* - `reencodeInPlace` writes to a temporary file and renames, so an
|
||||
* interruption cannot leave a half-written image being served.
|
||||
* - It never renames the stored file, so item_images.image_path stays correct
|
||||
* and no database write is needed at all.
|
||||
*
|
||||
* It lives in src/ rather than scripts/ so that it compiles into dist and ships
|
||||
* in the container image. `scripts/` is excluded by tsconfig, is never copied by
|
||||
* the Dockerfile, and would need `tsx` — a devDependency that `npm install
|
||||
* --omit=dev` removes. An operational task that can only be useful where the
|
||||
* images are has to be somewhere the image actually carries it, which is the
|
||||
* same reason `migrate.js` sits where it does. See #231.
|
||||
*
|
||||
* Usage
|
||||
* -----
|
||||
* Locally, after `npm run build`:
|
||||
*
|
||||
* npm run backfill:images # report only
|
||||
* npm run backfill:images -- --apply # rewrite the files
|
||||
*
|
||||
* In a deployed container, identically — package.json ships in the image:
|
||||
*
|
||||
* docker exec <container> npm run backfill:images
|
||||
* docker exec <container> npm run backfill:images -- --apply
|
||||
*
|
||||
* Point it at QA first. Compare a handful of images by eye before production,
|
||||
* and take a backup that you have confirmed restores.
|
||||
*/
|
||||
|
||||
import sharp from 'sharp';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { pool } from './db';
|
||||
import { needsProcessing, reencodeInPlace } from './imageProcessing';
|
||||
import { typeForExtension } from './uploadTypes';
|
||||
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/app/uploads';
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
|
||||
interface Totals {
|
||||
seen: number;
|
||||
missing: number;
|
||||
skipped: number;
|
||||
unrecognised: number;
|
||||
processed: number;
|
||||
failed: number;
|
||||
bytesBefore: number;
|
||||
bytesAfter: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One stored image: classify it, and rewrite it when it needs rewriting.
|
||||
*
|
||||
* Split out of `run` so that the loop reads as a loop. Every outcome is
|
||||
* counted rather than thrown, because one unreadable file in a catalogue is
|
||||
* not a reason to leave the rest of it exposed.
|
||||
*/
|
||||
async function handleRow(imagePath: string, totals: Totals): Promise<void> {
|
||||
// basename only: image_path is '/uploads/<name>', and the directory it is
|
||||
// served from is a server constant rather than part of the stored value.
|
||||
const filePath = path.join(UPLOADS_DIR, path.basename(imagePath));
|
||||
// typeForExtension rather than a copy of its table. The stored extension is
|
||||
// the file's real type — uploadTypes.ts derives it from the validated content
|
||||
// type on the way in — and this rewrites stored images, so a private copy
|
||||
// drifting from the real one would silently skip files it should re-encode.
|
||||
// It lowercases its own input, so the call site does not.
|
||||
const mimetype = typeForExtension(path.extname(filePath));
|
||||
|
||||
if (!mimetype) {
|
||||
console.warn(`[backfill] unrecognised extension, skipping: ${imagePath}`);
|
||||
totals.unrecognised++;
|
||||
return;
|
||||
}
|
||||
|
||||
let before: number;
|
||||
try {
|
||||
before = (await fs.stat(filePath)).size;
|
||||
} catch {
|
||||
// A row pointing at nothing is a pre-existing inconsistency. Reported
|
||||
// rather than fatal: it is not this script's job to fix, and stopping
|
||||
// would leave the rest of the catalogue exposed.
|
||||
console.warn(`[backfill] file missing for ${imagePath}`);
|
||||
totals.missing++;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const meta = await sharp(filePath).metadata();
|
||||
|
||||
if (!needsProcessing(meta)) {
|
||||
totals.skipped++;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!APPLY) {
|
||||
console.info(
|
||||
`[backfill] would process ${imagePath} ` +
|
||||
`(${meta.width}x${meta.height}, exif ${meta.exif ? 'present' : 'absent'}, ${before} bytes)`
|
||||
);
|
||||
totals.processed++;
|
||||
totals.bytesBefore += before;
|
||||
return;
|
||||
}
|
||||
|
||||
await reencodeInPlace(filePath, mimetype);
|
||||
const after = (await fs.stat(filePath)).size;
|
||||
// Counted only once the rewrite succeeded, so a file that threw is `failed`
|
||||
// and nothing else. A row counted as both processed and failed would make
|
||||
// the summary unreadable at exactly the moment it matters.
|
||||
totals.processed++;
|
||||
totals.bytesBefore += before;
|
||||
totals.bytesAfter += after;
|
||||
console.info(`[backfill] ${imagePath}: ${before} -> ${after} bytes`);
|
||||
} catch (err) {
|
||||
console.error(`[backfill] failed on ${imagePath}:`, err);
|
||||
totals.failed++;
|
||||
}
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const totals: Totals = {
|
||||
seen: 0,
|
||||
missing: 0,
|
||||
skipped: 0,
|
||||
unrecognised: 0,
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
bytesBefore: 0,
|
||||
bytesAfter: 0
|
||||
};
|
||||
|
||||
const { rows } = await pool.query<{ image_path: string }>(
|
||||
`SELECT image_path FROM item_images ORDER BY id`
|
||||
);
|
||||
|
||||
console.info(
|
||||
`[backfill] ${rows.length} image rows in ${UPLOADS_DIR}, ` +
|
||||
`${APPLY ? 'APPLYING CHANGES' : 'reporting only (pass --apply to rewrite)'}`
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
totals.seen++;
|
||||
await handleRow(row.image_path, totals);
|
||||
}
|
||||
|
||||
console.info('[backfill] done', totals);
|
||||
|
||||
if (totals.failed > 0) {
|
||||
// A non-zero exit so a partial run is visible to whatever invoked it,
|
||||
// rather than reading as success because the summary printed.
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Guarded rather than run on import. This file lives in src/ so that it
|
||||
// compiles into dist and therefore ships in the image (#231) — but that puts a
|
||||
// catalogue-wide, irreversible rewrite in the same directory as the modules the
|
||||
// server imports at boot. Without this guard, importing it by mistake would run
|
||||
// it. Nothing imports it today; the guard is here so that staying true does not
|
||||
// depend on anyone noticing.
|
||||
if (require.main === module) {
|
||||
run()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => pool.end());
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Which build is running, so a deployed environment can say so.
|
||||
*
|
||||
* There was previously no way to tell. On 2026-08-29 a QA container kept
|
||||
* serving a pre-merge image after its stack was rebuilt, and the only thing
|
||||
* that revealed it was npm happening to echo an old script line — had the
|
||||
* change been anywhere other than a package.json script, the container would
|
||||
* have looked healthy while running the wrong code. See #233.
|
||||
*
|
||||
* The commit is read out of `.git` directly rather than by shelling out to
|
||||
* git: `node:20-bookworm-slim` has no git binary, and adding an apt layer to
|
||||
* this image so that it can print seven characters is a poor trade.
|
||||
*
|
||||
* Everything here fails to `unknown` rather than throwing. This runs during a
|
||||
* Docker build, and a version stamp must never be the thing that stops a
|
||||
* deploy.
|
||||
*/
|
||||
|
||||
export const UNKNOWN_COMMIT = 'unknown';
|
||||
|
||||
/** Full 40-character object name, which is what both HEAD and refs contain. */
|
||||
const SHA_PATTERN = /^[0-9a-f]{40}$/;
|
||||
|
||||
const SHORT_LENGTH = 7;
|
||||
|
||||
/**
|
||||
* The `.git` files this needs, as content rather than paths.
|
||||
*
|
||||
* Passed in rather than read here so the resolution rules are pure and can be
|
||||
* tested without a repository on disk — the same reasoning `uploadTypes.ts`
|
||||
* and `keyByCallerAndEmail` are shaped by.
|
||||
*/
|
||||
export interface GitSource {
|
||||
/** `.git/HEAD`, or null when there is no `.git` at all. */
|
||||
head: string | null;
|
||||
/** `.git/<ref>` for a symbolic HEAD, or null when the ref is packed. */
|
||||
readRef(ref: string): string | null;
|
||||
/** `.git/packed-refs`, or null when the repository has none. */
|
||||
packedRefs: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds `<sha> <ref>` in a packed-refs file.
|
||||
*
|
||||
* Lines beginning `#` are the header and lines beginning `^` are the object an
|
||||
* annotated tag points at — neither is a ref, and treating a `^` line as one
|
||||
* would return the wrong commit for any tag.
|
||||
*/
|
||||
function fromPackedRefs(packedRefs: string, ref: string): string | null {
|
||||
for (const line of packedRefs.split('\n')) {
|
||||
if (line.startsWith('#') || line.startsWith('^')) continue;
|
||||
const [sha, name] = line.trim().split(/\s+/);
|
||||
if (name === ref && sha && SHA_PATTERN.test(sha)) return sha;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The short commit for a checkout, or `unknown`.
|
||||
*
|
||||
* Two shapes of HEAD are possible and both occur here: a detached HEAD holds
|
||||
* the object name directly, which is what a checkout of a specific ref
|
||||
* produces, and a symbolic HEAD holds `ref: refs/heads/<name>`, which is what
|
||||
* a working clone has. The ref may be loose or packed, and a fresh clone
|
||||
* commonly packs it.
|
||||
*/
|
||||
export function resolveCommit(source: GitSource): string {
|
||||
const head = source.head?.trim();
|
||||
if (!head) return UNKNOWN_COMMIT;
|
||||
|
||||
if (SHA_PATTERN.test(head)) {
|
||||
return head.slice(0, SHORT_LENGTH);
|
||||
}
|
||||
|
||||
if (!head.startsWith('ref:')) {
|
||||
// Neither a ref line nor an object name. Returning it verbatim would put
|
||||
// whatever the file happened to contain onto the admin screen.
|
||||
return UNKNOWN_COMMIT;
|
||||
}
|
||||
|
||||
const ref = head.slice('ref:'.length).trim();
|
||||
if (!ref) return UNKNOWN_COMMIT;
|
||||
|
||||
const loose = source.readRef(ref)?.trim();
|
||||
if (loose && SHA_PATTERN.test(loose)) {
|
||||
return loose.slice(0, SHORT_LENGTH);
|
||||
}
|
||||
|
||||
const packed = source.packedRefs ? fromPackedRefs(source.packedRefs, ref) : null;
|
||||
return packed ? packed.slice(0, SHORT_LENGTH) : UNKNOWN_COMMIT;
|
||||
}
|
||||
|
||||
/** Reads a file, treating any failure as absence. */
|
||||
function readOrNull(filePath: string): string | null {
|
||||
try {
|
||||
return readFileSync(filePath, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A `GitSource` backed by a real `.git` directory. */
|
||||
export function gitSourceAt(gitDir: string): GitSource {
|
||||
return {
|
||||
head: readOrNull(path.join(gitDir, 'HEAD')),
|
||||
// The ref is a repository-relative path with forward slashes; join splits
|
||||
// it correctly on both platforms.
|
||||
readRef: (ref) => readOrNull(path.join(gitDir, ...ref.split('/'))),
|
||||
packedRefs: readOrNull(path.join(gitDir, 'packed-refs'))
|
||||
};
|
||||
}
|
||||
|
||||
export interface BuildInfo {
|
||||
commit: string;
|
||||
builtAt: string | null;
|
||||
}
|
||||
|
||||
/** Where the build writes its stamp, and where the server reads it back. */
|
||||
export const BUILD_INFO_PATH = path.join(__dirname, 'buildInfo.json');
|
||||
|
||||
const MISSING: BuildInfo = { commit: UNKNOWN_COMMIT, builtAt: null };
|
||||
|
||||
let cached: BuildInfo | null = null;
|
||||
|
||||
/**
|
||||
* The stamp written at build time.
|
||||
*
|
||||
* Read once and cached: it cannot change while the process lives, and this is
|
||||
* on a request path. Absent in local development, where nothing has been
|
||||
* built — reported as unknown rather than treated as an error, so `npm run
|
||||
* dev` is unaffected.
|
||||
*/
|
||||
export function readBuildInfo(): BuildInfo {
|
||||
if (cached) return cached;
|
||||
|
||||
const raw = readOrNull(BUILD_INFO_PATH);
|
||||
if (!raw) {
|
||||
cached = MISSING;
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<BuildInfo>;
|
||||
cached = {
|
||||
commit: typeof parsed.commit === 'string' ? parsed.commit : UNKNOWN_COMMIT,
|
||||
builtAt: typeof parsed.builtAt === 'string' ? parsed.builtAt : null
|
||||
};
|
||||
} catch {
|
||||
// A malformed stamp is not worth failing a boot over.
|
||||
cached = MISSING;
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import crypto from 'crypto';
|
||||
import { Response } from 'express';
|
||||
import { pool } from './db';
|
||||
|
||||
/**
|
||||
* Establishing a signed-in session, for every way of signing in.
|
||||
*
|
||||
* Lifted out of routes/customers.ts when passkey authentication arrived (#39),
|
||||
* which requires that a passkey sign-in "go through the same session creation as
|
||||
* password login, so cookie flags, expiry, and logout behave identically. A
|
||||
* second, subtly different session path is how auth bugs get in."
|
||||
*
|
||||
* Shared rather than copied is what makes that true rather than merely intended.
|
||||
* Two implementations that agree today are two implementations that can be
|
||||
* changed one at a time — and the one that would be forgotten is whichever is
|
||||
* not the password path, because that is the one every manual test exercises.
|
||||
*
|
||||
* Anything that establishes a session belongs here: password login,
|
||||
* registration, password reset, passkeys, and social sign-in when #332 lands.
|
||||
*/
|
||||
|
||||
export const SESSION_DAYS = 30;
|
||||
|
||||
const SESSION_MS = SESSION_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function setSessionCookie(res: Response, token: string): void {
|
||||
res.cookie('rd_session', token, {
|
||||
httpOnly: true,
|
||||
// Gated on NODE_ENV rather than hardcoded true, or the integration tests —
|
||||
// plain HTTP, no TLS — would silently fail to persist a session and every
|
||||
// signed-in assertion would fail for a reason that looks unrelated.
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: SESSION_MS
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSession(customerId: number): Promise<string> {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + SESSION_MS);
|
||||
await pool.query(
|
||||
`INSERT INTO customer_sessions (token, customer_id, expires_at) VALUES ($1, $2, $3)`,
|
||||
[token, customerId, expiresAt]
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Mints a session and sets its cookie — the whole of "sign this customer in". */
|
||||
export async function signIn(res: Response, customerId: number): Promise<void> {
|
||||
setSessionCookie(res, await createSession(customerId));
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { pool } from './db';
|
||||
import { sendMail } from './mailer';
|
||||
import { renderTemplate, greeting, formatDuration } from './emailTemplates';
|
||||
import { getSettings } from './adminSettings';
|
||||
import { loadStoredTemplate } from './routes/adminEmailTemplates';
|
||||
|
||||
/**
|
||||
* Issuing a "confirm this address" link, for every route that changes an address.
|
||||
*
|
||||
* Lifted out of routes/customers.ts when the admin gained the ability to move an
|
||||
* account to a new address (#337), for the same reason session creation was
|
||||
* lifted out for passkeys: two implementations that agree today are two
|
||||
* implementations that can be changed one at a time, and the one that would be
|
||||
* forgotten is whichever the manual testing does not exercise. The admin path
|
||||
* runs perhaps once a year, so it is exactly the one that would rot.
|
||||
*
|
||||
* Anything that puts a new address on an account belongs here: registration, a
|
||||
* resend, the customer changing their own, and the shop changing it for them.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supersedes any outstanding link as part of issuing the new one, so a message
|
||||
* already sitting in an old inbox cannot verify a newer address. Deleting first
|
||||
* is the part that matters — an un-superseded link means an older message still
|
||||
* verifies.
|
||||
*
|
||||
* Sending is fire-and-forget by the rule the rest of this codebase follows: the
|
||||
* token row is written first, so a send that fails cannot leave a customer
|
||||
* believing a link exists that does not, only waiting for one that never came.
|
||||
*/
|
||||
export async function issueVerificationEmail(
|
||||
customerId: number,
|
||||
email: string,
|
||||
firstName: string | null,
|
||||
lastName: string | null = null
|
||||
): Promise<void> {
|
||||
await pool.query(
|
||||
`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'verify_email'`,
|
||||
[customerId]
|
||||
);
|
||||
const { verifyTokenHours, greetingFormat, greetingFallback } = await getSettings();
|
||||
const token = crypto.randomBytes(24).toString('hex');
|
||||
await pool.query(
|
||||
`INSERT INTO customer_tokens (token, customer_id, kind, expires_at) VALUES ($1, $2, 'verify_email', $3)`,
|
||||
[token, customerId, new Date(Date.now() + verifyTokenHours * 60 * 60 * 1000)]
|
||||
);
|
||||
const verifyUrl = `${process.env.PUBLIC_URL}/verify-email?token=${token}`;
|
||||
const template = renderTemplate('verification', await loadStoredTemplate('verification'), {
|
||||
greeting: greeting(firstName, greetingFormat, greetingFallback, lastName),
|
||||
firstName: firstName ?? '',
|
||||
lastName: lastName ?? '',
|
||||
verifyUrl,
|
||||
expiresIn: formatDuration(verifyTokenHours)
|
||||
});
|
||||
sendMail(email, template.subject, template.html)
|
||||
.catch(err => console.error('verify email send failed', err));
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
# Kysely conventions
|
||||
|
||||
Decided in #216, rebuilt on Kysely in #305 for the reasons in #297. Read this before converting a query.
|
||||
|
||||
## What is in this directory
|
||||
|
||||
| File | Owner |
|
||||
|---|---|
|
||||
| `schema.ts` | **Generated.** `kysely-codegen` output. Do not hand-edit. |
|
||||
| `CONVENTIONS.md` | This file. |
|
||||
|
||||
`backend/migrations` owns the schema. `schema.ts` is a read-only mirror of it, and refreshing that mirror is a manual step:
|
||||
|
||||
```bash
|
||||
KYSELY_DATABASE_URL=postgres://user:pass@localhost:PORT/db npm run db:types
|
||||
```
|
||||
|
||||
Run it against a database with every migration applied, after writing a migration. `schemaMirror.integration.test.ts` fails when the mirror and the database disagree, on tables or on columns.
|
||||
|
||||
That test exists because the drift is silent and already happened: the mirror sat missing `item_drafts` and `upload_links` from the moment #222 landed until #217, and nobody had reason to look. A stale mirror is worse than none — row types are inferred from it, so a converted query type-checks against a schema the database does not have and fails at run time on a column that does not exist.
|
||||
|
||||
## The reason this is worth doing
|
||||
|
||||
`${value}` in a Kysely `sql` template emits a bind parameter, never text. There is no way to spell "interpolate this value as SQL" by accident: the escape hatch that looks like a plain template literal does not behave like one. Passing `"1); DROP TABLE items; --"` as a status value puts it in the parameters, not in the SQL.
|
||||
|
||||
That makes the #202 invariant — only placeholder indices may be interpolated into a clause — a property of the type system rather than a comment guarded by two mutation tests, and it retires #180's three S2077 hotspots rather than leaving them reviewed and watched.
|
||||
|
||||
## Both drivers run at once
|
||||
|
||||
`db` and `pool` are exported from `src/db.ts` and share one pool, deliberately. Conversion is file by file across 238 sites, so most queries will be raw `pg` for a long time and the two must not open separate connection pools — a transaction on one would be invisible to the other, and the configured limits would silently double.
|
||||
|
||||
Column names need no translation. The generated types carry the database's own snake_case, which is also what these APIs answer with, so a select names the columns it wants and the JSON comes out right. Do not turn on kysely-codegen's `--camel-case`: it would reintroduce a mapping layer whose failure mode is a silently changed response that no status-code test catches.
|
||||
|
||||
## Driver errors are not wrapped
|
||||
|
||||
Kysely uses the `pg` driver directly, so a Postgres SQLSTATE stays on `err.code`. This is worth stating only because it was not true before: Drizzle wrapped driver errors and moved the code to `err.cause.code`, so a `catch` keyed on it still compiled, never matched, and turned a handled 409 into a 500. `adminCategories.ts` has `isUniqueViolation`, which accepts both shapes and has an integration test behind it. Reuse that pattern, and keep the test.
|
||||
|
||||
## The worked example
|
||||
|
||||
`buildItemFilterSql` was the hardest query in the codebase — six optional clauses composed at run time, a recursive CTE for the category subtree, an `ANY(...::int[])` tag match with a count equality, and array parameters. It was the #216 spike's target and #297's, and #308 converted it: it is now `itemFilterExpressions` in `src/itemFilters.ts`, returning Kysely expressions that the storefront and admin listings compose with `eb.and`. What follows is the shape it took, kept here because it is the worked reference for converting anything else of that difficulty.
|
||||
|
||||
```ts
|
||||
if (filters.categoryIds.length) {
|
||||
clauses.push(sql<SqlBool>`items.category_id IN (
|
||||
WITH RECURSIVE subtree AS (
|
||||
SELECT id FROM categories WHERE id = ANY(${filters.categoryIds}::int[])
|
||||
UNION ALL
|
||||
SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id
|
||||
)
|
||||
SELECT id FROM subtree
|
||||
)`);
|
||||
}
|
||||
|
||||
if (filters.tagIds.length) {
|
||||
clauses.push(sql<SqlBool>`(
|
||||
SELECT COUNT(*) FROM item_tags it
|
||||
WHERE it.item_id = items.id AND it.tag_id = ANY(${filters.tagIds}::int[])
|
||||
) = ${filters.tagIds.length}`);
|
||||
}
|
||||
|
||||
if (filters.status !== null) clauses.push(eb('items.status', 'in', filters.status));
|
||||
```
|
||||
|
||||
Two things in there are worth pointing at, because both were traps in the previous library and are not traps here.
|
||||
|
||||
`${filters.categoryIds}` emits **one** bind parameter holding the whole array — `ANY($1::int[])` — rather than a placeholder list. Drizzle emitted `ANY(($1, $2)::int[])`, which is invalid Postgres, unless every array site remembered `sql.param()`.
|
||||
|
||||
The column references inside those templates are text you wrote and qualified yourself, so `it.item_id = items.id` means what it says. Drizzle rendered an interpolated column reference without its table, so a correlated subquery silently correlated with itself — valid SQL, quietly wrong data, and the reason #218 got a count of 1 where 2 was correct.
|
||||
|
||||
That second one is why a converted query containing a correlated subquery or a self-join still deserves a test asserting **values** rather than a status code. The library no longer makes the mistake for you; writing the wrong column name in a raw fragment is still your own to make.
|
||||
|
||||
## Migrations stay hand-written
|
||||
|
||||
Decided in **#219** and unchanged by #305: `node-pg-migrate` keeps the schema, the builder is for queries only.
|
||||
|
||||
Three reasons, all measured rather than assumed. `drizzle-kit generate` could not diff expression indexes, so adding one nullable column emitted six statements — three `DROP INDEX` and three `CREATE UNIQUE INDEX` alongside the `ALTER` — and those rebuilds take real locks on a large table. Our migrations are mostly prose, and generated SQL carries none of it: a rule that every generated migration is annotated before merge is a rule that holds for three migrations and then quietly stops, with the failure invisible because the migration still works. And data migrations cannot be generated at all, so anything touching existing rows stays hand-written regardless.
|
||||
|
||||
The first of those was specific to `drizzle-kit`. The other two are true of any generator, which is why the decision survives the change of library — and Kysely, which ships no generator anyone was asking us to use, has nothing to refuse.
|
||||
|
||||
The workflow: write the migration by hand, then run `npm run db:types` to refresh the mirror. `schemaMirror.integration.test.ts` fails if you forget.
|
||||
@@ -1,277 +0,0 @@
|
||||
/**
|
||||
* This file was generated by kysely-codegen.
|
||||
* Please do not edit it manually.
|
||||
*/
|
||||
|
||||
import type { ColumnType } from "kysely";
|
||||
|
||||
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||
? ColumnType<S, I | undefined, U>
|
||||
: ColumnType<T, T | undefined, T>;
|
||||
|
||||
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>;
|
||||
|
||||
export type Json = JsonValue;
|
||||
|
||||
export type JsonArray = JsonValue[];
|
||||
|
||||
export type JsonObject = {
|
||||
[x: string]: JsonValue | undefined;
|
||||
};
|
||||
|
||||
export type JsonPrimitive = boolean | number | string | null;
|
||||
|
||||
export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
|
||||
|
||||
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
|
||||
|
||||
export interface AdminSettings {
|
||||
key: string;
|
||||
updated_at: Generated<Timestamp>;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface CartItems {
|
||||
added_at: Generated<Timestamp>;
|
||||
cart_id: number;
|
||||
expires_at: Timestamp;
|
||||
id: Generated<number>;
|
||||
item_id: number;
|
||||
last_reminder_sent_at: Timestamp | null;
|
||||
}
|
||||
|
||||
export interface Carts {
|
||||
created_at: Generated<Timestamp>;
|
||||
customer_id: number;
|
||||
id: Generated<number>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface Categories {
|
||||
created_at: Generated<Timestamp>;
|
||||
id: Generated<number>;
|
||||
name: string;
|
||||
parent_id: number | null;
|
||||
sort_order: Generated<number>;
|
||||
}
|
||||
|
||||
export interface CheckoutItems {
|
||||
checkout_id: number;
|
||||
item_id: number;
|
||||
price_cents: number;
|
||||
}
|
||||
|
||||
export interface Checkouts {
|
||||
amount_cents: number | null;
|
||||
created_at: Generated<Timestamp>;
|
||||
customer_id: number | null;
|
||||
id: Generated<number>;
|
||||
processor: string;
|
||||
processor_order_id: string | null;
|
||||
raw_event: Json | null;
|
||||
shipping_address_id: number | null;
|
||||
status: Generated<string>;
|
||||
}
|
||||
|
||||
export interface CustomerCredentials {
|
||||
created_at: Generated<Timestamp>;
|
||||
credential_id: string;
|
||||
customer_id: number;
|
||||
id: Generated<number>;
|
||||
last_used_at: Timestamp | null;
|
||||
name: Generated<string>;
|
||||
public_key: string;
|
||||
signature_counter: Generated<Int8>;
|
||||
transports: string | null;
|
||||
}
|
||||
|
||||
export interface Customers {
|
||||
analytics_consent: Generated<boolean>;
|
||||
analytics_consent_at: Timestamp | null;
|
||||
analytics_consent_text: string | null;
|
||||
created_at: Generated<Timestamp>;
|
||||
disabled_at: Timestamp | null;
|
||||
email: string;
|
||||
email_verified: Generated<boolean>;
|
||||
favorite_alerts: Generated<boolean>;
|
||||
favorite_alerts_at: Timestamp | null;
|
||||
favorite_alerts_text: string | null;
|
||||
first_name: string | null;
|
||||
id: Generated<number>;
|
||||
last_name: string | null;
|
||||
marketing_consent: Generated<boolean>;
|
||||
marketing_consent_at: Timestamp | null;
|
||||
marketing_consent_text: string | null;
|
||||
password_hash: string | null;
|
||||
unsubscribe_token: string;
|
||||
}
|
||||
|
||||
export interface CustomerEmailChanges {
|
||||
changed_at: Generated<Timestamp>;
|
||||
customer_id: number;
|
||||
id: Generated<number>;
|
||||
new_email: string;
|
||||
previous_email: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface CustomerIdentities {
|
||||
created_at: Generated<Timestamp>;
|
||||
customer_id: number;
|
||||
id: Generated<number>;
|
||||
last_used_at: Timestamp | null;
|
||||
provider: string;
|
||||
provider_sub: string;
|
||||
}
|
||||
|
||||
export interface CustomerSessions {
|
||||
created_at: Generated<Timestamp>;
|
||||
customer_id: number;
|
||||
expires_at: Timestamp;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface CustomerTokens {
|
||||
created_at: Generated<Timestamp>;
|
||||
customer_id: number;
|
||||
expires_at: Timestamp;
|
||||
kind: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface Favorites {
|
||||
created_at: Generated<Timestamp>;
|
||||
customer_id: number;
|
||||
item_id: number;
|
||||
}
|
||||
|
||||
export interface ItemDrafts {
|
||||
ai_category_id: number | null;
|
||||
ai_description: string | null;
|
||||
ai_error: string | null;
|
||||
ai_name: string | null;
|
||||
ai_suggested_price_cents: number | null;
|
||||
ai_tag_names: string[] | null;
|
||||
attempts: Generated<number>;
|
||||
cost_micros: number | null;
|
||||
created_at: Generated<Timestamp>;
|
||||
drafted_at: Timestamp | null;
|
||||
id: Generated<number>;
|
||||
input_tokens: number | null;
|
||||
item_id: number;
|
||||
model: string | null;
|
||||
output_tokens: number | null;
|
||||
price_source: Generated<string>;
|
||||
remove_background: Generated<boolean>;
|
||||
reviewed_at: Timestamp | null;
|
||||
state: Generated<string>;
|
||||
submitter_note: string | null;
|
||||
upload_link_id: number | null;
|
||||
}
|
||||
|
||||
export interface ItemImages {
|
||||
created_at: Generated<Timestamp>;
|
||||
id: Generated<number>;
|
||||
image_path: string;
|
||||
item_id: number;
|
||||
original_image_path: string | null;
|
||||
sort_order: Generated<number>;
|
||||
}
|
||||
|
||||
export interface Items {
|
||||
category_id: number | null;
|
||||
created_at: Generated<Timestamp>;
|
||||
description: string | null;
|
||||
id: Generated<number>;
|
||||
name: string;
|
||||
paypal_order_id: string | null;
|
||||
price_cents: Generated<number>;
|
||||
reserved_until: Timestamp | null;
|
||||
sold_at: Timestamp | null;
|
||||
status: Generated<string>;
|
||||
}
|
||||
|
||||
export interface ItemTags {
|
||||
item_id: number;
|
||||
tag_id: number;
|
||||
}
|
||||
|
||||
export interface Orders {
|
||||
amount_cents: number | null;
|
||||
checkout_id: number | null;
|
||||
created_at: Generated<Timestamp>;
|
||||
customer_id: number | null;
|
||||
id: Generated<number>;
|
||||
item_id: number | null;
|
||||
processor: string;
|
||||
processor_order_id: string | null;
|
||||
raw_event: Json | null;
|
||||
status: string | null;
|
||||
}
|
||||
|
||||
export interface ShippingAddresses {
|
||||
address_line1: string;
|
||||
address_line2: string | null;
|
||||
city: string;
|
||||
country: Generated<string>;
|
||||
created_at: Generated<Timestamp>;
|
||||
customer_id: number;
|
||||
full_name: string;
|
||||
id: Generated<number>;
|
||||
is_default: Generated<boolean>;
|
||||
postal_code: string;
|
||||
state: string;
|
||||
usps_standardized: Json | null;
|
||||
usps_validated: Generated<boolean>;
|
||||
}
|
||||
|
||||
export interface Tags {
|
||||
color: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
id: Generated<number>;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UploadLinks {
|
||||
contact_email: string | null;
|
||||
created_at: Generated<Timestamp>;
|
||||
id: Generated<number>;
|
||||
label: string;
|
||||
last_used_at: Timestamp | null;
|
||||
max_submissions: number | null;
|
||||
revoked_at: Timestamp | null;
|
||||
submission_count: Generated<number>;
|
||||
token_hash: string;
|
||||
}
|
||||
|
||||
export interface WebauthnChallenges {
|
||||
challenge: string;
|
||||
customer_id: number | null;
|
||||
expires_at: Timestamp;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
export interface DB {
|
||||
admin_settings: AdminSettings;
|
||||
cart_items: CartItems;
|
||||
carts: Carts;
|
||||
categories: Categories;
|
||||
checkout_items: CheckoutItems;
|
||||
checkouts: Checkouts;
|
||||
customer_credentials: CustomerCredentials;
|
||||
customer_email_changes: CustomerEmailChanges;
|
||||
customer_identities: CustomerIdentities;
|
||||
customer_sessions: CustomerSessions;
|
||||
customer_tokens: CustomerTokens;
|
||||
customers: Customers;
|
||||
favorites: Favorites;
|
||||
item_drafts: ItemDrafts;
|
||||
item_images: ItemImages;
|
||||
item_tags: ItemTags;
|
||||
items: Items;
|
||||
orders: Orders;
|
||||
shipping_addresses: ShippingAddresses;
|
||||
tags: Tags;
|
||||
upload_links: UploadLinks;
|
||||
webauthn_challenges: WebauthnChallenges;
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Pool } from 'pg';
|
||||
import { Kysely, PostgresDialect } from 'kysely';
|
||||
import type { DB } from './db-kysely/schema';
|
||||
|
||||
export const pool = new Pool({
|
||||
host: process.env.PGHOST,
|
||||
@@ -9,53 +7,3 @@ export const pool = new Pool({
|
||||
password: process.env.PGPASSWORD,
|
||||
database: process.env.PGDATABASE
|
||||
});
|
||||
|
||||
/**
|
||||
* Kysely over the same pool, alongside `pool` rather than instead of it.
|
||||
*
|
||||
* Both have to work at once: the conversion is file by file across 238 call
|
||||
* sites, so for a long time most queries will still be raw `pg` and the two
|
||||
* must share one set of connections. Handing Kysely the existing pool rather
|
||||
* than letting it open its own is what makes that true — otherwise a
|
||||
* transaction started on one would be invisible to the other, and the pool
|
||||
* limits would silently double.
|
||||
*
|
||||
* The value of this over raw `pg` is not brevity. In a Kysely `sql` template
|
||||
* `${value}` emits a bind parameter, never text, so there is no way to spell
|
||||
* "interpolate this as SQL" by accident. That makes the #202 invariant
|
||||
* structural instead of a comment plus two mutation tests, and it is the main
|
||||
* reason a builder is here at all.
|
||||
*
|
||||
* Kysely rather than Drizzle since #305. The safety property above was true of
|
||||
* both; what decided it is that three of the four hazards in the old
|
||||
* CONVENTIONS.md — an array needing sql.param(), a column reference silently
|
||||
* losing its table inside a raw fragment, and a camelCase mirror that had to be
|
||||
* mapped back at every select — were properties of Drizzle rather than of
|
||||
* type-safe query building. See #297 for the SQL each one actually emitted.
|
||||
*/
|
||||
export const db = new Kysely<DB>({ dialect: new PostgresDialect({ pool }) });
|
||||
|
||||
/**
|
||||
* The single row a query is guaranteed to have returned.
|
||||
*
|
||||
* For `INSERT ... RETURNING` and `UPDATE ... WHERE id = $1 RETURNING` after the
|
||||
* row's existence has already been established: Postgres returns exactly one
|
||||
* row, so there is nothing to branch on, but `noUncheckedIndexedAccess` is right
|
||||
* that `rows[0]` is `T | undefined` and the compiler cannot know better.
|
||||
*
|
||||
* A thrown error rather than a non-null assertion. If the assumption is ever
|
||||
* wrong the assertion would hand `undefined` to the next line and fail somewhere
|
||||
* unrelated, whereas this fails here and says which query. `asyncRoute` turns it
|
||||
* into a 500, which is the right answer for "the database did not do what the
|
||||
* statement says it does".
|
||||
*
|
||||
* Reads that legitimately might find nothing do not use this — they destructure
|
||||
* and branch, so the check and the use are the same thing.
|
||||
*/
|
||||
export function requireRow<T>(rows: T[], what: string): T {
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
throw new Error(`expected ${what} to return a row, got none`);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
|
||||
/**
|
||||
* The five customer emails, their default copy, and the rules for editing it.
|
||||
*
|
||||
* Bodies are markdown rather than HTML. `html: false` is markdown-it's default
|
||||
* and is the point of choosing it: raw HTML in a stored body is escaped, not
|
||||
* passed through, so editing copy from the settings screen cannot put script
|
||||
* into a customer's inbox. That is a stronger guarantee than sanitising output
|
||||
* afterwards, because there is no output to sanitise.
|
||||
*/
|
||||
const md = new MarkdownIt({ html: false, linkify: true });
|
||||
|
||||
export type TemplateKey =
|
||||
| 'verification'
|
||||
| 'passwordReset'
|
||||
| 'favoriteSold'
|
||||
| 'favoriteWithdrawn'
|
||||
| 'cartReminder'
|
||||
| 'emailChanged'
|
||||
| 'emailChangedByAdmin'
|
||||
| 'intakeDraft'
|
||||
| 'uploadLink';
|
||||
|
||||
export interface TemplateDefinition {
|
||||
/** Shown in the admin so a card is identifiable without reading its body. */
|
||||
label: string;
|
||||
/**
|
||||
* Placeholders a body must contain. Saving without one is refused: a reset
|
||||
* email with no link still sends, still looks fine in the log, and is useless
|
||||
* to everyone who receives it.
|
||||
*/
|
||||
required: readonly string[];
|
||||
/** Every placeholder this template understands, for the admin to see. */
|
||||
available: readonly string[];
|
||||
defaultSubject: string;
|
||||
defaultBody: string;
|
||||
/**
|
||||
* Appended after rendering and deliberately not editable. The favorite alerts
|
||||
* carry a consent notice explaining why the customer is receiving them, which
|
||||
* is a compliance artifact rather than copy — editing wording should not be
|
||||
* able to delete the sentence that makes the email lawful to send.
|
||||
*/
|
||||
footer?: string;
|
||||
}
|
||||
|
||||
const FAVORITE_CONSENT_FOOTER =
|
||||
'<p>You are receiving this because you asked to be told when a favorited item becomes ' +
|
||||
'unavailable. You can turn these off on your account page.</p>';
|
||||
|
||||
export const TEMPLATES: Record<TemplateKey, TemplateDefinition> = {
|
||||
verification: {
|
||||
label: 'Email verification',
|
||||
required: ['verifyUrl'],
|
||||
available: ['greeting', 'firstName', 'lastName', 'verifyUrl', 'expiresIn'],
|
||||
defaultSubject: 'Confirm your email address',
|
||||
defaultBody:
|
||||
'{{greeting}}\n\n' +
|
||||
'Please confirm this address so we know we can reach you.\n\n' +
|
||||
'[Confirm my email]({{verifyUrl}})\n\n' +
|
||||
'This link expires in {{expiresIn}}.'
|
||||
},
|
||||
|
||||
passwordReset: {
|
||||
label: 'Password reset',
|
||||
required: ['resetUrl'],
|
||||
available: ['greeting', 'firstName', 'lastName', 'resetUrl', 'expiresIn'],
|
||||
defaultSubject: 'Reset your Redefined Designs password',
|
||||
defaultBody:
|
||||
'Someone asked to reset the password for this account.\n\n' +
|
||||
'[Choose a new password]({{resetUrl}}). This link expires in {{expiresIn}}.\n\n' +
|
||||
// Said before the customer follows the link rather than after they have
|
||||
// used it, because it is the one consequence of a reset they cannot undo
|
||||
// and might have chosen differently about (#42). Worded so it reads the
|
||||
// same to someone who has never registered one.
|
||||
'Resetting your password also removes any passkeys saved on this account, ' +
|
||||
'and signs you out everywhere. You can add your passkeys again afterwards.\n\n' +
|
||||
"If this wasn't you, you can ignore this email — your password has not changed."
|
||||
},
|
||||
|
||||
favoriteSold: {
|
||||
label: 'Favorited item sold',
|
||||
required: ['itemName'],
|
||||
available: ['greeting', 'firstName', 'lastName', 'itemName', 'siteUrl'],
|
||||
defaultSubject: '"{{itemName}}" has been sold',
|
||||
defaultBody:
|
||||
'An item you favorited has been sold to another customer, so it is no longer available.\n\n' +
|
||||
'**{{itemName}}**\n\n' +
|
||||
'Every piece is one of a kind, so this one will not be restocked. You can browse what is ' +
|
||||
'still available at [Redefined Designs]({{siteUrl}}).',
|
||||
footer: FAVORITE_CONSENT_FOOTER
|
||||
},
|
||||
|
||||
favoriteWithdrawn: {
|
||||
label: 'Favorited item withdrawn',
|
||||
required: ['itemName'],
|
||||
available: ['greeting', 'firstName', 'lastName', 'itemName', 'siteUrl'],
|
||||
defaultSubject: '"{{itemName}}" is no longer available',
|
||||
defaultBody:
|
||||
'An item you favorited has been withdrawn and is no longer available.\n\n' +
|
||||
'**{{itemName}}**\n\n' +
|
||||
'You can browse what is still available at [Redefined Designs]({{siteUrl}}).',
|
||||
footer: FAVORITE_CONSENT_FOOTER
|
||||
},
|
||||
|
||||
emailChanged: {
|
||||
label: 'Email address changed',
|
||||
// Naming the new address is the point: a notice that does not say what
|
||||
// the address was changed *to* is nearly useless to someone checking
|
||||
// whether it was them. This is the mail that catches an account
|
||||
// takeover, so it goes to the address being replaced.
|
||||
required: ['newEmail'],
|
||||
available: ['greeting', 'firstName', 'lastName', 'newEmail'],
|
||||
defaultSubject: 'Your Redefined Designs email address was changed',
|
||||
defaultBody:
|
||||
'{{greeting}}\n\n' +
|
||||
'The email address on your account was changed to **{{newEmail}}**.\n\n' +
|
||||
'If you made this change, nothing more is needed. This message is only a\n' +
|
||||
'record of it.\n\n' +
|
||||
'If you did not, contact us straight away: whoever made the change can now\n' +
|
||||
'receive password reset links for your account.'
|
||||
},
|
||||
emailChangedByAdmin: {
|
||||
label: 'Email address changed by the shop',
|
||||
// Its own template rather than reusing emailChanged, because the two are
|
||||
// addressed to different readers (#337).
|
||||
//
|
||||
// The self-service notice says "if you did not make this change, contact
|
||||
// us". Here somebody already did contact us — that is how the change came
|
||||
// about — so that sentence would be addressed to a customer who has just
|
||||
// done the thing it asks for, while the person who actually needs to act on
|
||||
// it is the one who did nothing.
|
||||
//
|
||||
// This is the mail that catches a takeover *by* the recovery route, which
|
||||
// is the risk the route carries: a stranger who talks their way past the
|
||||
// verification gets the account, and the only person who can say otherwise
|
||||
// is whoever still reads the old address. So it goes there, it says plainly
|
||||
// that the account has moved, and it makes contradicting it the easy reply.
|
||||
//
|
||||
// The operator's stated reason is deliberately not a placeholder. It is a
|
||||
// private note about how somebody was verified, and it can name things the
|
||||
// customer should not be handed back.
|
||||
required: ['newEmail'],
|
||||
available: ['greeting', 'firstName', 'lastName', 'newEmail'],
|
||||
defaultSubject: 'Your Redefined Designs account has moved to a new email address',
|
||||
defaultBody:
|
||||
'{{greeting}}\n\n' +
|
||||
'Someone contacted us saying they could no longer get into this account, and\n' +
|
||||
'we moved it to **{{newEmail}}** after checking their answers against the\n' +
|
||||
'order history on it.\n\n' +
|
||||
'If that was you, nothing more is needed — sign in at the new address and\n' +
|
||||
'confirm it when you get the message we sent there.\n\n' +
|
||||
'**If it was not you, reply to this email straight away.** Whoever asked for\n' +
|
||||
'the change can now sign in to this account, and we will undo it.'
|
||||
},
|
||||
|
||||
cartReminder: {
|
||||
label: 'Cart reminder',
|
||||
required: ['itemList', 'cartUrl'],
|
||||
available: ['greeting', 'firstName', 'lastName', 'itemList', 'cartUrl', 'holdDuration'],
|
||||
defaultSubject: 'Items waiting in your cart',
|
||||
defaultBody:
|
||||
'{{greeting}}\n\n' +
|
||||
'You still have items in your cart at Redefined Designs:\n\n' +
|
||||
'{{itemList}}\n\n' +
|
||||
'Items are held for {{holdDuration}} from when they were added.\n\n' +
|
||||
'[View your cart]({{cartUrl}}) before your reservation expires.'
|
||||
},
|
||||
|
||||
intakeDraft: {
|
||||
label: 'Item submitted for review',
|
||||
// Only the review link. The signed shortcuts are absent whenever
|
||||
// INTAKE_ACTION_SECRET is unset, and requiring them would make an
|
||||
// unconfigured environment unable to send this at all.
|
||||
required: ['reviewUrl'],
|
||||
available: [
|
||||
'itemName',
|
||||
'draftName',
|
||||
'draftDescription',
|
||||
'price',
|
||||
'submitterNote',
|
||||
'linkLabel',
|
||||
'reviewUrl',
|
||||
'regenerateUrl',
|
||||
'discardUrl'
|
||||
],
|
||||
defaultSubject: 'An item was submitted: {{draftName}}',
|
||||
defaultBody:
|
||||
'Someone sent in an item through {{linkLabel}}.\n\n' +
|
||||
'**{{draftName}}**\n\n' +
|
||||
'{{draftDescription}}\n\n' +
|
||||
'Suggested price: {{price}}\n\n' +
|
||||
"The sender's note: {{submitterNote}}\n\n" +
|
||||
'[Review and publish it]({{reviewUrl}})\n\n' +
|
||||
'Nothing is listed until you publish it from that screen, and the price ' +
|
||||
'above is a suggestion rather than a decision.\n\n' +
|
||||
'[Ask for another draft]({{regenerateUrl}}) - [Discard it]({{discardUrl}})'
|
||||
},
|
||||
|
||||
uploadLink: {
|
||||
label: 'Upload link for a contributor',
|
||||
// The link itself, for the same reason verification requires verifyUrl: an
|
||||
// email inviting somebody to send in photos, with no way to do it, sends
|
||||
// perfectly happily and wastes everyone's time.
|
||||
required: ['submitUrl'],
|
||||
available: ['submitUrl', 'label', 'submissionsAllowed'],
|
||||
defaultSubject: 'Send us your items',
|
||||
defaultBody:
|
||||
'You can send us photos of items you would like us to sell.\n\n' +
|
||||
'[Send in an item]({{submitUrl}})\n\n' +
|
||||
'You can send {{submissionsAllowed}}. Photograph one item at a time, and tell us anything you know about it — where it came from, what it is made of, any damage. A photo cannot show any of that.\n\n' +
|
||||
'Keep this link to yourself: anyone who has it can send us items in your name.'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a configured lifetime, in hours, as the words an email should use.
|
||||
*
|
||||
* All three duration placeholders go through this, so the reset email and the
|
||||
* cart reminder say "one hour" the same way rather than in two authors'
|
||||
* phrasing. A fractional hour drops to minutes: "0.5 hours" reads badly, and
|
||||
* "1.5 hours" reads worse in a sentence a customer is meant to act on.
|
||||
*/
|
||||
export function formatDuration(hours: number): string {
|
||||
if (!Number.isInteger(hours)) {
|
||||
return `${Math.round(hours * 60)} minutes`;
|
||||
}
|
||||
return hours === 1 ? 'one hour' : `${hours} hours`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `{{greeting}}` value from the admin-configured format.
|
||||
*
|
||||
* One placeholder rather than a bare name, so a template author writes
|
||||
* `{{greeting}}` on its own line instead of `Hi {{firstName}},` — which reads
|
||||
* as "Hi ," for anyone who registered before first names were required (#106).
|
||||
* `{{firstName}}` and `{{lastName}}` are still offered for a template that
|
||||
* genuinely wants the name inline, but the greeting is the safe default.
|
||||
*
|
||||
* The fallback is a separate setting rather than the format with the name
|
||||
* removed. Editing a name out of a sentence is the kind of thing that has to be
|
||||
* right every time and cannot be, so an admin writes both and neither is
|
||||
* guessed.
|
||||
*/
|
||||
export function greeting(
|
||||
firstName: string | null | undefined,
|
||||
format: string,
|
||||
fallback: string,
|
||||
lastName?: string | null
|
||||
): string {
|
||||
const first = (firstName ?? '').trim();
|
||||
if (!first) return fallback;
|
||||
return format
|
||||
.replace(/\{\{\s*firstName\s*\}\}/g, first)
|
||||
.replace(/\{\{\s*lastName\s*\}\}/g, (lastName ?? '').trim());
|
||||
}
|
||||
|
||||
/** Matches `{{name}}`, tolerating whitespace inside the braces. */
|
||||
const PLACEHOLDER = /\{\{\s*(\w+)\s*\}\}/g;
|
||||
|
||||
/**
|
||||
* Which of a template's required placeholders a candidate body is missing.
|
||||
*
|
||||
* Returns all of them rather than the first, so a save that dropped two says so
|
||||
* once instead of over two attempts.
|
||||
*/
|
||||
export function missingPlaceholders(key: TemplateKey, body: string): string[] {
|
||||
const present = new Set<string>();
|
||||
for (const match of body.matchAll(PLACEHOLDER)) {
|
||||
// PLACEHOLDER has exactly one capture group, so a match always has [1] —
|
||||
// but a RegExpMatchArray cannot say so, hence the guard rather than an
|
||||
// assertion. A match without it would be a change to the pattern.
|
||||
const name = match[1];
|
||||
if (name) present.add(name);
|
||||
}
|
||||
return TEMPLATES[key].required.filter((name) => !present.has(name));
|
||||
}
|
||||
|
||||
function substitute(text: string, values: Record<string, string>): string {
|
||||
return text.replace(PLACEHOLDER, (whole, name: string) => {
|
||||
// hasOwnProperty does not narrow an index signature, so the lookup is done
|
||||
// once and tested. Checking the value also treats an explicitly-undefined
|
||||
// entry the same as a missing one, which is what the caller means.
|
||||
const value = values[name];
|
||||
return value === undefined ? whole : value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Representative values for every placeholder any template accepts, used to
|
||||
* render a preview in the admin.
|
||||
*
|
||||
* Kept here beside the definitions rather than in the route, so that adding a
|
||||
* placeholder to a template puts the missing sample right next to the change
|
||||
* that needs it. A unit test asserts every `available` name has an entry, since
|
||||
* a missing one would render the preview with a literal {{placeholder}} in it
|
||||
* and quietly teach the admin that their copy is broken when it is not.
|
||||
*
|
||||
* itemList is markdown because values are substituted into the markdown source
|
||||
* before rendering, which is the same reason the real caller supplies markdown.
|
||||
*/
|
||||
export const SAMPLE_VALUES: Record<string, string> = {
|
||||
greeting: 'Hi Ada,',
|
||||
firstName: 'Ada',
|
||||
lastName: 'Lovelace',
|
||||
verifyUrl: 'https://example.com/verify-email?token=sample-token',
|
||||
resetUrl: 'https://example.com/reset-password?token=sample-token',
|
||||
itemName: 'Walnut sideboard',
|
||||
siteUrl: 'https://example.com',
|
||||
newEmail: 'new.address@example.com',
|
||||
itemList: '- Walnut sideboard\n- Brass table lamp',
|
||||
cartUrl: 'https://example.com/cart',
|
||||
// Fallbacks only. The admin preview overrides both from the live settings,
|
||||
// so the pane shows the duration that would actually be sent rather than a
|
||||
// plausible-looking number that disagrees with it.
|
||||
draftName: 'Blue stoneware vase',
|
||||
draftDescription: 'A hand-thrown vase with a chipped base.',
|
||||
price: '$80.00',
|
||||
submitterNote: 'Found in a loft clearance.',
|
||||
linkLabel: 'Autumn drop-off',
|
||||
reviewUrl: 'https://example.com/admin',
|
||||
regenerateUrl: 'https://example.com/api/intake-actions/1/regenerate?expires=0&sig=sample',
|
||||
discardUrl: 'https://example.com/api/intake-actions/1/discard?expires=0&sig=sample',
|
||||
expiresIn: 'one hour',
|
||||
holdDuration: '24 hours',
|
||||
submitUrl: 'https://example.com/submit/sample-token',
|
||||
label: 'Autumn drop-off',
|
||||
submissionsAllowed: '25 items'
|
||||
};
|
||||
|
||||
export interface StoredTemplate {
|
||||
subject?: string | null;
|
||||
body?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces the subject and HTML for one email.
|
||||
*
|
||||
* Values are substituted into the markdown *before* rendering, which is why a
|
||||
* value that should become a list has to arrive as markdown — emitting HTML
|
||||
* here would be escaped and shown to the customer as literal tags.
|
||||
*
|
||||
* An absent or blank stored value falls back to the built-in default, so an
|
||||
* unconfigured install behaves exactly as it did before any of this existed.
|
||||
*/
|
||||
export function renderTemplate(
|
||||
key: TemplateKey,
|
||||
stored: StoredTemplate,
|
||||
values: Record<string, string>
|
||||
): { subject: string; html: string } {
|
||||
const definition = TEMPLATES[key];
|
||||
const subjectSource = stored.subject?.trim() ? stored.subject : definition.defaultSubject;
|
||||
const bodySource = stored.body?.trim() ? stored.body : definition.defaultBody;
|
||||
|
||||
const html = md.render(substitute(bodySource, values)) + (definition.footer ?? '');
|
||||
|
||||
return { subject: substitute(subjectSource, values), html };
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
/**
|
||||
* Boot-time configuration checks.
|
||||
*
|
||||
* The backend reads environment variables in a couple of dozen places, and a
|
||||
* missing or misspelled one used to be `undefined` until the first line of code
|
||||
* that happened to need it — which could be a very long time after the
|
||||
* container reported healthy. Several of those failures are silent and
|
||||
* customer-visible: mail containing `undefined` in a link, or a shop that
|
||||
* quietly stops charging anyone.
|
||||
*
|
||||
* The container already refuses to start on a failed migration rather than
|
||||
* serving against a schema it does not match. This is the same argument applied
|
||||
* to configuration.
|
||||
*
|
||||
* Kept a pure function of the environment it is handed, rather than reading
|
||||
* `process.env` itself, so it can be tested exhaustively without booting a
|
||||
* server or mutating global state. `server.ts` calls it; `app.ts` deliberately
|
||||
* does not, because the integration suite imports `app` directly and would
|
||||
* otherwise become a configuration exercise.
|
||||
*/
|
||||
|
||||
export interface EnvValidation {
|
||||
/** Configuration that must be fixed. The process should not start. */
|
||||
errors: string[];
|
||||
/** Working, but worth saying out loud — usually a capability that is off. */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
// Without these the process cannot do its job at all.
|
||||
//
|
||||
// Exported so tests/unit/composeEnvironment.test.ts can assert the deploying
|
||||
// environment actually sets them. #107 happened because this list grew and
|
||||
// docker-compose.qa.yml did not: the check has to read this list rather than a
|
||||
// copy of it, or the next variable added here goes unguarded in exactly the
|
||||
// same way.
|
||||
export const ALWAYS_REQUIRED = [
|
||||
'PGHOST',
|
||||
'PGPORT',
|
||||
'PGUSER',
|
||||
'PGPASSWORD',
|
||||
'PGDATABASE',
|
||||
// No reprieve for this one despite having a fallback: '/app/uploads' is
|
||||
// correct inside the container and wrong everywhere else, so inheriting it
|
||||
// silently writes uploads somewhere nobody is looking.
|
||||
'UPLOADS_DIR'
|
||||
] as const;
|
||||
|
||||
// Only meaningful once real payments are switched on. QA runs with none of
|
||||
// these on purpose, which is why the requirement is conditional rather than
|
||||
// absolute.
|
||||
const PAYPAL_REQUIRED = [
|
||||
'PAYPAL_CLIENT_ID',
|
||||
'PAYPAL_CLIENT_SECRET',
|
||||
'PAYPAL_WEBHOOK_ID',
|
||||
'PAYPAL_ENV'
|
||||
] as const;
|
||||
|
||||
// A variable set to spaces is a configuration mistake, not a value.
|
||||
function isPresent(env: NodeJS.ProcessEnv, name: string): boolean {
|
||||
const value = env[name];
|
||||
return typeof value === 'string' && value.trim() !== '';
|
||||
}
|
||||
|
||||
// One function per rule, at module level rather than nested. Each is small
|
||||
// enough to read on its own, and cognitive complexity counts everything
|
||||
// declared inside a function — so keeping these out of validateEnv is what
|
||||
// keeps the composition below flat.
|
||||
|
||||
function checkAlwaysRequired(env: NodeJS.ProcessEnv): string[] {
|
||||
return ALWAYS_REQUIRED.filter((name) => !isPresent(env, name)).map(
|
||||
(name) => `${name} is required and is not set.`
|
||||
);
|
||||
}
|
||||
|
||||
// Strict rather than truthy. This used to be read as "demo unless the value is
|
||||
// exactly 'false'", so DEMO_MODE=False, 0, or any typo meant demo mode was on —
|
||||
// a configuration slip that stopped the shop taking money and said nothing.
|
||||
function checkDemoMode(env: NodeJS.ProcessEnv): string[] {
|
||||
const demoMode = env.DEMO_MODE;
|
||||
|
||||
if (demoMode === undefined || demoMode.trim() === '') {
|
||||
return [
|
||||
"DEMO_MODE is required and must be exactly 'true' or 'false'. It decides whether real " +
|
||||
'payments are taken, so it has to be stated rather than inherited.'
|
||||
];
|
||||
}
|
||||
|
||||
if (demoMode !== 'true' && demoMode !== 'false') {
|
||||
return [
|
||||
`DEMO_MODE must be exactly 'true' or 'false', but is '${demoMode}'. Anything else used to ` +
|
||||
'be read as demo mode, which meant a typo here quietly stopped the shop charging anyone.'
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// Conditional rather than absolute: QA runs with no PayPal credentials on
|
||||
// purpose, so requiring them unconditionally would be wrong.
|
||||
function checkPayPal(env: NodeJS.ProcessEnv): string[] {
|
||||
if (env.DEMO_MODE !== 'false') {
|
||||
return [];
|
||||
}
|
||||
return PAYPAL_REQUIRED.filter((name) => !isPresent(env, name)).map(
|
||||
(name) => `${name} is required when DEMO_MODE=false, because real payments are enabled.`
|
||||
);
|
||||
}
|
||||
|
||||
// SMTP is all or nothing, and two other variables hang off whether it is set.
|
||||
function checkMail(env: NodeJS.ProcessEnv): EnvValidation {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
const hasUser = isPresent(env, 'SMTP_USER');
|
||||
const hasPassword = isPresent(env, 'SMTP_PASSWORD');
|
||||
|
||||
// Half-configured is worse than absent: the mailer only skips when both are
|
||||
// missing, so setting one produces a connection that fails at send time
|
||||
// instead of a clean "mail is off".
|
||||
if (hasUser && !hasPassword) {
|
||||
errors.push('SMTP_PASSWORD is required when SMTP_USER is set — set both or neither.');
|
||||
}
|
||||
if (hasPassword && !hasUser) {
|
||||
errors.push('SMTP_USER is required when SMTP_PASSWORD is set — set both or neither.');
|
||||
}
|
||||
|
||||
if (!hasUser || !hasPassword) {
|
||||
warnings.push(
|
||||
'SMTP is not configured — no email will be sent. Verification, password reset, favorite ' +
|
||||
'alerts and cart reminders will all be skipped with a warning.'
|
||||
);
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
// Demanded only alongside SMTP. Its sole job is building links in email, so a
|
||||
// local environment that cannot send mail does not need it, and requiring it
|
||||
// there would break every existing local setup to prevent nothing.
|
||||
if (!isPresent(env, 'PUBLIC_URL')) {
|
||||
errors.push(
|
||||
'PUBLIC_URL is required when SMTP is configured, or every link in a verification, ' +
|
||||
'password-reset, favorite-alert or cart-reminder email reads "undefined".'
|
||||
);
|
||||
}
|
||||
|
||||
if (!isPresent(env, 'MAIL_ALLOWLIST')) {
|
||||
warnings.push(
|
||||
'MAIL_ALLOWLIST is not set while SMTP is configured — this environment can email real ' +
|
||||
'customers. That is correct for production and a hazard anywhere else.'
|
||||
);
|
||||
}
|
||||
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Google sign-in is all or nothing (#340).
|
||||
*
|
||||
* Half-configured is the case worth naming. With neither value set the feature
|
||||
* reports itself disabled and the button never appears, which is a legitimate
|
||||
* environment. With one set, the token exchange fails at the moment a customer
|
||||
* presses the button — the worst possible time to discover a typo in a stack
|
||||
* variable.
|
||||
*
|
||||
* An error rather than a warning, matching the SMTP pair above: both refuse to
|
||||
* start rather than serving something that is visibly offered and cannot work.
|
||||
*/
|
||||
function checkGoogleSignIn(env: NodeJS.ProcessEnv): EnvValidation {
|
||||
const hasId = isPresent(env, 'GOOGLE_CLIENT_ID');
|
||||
const hasSecret = isPresent(env, 'GOOGLE_CLIENT_SECRET');
|
||||
|
||||
if (hasId && !hasSecret) {
|
||||
return {
|
||||
errors: ['GOOGLE_CLIENT_SECRET is required when GOOGLE_CLIENT_ID is set — set both or neither.'],
|
||||
warnings: []
|
||||
};
|
||||
}
|
||||
if (hasSecret && !hasId) {
|
||||
return {
|
||||
errors: ['GOOGLE_CLIENT_ID is required when GOOGLE_CLIENT_SECRET is set — set both or neither.'],
|
||||
warnings: []
|
||||
};
|
||||
}
|
||||
if (!hasId) {
|
||||
return {
|
||||
errors: [],
|
||||
warnings: ['Google sign-in is not configured — the button will not be offered.']
|
||||
};
|
||||
}
|
||||
|
||||
// Only meaningful once the credentials exist, and only a warning: a
|
||||
// deployment with no PUBLIC_URL falls back to localhost, which is right for
|
||||
// local development and wrong everywhere else in a way worth saying out loud.
|
||||
if (!isPresent(env, 'PUBLIC_URL')) {
|
||||
return {
|
||||
errors: [],
|
||||
warnings: [
|
||||
'Google sign-in is configured but PUBLIC_URL is not, so the redirect URI falls back to ' +
|
||||
'localhost. Correct locally; anywhere else, Google will refuse the callback.'
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return { errors: [], warnings: [] };
|
||||
}
|
||||
|
||||
function checkAdminGate(env: NodeJS.ProcessEnv): string[] {
|
||||
if (isPresent(env, 'ADMIN_GATE_SECRET')) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
'ADMIN_GATE_SECRET is not set — /api/admin is protected only by the reverse proxy. ' +
|
||||
'Anything able to reach this container directly can administer the store.'
|
||||
];
|
||||
}
|
||||
|
||||
// Optional on purpose, and unlike the two above, unset costs nothing in
|
||||
// safety. A submission still arrives, keeps its photos and waits in the queue
|
||||
// undrafted (#223). It is a warning rather than an error because the photos are
|
||||
// often the only copy of an item no longer in the sender's hands, so losing a
|
||||
// consignment to an expired key would be far worse than an item arriving
|
||||
// without its description written. Silence would be the wrong answer too: an
|
||||
// operator who believes drafting is on and finds every item undrafted has
|
||||
// nothing to tell them why.
|
||||
// Optional, like the drafting key below. Absent, the notification still sends
|
||||
// with its review link and simply carries no shortcuts — being told an item
|
||||
// arrived matters far more than being able to discard it in one click.
|
||||
function checkIntakeActionSecret(env: NodeJS.ProcessEnv): string[] {
|
||||
if (isPresent(env, 'INTAKE_ACTION_SECRET')) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
'INTAKE_ACTION_SECRET is not set — intake notifications will link to the review queue ' +
|
||||
'but carry no regenerate or discard shortcuts.'
|
||||
];
|
||||
}
|
||||
|
||||
function checkDraftingKey(env: NodeJS.ProcessEnv): string[] {
|
||||
if (isPresent(env, 'ANTHROPIC_API_KEY')) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
'ANTHROPIC_API_KEY is not set — submitted items will arrive undrafted and wait in the ' +
|
||||
'review queue for someone to write them up by hand.'
|
||||
];
|
||||
}
|
||||
|
||||
// Optional, and the same shape as the admin gate above: unset is a working
|
||||
// configuration with one defence switched off, which is worth saying out loud
|
||||
// rather than leaving to be discovered. Set, it has to be an absolute origin —
|
||||
// a value missing its scheme joins into a relative path and silently breaks
|
||||
// every image on the site, which is a worse outcome than either extreme.
|
||||
function checkUploadsOrigin(env: NodeJS.ProcessEnv): EnvValidation {
|
||||
if (!isPresent(env, 'UPLOADS_BASE_URL')) {
|
||||
return {
|
||||
errors: [],
|
||||
warnings: [
|
||||
'UPLOADS_BASE_URL is not set — uploaded files are served from this application on its ' +
|
||||
'own origin, so anything reaching the uploads directory shares an origin with the site.'
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const value = (env.UPLOADS_BASE_URL ?? '').trim();
|
||||
if (!value.startsWith('https://') && !value.startsWith('http://')) {
|
||||
return {
|
||||
errors: [
|
||||
'UPLOADS_BASE_URL must be an absolute origin including the scheme, such as ' +
|
||||
'https://uploads.example.com. Without one it joins into a relative path and every ' +
|
||||
'image on the site breaks.'
|
||||
],
|
||||
warnings: []
|
||||
};
|
||||
}
|
||||
|
||||
return { errors: [], warnings: [] };
|
||||
}
|
||||
|
||||
export function validateEnv(env: NodeJS.ProcessEnv): EnvValidation {
|
||||
const mail = checkMail(env);
|
||||
const uploads = checkUploadsOrigin(env);
|
||||
const google = checkGoogleSignIn(env);
|
||||
|
||||
return {
|
||||
errors: [
|
||||
...checkAlwaysRequired(env),
|
||||
...checkDemoMode(env),
|
||||
...checkPayPal(env),
|
||||
...mail.errors,
|
||||
...uploads.errors,
|
||||
...google.errors
|
||||
],
|
||||
warnings: [
|
||||
...mail.warnings,
|
||||
...checkAdminGate(env),
|
||||
...uploads.warnings,
|
||||
...checkDraftingKey(env),
|
||||
...checkIntakeActionSecret(env),
|
||||
...google.warnings
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import { pool } from './db';
|
||||
import { sendMail } from './mailer';
|
||||
import { renderTemplate, greeting, TemplateKey } from './emailTemplates';
|
||||
import { getSettings } from './adminSettings';
|
||||
import { loadStoredTemplate } from './routes/adminEmailTemplates';
|
||||
|
||||
// Shown to the customer when they opt in, and stored verbatim against their
|
||||
// consent so the record says what they actually agreed to — the same pattern
|
||||
@@ -12,10 +9,6 @@ export const FAVORITE_ALERTS_CONSENT_TEXT =
|
||||
|
||||
export interface FavoriteRecipient {
|
||||
email: string;
|
||||
// Nullable because customers who registered while names were optional have
|
||||
// none — the same reason the greeting needs a fallback at all.
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
item_name: string;
|
||||
}
|
||||
|
||||
@@ -31,7 +24,7 @@ export async function collectFavoriteRecipients(
|
||||
if (!itemIds.length) return [];
|
||||
|
||||
const { rows } = await pool.query<FavoriteRecipient>(
|
||||
`SELECT c.email, c.first_name, c.last_name, i.name AS item_name
|
||||
`SELECT c.email, i.name AS item_name
|
||||
FROM favorites f
|
||||
JOIN customers c ON c.id = f.customer_id
|
||||
JOIN items i ON i.id = f.item_id
|
||||
@@ -49,28 +42,15 @@ export async function collectFavoriteRecipients(
|
||||
// thing the customer asked to hear about. Sent independently so one bad
|
||||
// address cannot stop the rest — and whatever prompted this has already
|
||||
// happened regardless of whether the mail goes out.
|
||||
async function send(recipients: FavoriteRecipient[], key: TemplateKey): Promise<void> {
|
||||
if (!recipients.length) return;
|
||||
|
||||
// Loaded once for the batch rather than per recipient: the copy is the same
|
||||
// for everyone, only the item name differs.
|
||||
const stored = await loadStoredTemplate(key);
|
||||
const siteUrl = process.env.PUBLIC_URL ?? '';
|
||||
const { greetingFormat, greetingFallback } = await getSettings();
|
||||
|
||||
function send(recipients: FavoriteRecipient[], subject: (name: string) => string, body: (name: string) => string): void {
|
||||
for (const recipient of recipients) {
|
||||
const { subject, html } = renderTemplate(key, stored, {
|
||||
greeting: greeting(recipient.first_name, greetingFormat, greetingFallback, recipient.last_name),
|
||||
firstName: recipient.first_name ?? '',
|
||||
lastName: recipient.last_name ?? '',
|
||||
itemName: recipient.item_name,
|
||||
siteUrl
|
||||
});
|
||||
sendMail(recipient.email, subject, html)
|
||||
sendMail(recipient.email, subject(recipient.item_name), body(recipient.item_name))
|
||||
.catch(err => console.error('favorite alert failed', err));
|
||||
}
|
||||
}
|
||||
|
||||
const FOOTER = `<p>You are receiving this because you asked to be told when a favorited item becomes
|
||||
unavailable. You can turn these off on your account page.</p>`;
|
||||
|
||||
// Called *after* the sale has been committed, never inside the transaction.
|
||||
// Emailing about a sale that then rolled back would be worse than a late
|
||||
@@ -80,16 +60,27 @@ async function send(recipients: FavoriteRecipient[], key: TemplateKey): Promise<
|
||||
// longer available reads as a bug.
|
||||
export async function notifyFavoritersOfSale(itemIds: number[], buyerId: number | null): Promise<void> {
|
||||
const recipients = await collectFavoriteRecipients(itemIds, buyerId);
|
||||
await send(recipients, 'favoriteSold');
|
||||
send(
|
||||
recipients,
|
||||
name => `"${name}" has been sold`,
|
||||
name => `<p>An item you favorited has been sold to another customer, so it is no longer available.</p>
|
||||
<p><b>${name}</b></p>
|
||||
<p>Every piece is one of a kind, so this one will not be restocked. You can browse what is
|
||||
still available at <a href="${process.env.PUBLIC_URL}">Redefined Designs</a>.</p>
|
||||
${FOOTER}`
|
||||
);
|
||||
}
|
||||
|
||||
// Sent when an item is withdrawn from sale rather than sold. Recipients must be
|
||||
// collected before the delete, since the favorites rows cascade with the item.
|
||||
// Async now that the copy is loaded from the database before rendering. It was
|
||||
// previously synchronous in dispatch — the sends were fire-and-forget, but they
|
||||
// were *started* before the caller returned. Leaving it fire-and-forget would
|
||||
// mean the response can beat the mail out of the door, which is a behaviour
|
||||
// change nobody asked for and which the withdrawal test caught.
|
||||
export async function notifyFavoritersOfRemoval(recipients: FavoriteRecipient[]): Promise<void> {
|
||||
await send(recipients, 'favoriteWithdrawn');
|
||||
export function notifyFavoritersOfRemoval(recipients: FavoriteRecipient[]): void {
|
||||
send(
|
||||
recipients,
|
||||
name => `"${name}" is no longer available`,
|
||||
name => `<p>An item you favorited has been withdrawn and is no longer available.</p>
|
||||
<p><b>${name}</b></p>
|
||||
<p>You can browse what is still available at
|
||||
<a href="${process.env.PUBLIC_URL}">Redefined Designs</a>.</p>
|
||||
${FOOTER}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
/**
|
||||
* Who this application is, as far as Google is concerned (#340).
|
||||
*
|
||||
* ## Why the redirect URI is derived rather than written down
|
||||
*
|
||||
* It is registered in two places that must agree exactly: Google's console, and
|
||||
* every authorization request this server sends. Google compares them as
|
||||
* strings — scheme, host, port, path, trailing slash and case all count — and
|
||||
* answers a mismatch with `redirect_uri_mismatch`, which is accurate and tells
|
||||
* you nothing about which half is wrong.
|
||||
*
|
||||
* So it comes from `PUBLIC_URL`, the same value every customer-facing link is
|
||||
* already built from, exactly as the WebAuthn Relying Party ID does (#37). One
|
||||
* source, and it is the one that is already correct in any environment where
|
||||
* mail works.
|
||||
*
|
||||
* ## Every environment needs its own console entry
|
||||
*
|
||||
* Whatever this resolves to has to exist, verbatim, under Authorized redirect
|
||||
* URIs for the client this app uses. Google compares the two as strings, and a
|
||||
* mismatch is answered with `redirect_uri_mismatch` — accurate, and silent
|
||||
* about which half is wrong.
|
||||
*
|
||||
* | Environment | Redirect URI |
|
||||
* | --- | --- |
|
||||
* | Local, Vite | `http://localhost:5173/api/auth/google/callback` |
|
||||
* | Local, built | `http://localhost:3000/api/auth/google/callback` |
|
||||
* | QA | `https://qa-redefined-designs.bermudalamb.synology.me/api/auth/google/callback` |
|
||||
* | Production | `https://redefined-designs.com/api/auth/google/callback` |
|
||||
*
|
||||
* Local development needs the 5173 one, because that is where the dev server
|
||||
* serves the app; the 3000 one only applies when the backend serves a built
|
||||
* frontend.
|
||||
*
|
||||
* An earlier version of this comment claimed the QA hostname could never be
|
||||
* registered, because it sits under a domain Synology owns. **That was wrong**,
|
||||
* and it is recorded here rather than quietly deleted: it was asserted from the
|
||||
* shape of #285, which is a related but different problem, and it sent QA
|
||||
* testing of this feature behind #313 for no reason. Adding the URI works.
|
||||
*
|
||||
* This module needs no change in any environment. It follows `PUBLIC_URL`
|
||||
* wherever it points.
|
||||
*/
|
||||
|
||||
/** The callback path. One constant, because it appears in two sentences. */
|
||||
export const GOOGLE_CALLBACK_PATH = '/api/auth/google/callback';
|
||||
|
||||
/**
|
||||
* Local development, where `PUBLIC_URL` is legitimately unset.
|
||||
*
|
||||
* `envValidation` requires `PUBLIC_URL` only when SMTP is configured, so a local
|
||||
* setup that cannot send mail does not have it. Falling back to the backend's
|
||||
* own port rather than refusing keeps that setup working, and `localhost` is
|
||||
* the one host Google will accept without an authorized domain — so the fallback
|
||||
* is also the only value that could possibly work here.
|
||||
*/
|
||||
const LOCAL_ORIGIN = 'http://localhost:3000';
|
||||
|
||||
export interface GoogleConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
/** Absolute, and byte-identical to what is registered in Google's console. */
|
||||
redirectUri: string;
|
||||
/**
|
||||
* Whether to offer Google sign-in at all.
|
||||
*
|
||||
* False when either credential is missing, and the button is then **absent
|
||||
* rather than disabled** — the same choice #41 made for a browser without
|
||||
* WebAuthn. A developer without credentials gets a storefront that works and
|
||||
* simply does not offer the option, rather than one that offers it and fails.
|
||||
*/
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Google configuration for this environment.
|
||||
*
|
||||
* Takes the environment as an argument so it can be tested without touching
|
||||
* `process.env`, and reads it on each call rather than at import time: the
|
||||
* module would otherwise capture whatever was set when it was first required,
|
||||
* which in tests is whatever the previous suite happened to leave behind.
|
||||
*
|
||||
* Throws on a `PUBLIC_URL` that is set but unparseable, for the reason
|
||||
* `relyingParty` does: that is a deployment already producing broken links in
|
||||
* every email, so failing here is not the first thing to go wrong — it is the
|
||||
* first thing to say so.
|
||||
*/
|
||||
export function googleConfig(env: NodeJS.ProcessEnv = process.env): GoogleConfig {
|
||||
const clientId = (env.GOOGLE_CLIENT_ID ?? '').trim();
|
||||
const clientSecret = (env.GOOGLE_CLIENT_SECRET ?? '').trim();
|
||||
const publicUrl = (env.PUBLIC_URL ?? '').trim();
|
||||
|
||||
let base = LOCAL_ORIGIN;
|
||||
if (publicUrl !== '') {
|
||||
try {
|
||||
// `origin` normalises away any path, trailing slash or default port,
|
||||
// which is what makes the result stable regardless of how PUBLIC_URL was
|
||||
// written. A trailing slash there would otherwise produce a double slash
|
||||
// here and a mismatch at Google.
|
||||
base = new URL(publicUrl).origin;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`PUBLIC_URL is not a URL (${publicUrl}), so the Google redirect URI cannot be derived ` +
|
||||
'from it. Google compares that value as an exact string, so this is refused rather ' +
|
||||
'than guessed at.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri: `${base}${GOOGLE_CALLBACK_PATH}`,
|
||||
// Both, or neither. One without the other cannot complete a token exchange,
|
||||
// and offering a button that always fails is worse than offering none.
|
||||
enabled: clientId !== '' && clientSecret !== ''
|
||||
};
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { pool } from '../db';
|
||||
import type { GoogleIdentity } from './oauth';
|
||||
|
||||
/**
|
||||
* Joining a Google identity to an account that already exists (#343).
|
||||
*
|
||||
* The smallest module in this feature and the one to read most carefully. It is
|
||||
* the point where somebody who has proved nothing to *this* shop is handed an
|
||||
* account that belongs to somebody who did.
|
||||
*
|
||||
* ## The rule, and why it is defensible
|
||||
*
|
||||
* Link only when Google asserts `email_verified` and the address matches an
|
||||
* existing customer exactly. Refuse otherwise.
|
||||
*
|
||||
* Google asserting the address means whoever completed that sign-in
|
||||
* demonstrably controls the mailbox. That mailbox is already the root of trust
|
||||
* for every other route into the account: it is where a password reset goes,
|
||||
* and following a reset link is enough to take the account over completely. So
|
||||
* linking on it grants nothing that was not already reachable, and it spares
|
||||
* the customer who came to Google precisely because they forgot the password.
|
||||
*
|
||||
* **Never link on an unverified address.** That is not a degraded version of the
|
||||
* same thing — it is an account takeover with extra steps, since the assertion
|
||||
* would be one nobody has checked. It is why this is a written rule rather than
|
||||
* a default that arrived with a library.
|
||||
*
|
||||
* ## Why the identity lookup happens before any of this
|
||||
*
|
||||
* The caller matches on `(provider, provider_sub)` first, and only reaches here
|
||||
* when that finds nothing. An identity that has signed in before keeps working
|
||||
* even if the address on either side has since changed, which is the whole
|
||||
* reason the subject claim is what gets stored.
|
||||
*/
|
||||
|
||||
export type LinkOutcome =
|
||||
| { kind: 'linked'; customerId: number }
|
||||
/** Google did not vouch for the address, or nothing matched it. */
|
||||
| { kind: 'refused' };
|
||||
|
||||
interface CustomerRow {
|
||||
id: number;
|
||||
disabled_at: Date | null;
|
||||
}
|
||||
|
||||
export async function linkToExistingCustomer(identity: GoogleIdentity): Promise<LinkOutcome> {
|
||||
// The first thing checked, and it is the whole policy. Everything below is
|
||||
// bookkeeping; this line is the security.
|
||||
if (!identity.emailVerified) return { kind: 'refused' };
|
||||
|
||||
const { rows } = await pool.query<CustomerRow>(
|
||||
// Compared exactly, against an address the caller has already lowercased
|
||||
// and trimmed the way registration does. A stricter comparison here would
|
||||
// silently fail to match and produce a second account for one person
|
||||
// instead of an error anybody sees.
|
||||
`SELECT id, disabled_at FROM customers WHERE email = $1`,
|
||||
[identity.email]
|
||||
);
|
||||
const customer = rows[0];
|
||||
if (!customer) return { kind: 'refused' };
|
||||
|
||||
// Refused here as well as at sign-in. Linking to a disabled account and then
|
||||
// refusing the session would leave the identity attached, so the next attempt
|
||||
// would take the sign-in path instead — turning a disabled account into one
|
||||
// that is merely inconvenient to reach.
|
||||
if (customer.disabled_at !== null) return { kind: 'refused' };
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO customer_identities (customer_id, provider, provider_sub, last_used_at)
|
||||
VALUES ($1, 'google', $2, now())
|
||||
ON CONFLICT (provider, provider_sub) DO NOTHING`,
|
||||
[customer.id, identity.sub]
|
||||
);
|
||||
|
||||
return { kind: 'linked', customerId: customer.id };
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { pool } from '../db';
|
||||
import type { GoogleIdentity } from './oauth';
|
||||
|
||||
/**
|
||||
* Creating a customer from a Google identity (#342).
|
||||
*
|
||||
* ## What this deliberately does not decide
|
||||
*
|
||||
* It reports `email-taken` when the address already belongs to a customer, and
|
||||
* stops there. Whether to join those two accounts is linking, which is the most
|
||||
* security-sensitive decision in this project and lives in `linkIdentity.ts`
|
||||
* (#343). Deciding it here would mean an account is handed over as a side
|
||||
* effect of an INSERT failing, which is exactly the shape that decision must
|
||||
* never take.
|
||||
*
|
||||
* ## Consent, which is the actual problem in this issue
|
||||
*
|
||||
* Registration captures two consents and stores their wording verbatim, and
|
||||
* marketing consent must start unticked (#56). A customer arriving through
|
||||
* Google has never seen those checkboxes and **cannot have**: the redirect to
|
||||
* Google happens before anyone knows whether they are new.
|
||||
*
|
||||
* So the account is created with both false and no stored wording, which is
|
||||
* legally correct — nobody has agreed to anything, and nothing is recorded as
|
||||
* though they had. What makes it honest rather than merely lawful is that the
|
||||
* customer is then asked, on a step that shows the same two sentences, through
|
||||
* the same endpoints registration uses. That is what keeps the stored text
|
||||
* byte-identical, which is the whole point of storing it.
|
||||
*
|
||||
* Skipping that step is allowed and leaves both false. A consent nobody gave is
|
||||
* the correct default and a perfectly fine resting state.
|
||||
*/
|
||||
|
||||
export type SignUpOutcome =
|
||||
| { kind: 'created'; customerId: number }
|
||||
/** The address is already an account's. #343 decides whether to link. */
|
||||
| { kind: 'email-taken' };
|
||||
|
||||
interface IdRow {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export async function createCustomerFromGoogle(identity: GoogleIdentity): Promise<SignUpOutcome> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const { rows: existing } = await client.query<IdRow>(
|
||||
`SELECT id FROM customers WHERE email = $1`,
|
||||
[identity.email]
|
||||
);
|
||||
if (existing.length) {
|
||||
await client.query('ROLLBACK');
|
||||
return { kind: 'email-taken' };
|
||||
}
|
||||
|
||||
const { rows } = await client.query<IdRow>(
|
||||
`INSERT INTO customers (email, password_hash, first_name, last_name, email_verified, unsubscribe_token)
|
||||
VALUES ($1, NULL, $2, $3, $4, $5)
|
||||
RETURNING id`,
|
||||
[
|
||||
identity.email,
|
||||
// Hints rather than requirements. Registration demands both names
|
||||
// because every email greets by first name, but Google may return
|
||||
// neither and refusing the sign-in over it would be absurd — the
|
||||
// greeting already has a fallback for exactly this.
|
||||
identity.firstName,
|
||||
identity.lastName,
|
||||
// Only on Google's word, never assumed. An unverified assertion is
|
||||
// worth nothing, and the caller sends the usual confirmation email when
|
||||
// this is false.
|
||||
identity.emailVerified,
|
||||
crypto.randomBytes(16).toString('hex')
|
||||
]
|
||||
);
|
||||
// The INSERT above has a RETURNING clause, so no row means the statement
|
||||
// did not do what it says.
|
||||
const customer = rows[0];
|
||||
if (!customer) throw new Error('the customer INSERT returned no row');
|
||||
|
||||
// In the same transaction, deliberately. A customer row with no identity is
|
||||
// an account nobody can sign in to and nobody can recover, because it has
|
||||
// no password either — the worst possible thing to leave behind.
|
||||
await client.query(
|
||||
`INSERT INTO customer_identities (customer_id, provider, provider_sub, last_used_at)
|
||||
VALUES ($1, 'google', $2, now())`,
|
||||
[customer.id, identity.sub]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return { kind: 'created', customerId: customer.id };
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
// Two sign-ins racing for the same brand-new address. The SELECT above
|
||||
// cannot see the other transaction's uncommitted row, so the unique index
|
||||
// is what actually holds — and losing that race means the account now
|
||||
// exists, which is 'email-taken' rather than an error.
|
||||
if ((err as { code?: string }).code === '23505') {
|
||||
return { kind: 'email-taken' };
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { GoogleConfig } from './config';
|
||||
|
||||
/**
|
||||
* The OpenID Connect authorization code flow, as far as Google implements it (#341).
|
||||
*
|
||||
* ## Why the code flow, and not the one with a token in the browser
|
||||
*
|
||||
* The browser is sent to Google, comes back carrying a code, and *this server*
|
||||
* exchanges that code for tokens over its own TLS connection. The customer's
|
||||
* browser never holds a token, so nothing that can read the page can steal one.
|
||||
*
|
||||
* PKCE goes in as well, even though this is a confidential client that holds a
|
||||
* secret. It costs one hash and it closes code interception outright rather
|
||||
* than resting the whole flow on the secret staying secret.
|
||||
*
|
||||
* ## Why there is no JWKS fetch here
|
||||
*
|
||||
* The id token arrives on a direct TLS connection to Google's token endpoint,
|
||||
* in the response to a request this server made. OpenID Connect Core §3.1.3.7
|
||||
* says signature verification MAY be skipped in exactly that case, because TLS
|
||||
* has already established who answered and that nothing altered the reply.
|
||||
*
|
||||
* That removes a key fetch, a cache and a rotation path from the auth code,
|
||||
* which is a real saving in the place least worth having moving parts. It
|
||||
* removes none of the claim checks: those are what stop a token minted for
|
||||
* another application, or for another attempt, being accepted here. See
|
||||
* `verifiedIdentity`, where every one of them is enforced and none is optional.
|
||||
*
|
||||
* The moment an id token reaches this code from anywhere other than that
|
||||
* response — a redirect fragment, a request body, a header — this reasoning
|
||||
* stops holding and signature verification becomes mandatory. Nothing does that
|
||||
* today, and nothing should.
|
||||
*/
|
||||
|
||||
const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
|
||||
|
||||
/**
|
||||
* The only scopes this asks for, and the reason publishing needs no review.
|
||||
*
|
||||
* `openid` produces the id token, `email` carries the address and the
|
||||
* `email_verified` flag the linking policy turns on, and `profile` carries the
|
||||
* names used when an account is created. All three are non-sensitive; adding a
|
||||
* sensitive one turns publishing into a verification review with a video
|
||||
* walkthrough and a wait measured in weeks.
|
||||
*/
|
||||
const SCOPES = 'openid email profile';
|
||||
|
||||
/**
|
||||
* Both spellings Google issues for the issuer claim.
|
||||
*
|
||||
* It really does use both, and accepting only one produces sign-ins that fail
|
||||
* for some customers and not others — which is about the least diagnosable
|
||||
* failure this flow can have.
|
||||
*/
|
||||
const ISSUERS = new Set(['https://accounts.google.com', 'accounts.google.com']);
|
||||
|
||||
/** A little slack for clock skew between this host and Google. */
|
||||
const CLOCK_SKEW_SECONDS = 60;
|
||||
|
||||
/** Who Google says signed in. Everything here has been checked. */
|
||||
export interface GoogleIdentity {
|
||||
/** The subject claim: opaque, stable, and the only safe identifier. */
|
||||
sub: string;
|
||||
email: string;
|
||||
/** Whether Google asserts the address. The linking policy turns on this. */
|
||||
emailVerified: boolean;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
}
|
||||
|
||||
/** The claims this cares about. Google sends more; none of it is wanted. */
|
||||
interface IdTokenClaims {
|
||||
iss?: unknown;
|
||||
aud?: unknown;
|
||||
exp?: unknown;
|
||||
sub?: unknown;
|
||||
nonce?: unknown;
|
||||
email?: unknown;
|
||||
email_verified?: unknown;
|
||||
given_name?: unknown;
|
||||
family_name?: unknown;
|
||||
}
|
||||
|
||||
/** One attempt's secrets, minted at the start and spent at the callback. */
|
||||
export interface AttemptSecrets {
|
||||
state: string;
|
||||
nonce: string;
|
||||
codeVerifier: string;
|
||||
}
|
||||
|
||||
function randomToken(): string {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh secrets for one sign-in attempt.
|
||||
*
|
||||
* `state` proves the callback belongs to the request this browser started.
|
||||
* `nonce` is echoed inside the id token and proves the token was minted for
|
||||
* this attempt rather than replayed from another. `codeVerifier` is PKCE.
|
||||
*
|
||||
* Three separate values rather than one reused three times: they are checked by
|
||||
* different parties at different moments, and a single value would mean
|
||||
* anything that learned it from one check could satisfy the others.
|
||||
*/
|
||||
export function newAttempt(): AttemptSecrets {
|
||||
return { state: randomToken(), nonce: randomToken(), codeVerifier: randomToken() };
|
||||
}
|
||||
|
||||
/** The S256 challenge for a verifier. Google supports S256; plain is not offered. */
|
||||
export function codeChallenge(verifier: string): string {
|
||||
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
||||
}
|
||||
|
||||
/** Where to send the browser to begin. */
|
||||
export function authorizationUrl(config: GoogleConfig, attempt: AttemptSecrets): string {
|
||||
const url = new URL(AUTH_ENDPOINT);
|
||||
url.searchParams.set('client_id', config.clientId);
|
||||
url.searchParams.set('redirect_uri', config.redirectUri);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('scope', SCOPES);
|
||||
url.searchParams.set('state', attempt.state);
|
||||
url.searchParams.set('nonce', attempt.nonce);
|
||||
url.searchParams.set('code_challenge', codeChallenge(attempt.codeVerifier));
|
||||
url.searchParams.set('code_challenge_method', 'S256');
|
||||
// No `access_type=offline` and no `prompt=consent`, deliberately. Those ask
|
||||
// for a refresh token, and Google is being used to answer one question once —
|
||||
// a stored refresh token would be a long-lived credential with nothing to
|
||||
// spend it on and everything to lose if it leaked.
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trades the code for an id token.
|
||||
*
|
||||
* Returns the raw token rather than parsed claims, so the exchange and the
|
||||
* checking stay separable: the checking is pure and can be tested exhaustively
|
||||
* without a network, which is where the security actually lives.
|
||||
*/
|
||||
export async function exchangeCode(config: GoogleConfig, code: string, codeVerifier: string): Promise<string> {
|
||||
const response = await fetch(TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
redirect_uri: config.redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
code_verifier: codeVerifier
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Logged rather than returned. The body names the client id and can carry
|
||||
// the secret back in an error description, and none of it means anything to
|
||||
// the customer.
|
||||
const detail = await response.text().catch(() => '');
|
||||
console.warn(`[google] token exchange failed: ${response.status} ${detail.slice(0, 300)}`);
|
||||
throw new Error('the Google token exchange was refused');
|
||||
}
|
||||
|
||||
const body = (await response.json()) as { id_token?: unknown };
|
||||
if (typeof body.id_token !== 'string' || body.id_token === '') {
|
||||
throw new Error('Google returned no id token');
|
||||
}
|
||||
return body.id_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* The claims inside an id token, without verifying its signature.
|
||||
*
|
||||
* Named for what it does. Anywhere the token has not come straight back from
|
||||
* the token endpoint over TLS, this function is the wrong one to call, and the
|
||||
* name is meant to make that obvious at the call site.
|
||||
*/
|
||||
function decodeClaims(idToken: string): IdTokenClaims {
|
||||
const [, payload] = idToken.split('.');
|
||||
if (!payload) throw new Error('the id token is not a JWT');
|
||||
try {
|
||||
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as IdTokenClaims;
|
||||
} catch {
|
||||
throw new Error('the id token payload is not JSON');
|
||||
}
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value !== '' ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who signed in, or a thrown error saying which check failed.
|
||||
*
|
||||
* Every check here is mandatory, and each one closes something specific:
|
||||
*
|
||||
* | Claim | What accepting it blindly would allow |
|
||||
* | --- | --- |
|
||||
* | `iss` | A token from an issuer we never chose to trust |
|
||||
* | `aud` | A token minted for a different application, replayed here |
|
||||
* | `exp` | A token captured once and reused indefinitely |
|
||||
* | `nonce` | A token from an earlier attempt, replayed into this one |
|
||||
* | `sub` | An identity row keyed on nothing |
|
||||
*
|
||||
* The messages name the failing check because they are logged, never shown. A
|
||||
* customer sees one refusal for every cause, exactly as the passkey path does.
|
||||
*/
|
||||
export function verifiedIdentity(
|
||||
idToken: string,
|
||||
expected: { clientId: string; nonce: string },
|
||||
now: Date = new Date()
|
||||
): GoogleIdentity {
|
||||
const claims = decodeClaims(idToken);
|
||||
|
||||
if (typeof claims.iss !== 'string' || !ISSUERS.has(claims.iss)) {
|
||||
throw new Error(`unexpected issuer: ${String(claims.iss)}`);
|
||||
}
|
||||
if (claims.aud !== expected.clientId) {
|
||||
throw new Error('the id token was minted for a different client');
|
||||
}
|
||||
|
||||
const exp = typeof claims.exp === 'number' ? claims.exp : NaN;
|
||||
if (!Number.isFinite(exp)) throw new Error('the id token has no expiry');
|
||||
if (exp + CLOCK_SKEW_SECONDS < Math.floor(now.getTime() / 1000)) {
|
||||
throw new Error('the id token has expired');
|
||||
}
|
||||
|
||||
// Compared in constant time. The nonce is a secret this server minted, and a
|
||||
// byte-by-byte comparison that stops early is a timing oracle for it.
|
||||
const nonce = asString(claims.nonce) ?? '';
|
||||
const supplied = Buffer.from(nonce);
|
||||
const wanted = Buffer.from(expected.nonce);
|
||||
if (supplied.length !== wanted.length || !crypto.timingSafeEqual(supplied, wanted)) {
|
||||
throw new Error('the id token belongs to a different sign-in attempt');
|
||||
}
|
||||
|
||||
const sub = asString(claims.sub);
|
||||
if (sub === null) throw new Error('the id token carries no subject');
|
||||
|
||||
const email = asString(claims.email);
|
||||
if (email === null) throw new Error('the id token carries no email');
|
||||
|
||||
return {
|
||||
sub,
|
||||
email: email.toLowerCase().trim(),
|
||||
// Strictly true, never merely truthy. Google sends a boolean, and treating
|
||||
// the string "false" as a verified address is the exact mistake that turns
|
||||
// the linking policy into an account-takeover path.
|
||||
emailVerified: claims.email_verified === true,
|
||||
firstName: asString(claims.given_name),
|
||||
lastName: asString(claims.family_name)
|
||||
};
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* Where a customer is sent back to after signing in with Google (#341).
|
||||
*
|
||||
* An OAuth flow leaves this application entirely and comes back, so where the
|
||||
* customer was has to survive the round trip — and the value doing that is one
|
||||
* an attacker can propose, by handing somebody a link to our own start route
|
||||
* with their destination attached.
|
||||
*
|
||||
* Unchecked, that makes the start route an open redirect wearing a sign-in flow
|
||||
* as a disguise: a link on our real domain, with our real certificate, that
|
||||
* deposits the customer somewhere else entirely. It is precisely the shape a
|
||||
* credible phishing page wants, and it is worth more to an attacker than most
|
||||
* bugs in the flow it hides behind.
|
||||
*
|
||||
* Its own module rather than a helper inside the route, so it can be tested
|
||||
* without a database connection and so the next path that needs the same
|
||||
* question has somewhere obvious to ask it.
|
||||
*/
|
||||
|
||||
/** Where anyone goes when the answer is "not that". */
|
||||
export const DEFAULT_RETURN_TO = '/';
|
||||
|
||||
/**
|
||||
* A path inside this site, or the home page.
|
||||
*
|
||||
* Everything that is not plainly a local path is replaced rather than rejected.
|
||||
* A refusal would mean a customer who signed in successfully sees an error
|
||||
* about a query parameter they never typed, which helps nobody — the storefront
|
||||
* is a fine place to land.
|
||||
*
|
||||
* The cases worth naming, because each is a way of writing "somewhere else"
|
||||
* that still starts with a slash or looks like it might:
|
||||
*
|
||||
* - `//evil.test` is protocol-relative, and browsers treat it as absolute
|
||||
* - `/\evil.test` is treated as protocol-relative by several browsers
|
||||
* - `https://evil.test` does not start with a slash at all
|
||||
* - a backslash anywhere in the authority position is normalised to a slash
|
||||
*/
|
||||
export function safeReturnTo(value: unknown): string {
|
||||
if (typeof value !== 'string' || value === '') return DEFAULT_RETURN_TO;
|
||||
if (!value.startsWith('/')) return DEFAULT_RETURN_TO;
|
||||
// Both slashes, because browsers disagree about which they normalise.
|
||||
if (value.startsWith('//') || value.startsWith('/\\')) return DEFAULT_RETURN_TO;
|
||||
// A control character can truncate or split the Location header a browser
|
||||
// reads. Checked by code point rather than by a regex, because a regex that
|
||||
// matches control characters trips a lint rule existing for good reasons of
|
||||
// its own, and this is clearer than an exemption from it.
|
||||
for (const character of value) {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
if (code < 0x20 || code === 0x7f) return DEFAULT_RETURN_TO;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import sharp, { type Sharp } from 'sharp';
|
||||
import { promises as fs } from 'fs';
|
||||
|
||||
/**
|
||||
* Rebuilding an uploaded image so it carries nothing but the picture.
|
||||
*
|
||||
* A file arrives as the camera wrote it, and a camera writes EXIF — which
|
||||
* routinely includes the coordinates the photo was taken at. Those files are
|
||||
* served publicly from /uploads/, so an unmodified product photo publishes the
|
||||
* location it was taken. Nobody sending in a photograph of a vase expects that.
|
||||
*
|
||||
* The fix is to re-encode rather than to delete tags. Deleting requires knowing
|
||||
* every tag that could carry something sensitive, across formats and camera
|
||||
* makers, forever. Re-encoding builds a new file from the decoded pixels, so
|
||||
* there is nothing left that could have been missed — the same reasoning that
|
||||
* makes uploadTypes.ts an allowlist rather than a denylist.
|
||||
*
|
||||
* Format is deliberately preserved. Converting to WebP would compress better,
|
||||
* but it changes stored extensions, and therefore item_images.image_path, and
|
||||
* therefore turns the backfill into a rename with a window where rows point at
|
||||
* files that no longer exist. See #226.
|
||||
*/
|
||||
|
||||
/** Comfortably larger than anything the storefront renders. */
|
||||
export const MAX_DIMENSION = 2000;
|
||||
|
||||
/** Where further reduction starts to show on a photograph. */
|
||||
export const QUALITY = 82;
|
||||
|
||||
interface ImageFacts {
|
||||
width?: number;
|
||||
height?: number;
|
||||
exif?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a file still needs rebuilding.
|
||||
*
|
||||
* Pure, and the backfill's entire idempotency argument: a file with no EXIF
|
||||
* that is already within bounds is already in its final state, so a re-run
|
||||
* skips it rather than putting it through a second lossy pass. Anything
|
||||
* unreadable is processed rather than skipped — a file we cannot describe is
|
||||
* not one to assume is safe.
|
||||
*/
|
||||
export function needsProcessing(meta: ImageFacts): boolean {
|
||||
if (meta.exif !== undefined && meta.exif !== null) return true;
|
||||
if (meta.width === undefined || meta.height === undefined) return true;
|
||||
return meta.width > MAX_DIMENSION || meta.height > MAX_DIMENSION;
|
||||
}
|
||||
|
||||
function encoderFor(instance: Sharp, mimetype: string): Sharp {
|
||||
switch (mimetype) {
|
||||
case 'image/jpeg':
|
||||
return instance.jpeg({ quality: QUALITY });
|
||||
case 'image/webp':
|
||||
return instance.webp({ quality: QUALITY });
|
||||
case 'image/png':
|
||||
// PNG is lossless, so quality does not apply and this will not shrink
|
||||
// much. It still strips EXIF and still bounds the dimensions, which are
|
||||
// the two things being bought here.
|
||||
return instance.png({ compressionLevel: 9 });
|
||||
default:
|
||||
// Unreachable: the allowlist in uploadTypes.ts is these three. Throwing
|
||||
// rather than passing the file through unmodified, because "we did not
|
||||
// recognise it so we left the metadata in" is the failure mode this
|
||||
// module exists to make impossible.
|
||||
throw new Error(`cannot re-encode unsupported type ${mimetype}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the file at `filePath`, in its own format, stripped and bounded.
|
||||
*
|
||||
* Writes to a sibling temporary file and renames over the original, because
|
||||
* writing in place would leave a half-written image being served if the process
|
||||
* died mid-write — and sharp cannot read and write the same path in one pass
|
||||
* anyway.
|
||||
*
|
||||
* `withoutEnlargement` so a small image is not blown up to the cap: the ceiling
|
||||
* is a maximum, not a target.
|
||||
*/
|
||||
export async function reencodeInPlace(filePath: string, mimetype: string): Promise<void> {
|
||||
const temporary = `${filePath}.reencoding`;
|
||||
try {
|
||||
await encoderFor(
|
||||
// `animated` only for WebP, which is the one allowed type that can carry
|
||||
// more than one frame. Reading an animated WebP without it decodes the
|
||||
// first frame alone and silently writes back a still — destroying the
|
||||
// uploader's image while reporting success. It is not set unconditionally
|
||||
// because it changes how `resize` interprets height (the full frame
|
||||
// strip, not one frame), which would be wrong for the other two.
|
||||
sharp(filePath, mimetype === 'image/webp' ? { animated: true } : {})
|
||||
// Applies the EXIF orientation to the pixels, and must come before
|
||||
// resize (#300).
|
||||
//
|
||||
// A camera does not turn its sensor data round. It writes the pixels as
|
||||
// the sensor read them and sets an Orientation tag saying which way up
|
||||
// they go, and every viewer honours that — which is why a portrait
|
||||
// photograph looks upright to the person who took it and to the person
|
||||
// who attached it. Stripping the tag without applying it does not leave
|
||||
// the photo alone: it leaves the pixels sideways with nothing left to
|
||||
// explain them, and the sender's upright photo arrives on its side.
|
||||
//
|
||||
// Before resize because the resize bounds are width and height, and for
|
||||
// a portrait photo those are the wrong way round until the rotation has
|
||||
// happened. A 3000x4000 photograph stored as 4000x3000 would otherwise
|
||||
// be bounded on the wrong axis.
|
||||
//
|
||||
// No argument: that is what makes it read the tag rather than turn the
|
||||
// image by a fixed amount.
|
||||
.rotate()
|
||||
.resize({
|
||||
width: MAX_DIMENSION,
|
||||
height: MAX_DIMENSION,
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true
|
||||
}),
|
||||
mimetype
|
||||
// No withMetadata(): omitting it is what drops EXIF, ICC and everything
|
||||
// else. Calling it would put the metadata back.
|
||||
).toFile(temporary);
|
||||
|
||||
await fs.rename(temporary, filePath);
|
||||
} catch (err) {
|
||||
await fs.unlink(temporary).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Which way a quarter turn goes, in the words the buttons use. */
|
||||
export type RotateDirection = 'left' | 'right';
|
||||
|
||||
/**
|
||||
* sharp reads a positive angle as clockwise, so the sign is the whole of the
|
||||
* mapping — and getting it backwards produces a control that works perfectly
|
||||
* and does the opposite of what its label says, which no dimension assertion
|
||||
* would ever catch.
|
||||
*/
|
||||
const QUARTER_TURN: Record<RotateDirection, number> = { left: -90, right: 90 };
|
||||
|
||||
/**
|
||||
* Turns the file at `filePath` a quarter turn, in its own format.
|
||||
*
|
||||
* The remedy for photos uploaded before #300 taught the re-encode to apply EXIF
|
||||
* orientation instead of discarding it. Those files cannot be repaired
|
||||
* automatically — the tag that said which way up they went is gone — so a
|
||||
* person has to look at each one and decide.
|
||||
*
|
||||
* Rewrites the pixels rather than recording an angle. An angle would keep the
|
||||
* bytes pristine, but it would put an obligation on every consumer — the
|
||||
* storefront, both admin screens, the drafting worker's photo reader, and the
|
||||
* rembg sidecar — and any one that forgot would show the photo sideways. The
|
||||
* sidecar in particular is not ours to teach.
|
||||
*
|
||||
* Same temporary-file-and-rename shape as `reencodeInPlace`, for the same two
|
||||
* reasons: sharp cannot read and write one path in a single pass, and a process
|
||||
* that dies mid-write must leave the old photo intact rather than half a new
|
||||
* one.
|
||||
*
|
||||
* No `resize`. The file went through `reencodeInPlace` on upload and is already
|
||||
* within bounds, so re-applying the cap would be a second lossy pass buying
|
||||
* nothing. No metadata handling either: the re-encode already stripped it, and
|
||||
* there is nothing left to strip.
|
||||
*/
|
||||
export async function rotateInPlace(
|
||||
filePath: string,
|
||||
mimetype: string,
|
||||
direction: RotateDirection
|
||||
): Promise<void> {
|
||||
const temporary = `${filePath}.rotating`;
|
||||
try {
|
||||
await encoderFor(
|
||||
// Exactly the `animated` argument reencodeInPlace uses, and the reason is
|
||||
// sharper here. Reading an animated WebP without it decodes the first
|
||||
// frame alone, so omitting it would silently write back a still and
|
||||
// destroy the animation while reporting success. With it, sharp refuses
|
||||
// the rotation outright — multi-page images can only be turned 180° —
|
||||
// which is the honest answer and surfaces as a 500 with the file
|
||||
// untouched. A still WebP has one page and turns normally.
|
||||
sharp(filePath, mimetype === 'image/webp' ? { animated: true } : {}).rotate(
|
||||
QUARTER_TURN[direction]
|
||||
),
|
||||
mimetype
|
||||
).toFile(temporary);
|
||||
|
||||
await fs.rename(temporary, filePath);
|
||||
} catch (err) {
|
||||
await fs.unlink(temporary).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import path from 'path';
|
||||
import { pool } from './db';
|
||||
import { typeForExtension } from './uploadTypes';
|
||||
import { rotateInPlace, RotateDirection } from './imageProcessing';
|
||||
|
||||
/**
|
||||
* Turning one stored photo, and everything that photo is stored alongside.
|
||||
*
|
||||
* Its own module rather than another export on backgroundRemoval.ts: the two
|
||||
* features share a table and nothing else. Rotation touches no sidecar, records
|
||||
* no provenance, and is not idempotent — bundling them would put a function
|
||||
* with none of that module's invariants under its documentation.
|
||||
*/
|
||||
|
||||
interface ImageRow {
|
||||
image_path: string;
|
||||
original_image_path: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when the photo is not on that item.
|
||||
*
|
||||
* A named class for the same reason NoOriginalToRestoreError is one: the route
|
||||
* has to tell "no such photo" apart from "the application is in trouble", and
|
||||
* the two need opposite replies. Everything else stays loud.
|
||||
*/
|
||||
export class ImageNotOnItemError extends Error {}
|
||||
|
||||
/**
|
||||
* basename only. `image_path` is stored as `/uploads/<name>` and the directory
|
||||
* it lives in is a server constant — the same rule readPhotos and
|
||||
* removeImageBackground both follow.
|
||||
*/
|
||||
async function rotateStoredFile(storedPath: string, direction: RotateDirection): Promise<void> {
|
||||
const name = path.basename(storedPath);
|
||||
const mediaType = typeForExtension(path.extname(name));
|
||||
if (mediaType === null) {
|
||||
throw new Error(`cannot rotate ${name}: unrecognised extension`);
|
||||
}
|
||||
await rotateInPlace(path.join(process.env.UPLOADS_DIR ?? '', name), mediaType, direction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns one photo of one item, and its pristine original when it has one.
|
||||
*
|
||||
* Both files or neither is not achievable here and is not claimed. What matters
|
||||
* is that they cannot end up disagreeing silently: an image that has been
|
||||
* through #281 has a displayed cut-out and a recorded original, and rotating
|
||||
* only the first would leave Restore original quietly un-rotating the photo —
|
||||
* the undo of one feature becoming a regression of another.
|
||||
*
|
||||
* The displayed file goes first, so a failure on the original leaves the admin
|
||||
* looking at a photo that visibly moved, with the failure reported. A retry
|
||||
* would turn the displayed file a second time: rotation is not idempotent the
|
||||
* way removeImageBackground is, because nothing records how far the last
|
||||
* attempt got. That is accepted rather than engineered around — it takes the
|
||||
* disk failing between two writes, and the remedy is one press in the other
|
||||
* direction.
|
||||
*
|
||||
* No column is written. The paths are the same afterwards; only the bytes
|
||||
* differ.
|
||||
*/
|
||||
export async function rotateItemImage(
|
||||
itemId: number,
|
||||
imageId: number,
|
||||
direction: RotateDirection
|
||||
): Promise<void> {
|
||||
const { rows } = await pool.query<ImageRow>(
|
||||
`SELECT image_path, original_image_path FROM item_images WHERE id = $1 AND item_id = $2`,
|
||||
[imageId, itemId]
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
throw new ImageNotOnItemError(`no image ${imageId} on item ${itemId}`);
|
||||
}
|
||||
|
||||
await rotateStoredFile(row.image_path, direction);
|
||||
if (row.original_image_path !== null) {
|
||||
await rotateStoredFile(row.original_image_path, direction);
|
||||
}
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
/**
|
||||
* The one validated path from a multipart request to files on the uploads
|
||||
* volume.
|
||||
*
|
||||
* Extracted from routes/admin.ts when a second caller appeared (#222's public
|
||||
* intake endpoint). It is deliberately one module rather than two similar
|
||||
* ones: every property that makes an upload safe here — the type allowlist,
|
||||
* the magic-byte check after the write, names from a CSPRNG rather than from
|
||||
* `originalname`, the re-encode that strips EXIF, and the cleanup of whatever
|
||||
* a refused request left behind — is a property a second implementation would
|
||||
* have to reproduce exactly. A near-copy that drifted would be precisely the
|
||||
* gap #95, #103, #180 and #226 exist to close.
|
||||
*
|
||||
* Nothing below changed in the move. The comments came with it, because they
|
||||
* record why the code is shaped as it is and are the most valuable part of it.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import multer from 'multer';
|
||||
import { promises as fs } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { PoolClient } from 'pg';
|
||||
import {
|
||||
ALLOWED_IMAGE_TYPES,
|
||||
SIGNATURE_BYTES,
|
||||
extensionFor,
|
||||
isAllowedImageType,
|
||||
signatureMatches
|
||||
} from './uploadTypes';
|
||||
import { reencodeInPlace } from './imageProcessing';
|
||||
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/app/uploads';
|
||||
|
||||
// Multer writes to disk with no size cap unless one is given, so a single
|
||||
// request could fill the uploads volume. Bound every dimension of the
|
||||
// multipart body: image count, bytes per image, and the small text fields
|
||||
// (name/description/price) that accompany them.
|
||||
const MAX_IMAGES_PER_REQUEST = 6;
|
||||
// 8 MB, not 8 MiB — this is the ceiling S5693 treats as safe, and 8 * 1024 *
|
||||
// 1024 sits just over it. Plenty for a product photo either way.
|
||||
const MAX_IMAGE_BYTES = 8_000_000;
|
||||
const MAX_TEXT_FIELDS = 8;
|
||||
const MAX_TEXT_FIELD_BYTES = 64 * 1024;
|
||||
|
||||
// Refused before a byte is written. This catches the honest mistake — picking a
|
||||
// PDF by accident — and nothing more, because file.mimetype is whatever the
|
||||
// caller wrote in the multipart headers. The bytes are checked after the write;
|
||||
// see verifyUploadedImages.
|
||||
class UnsupportedImageTypeError extends Error {}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: UPLOADS_DIR,
|
||||
// Stored names come from a CSPRNG rather than a timestamp plus Math.random,
|
||||
// which is predictable enough that a caller could guess (or collide with)
|
||||
// another upload's path.
|
||||
//
|
||||
// The extension comes from the validated content type rather than from
|
||||
// path.extname(file.originalname), so the name on disk cannot disagree with
|
||||
// what the file claims to be — a caller cannot get `.html` onto the uploads
|
||||
// volume by naming their file that way.
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = extensionFor(file.mimetype);
|
||||
if (!ext) {
|
||||
// Unreachable while fileFilter runs first, and here so that it stays
|
||||
// unreachable rather than silently writing a file with no extension.
|
||||
cb(new UnsupportedImageTypeError(`unsupported image type ${file.mimetype}`), '');
|
||||
return;
|
||||
}
|
||||
cb(null, `${randomUUID()}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Reviewed for #180. Bounding one request is only half the problem — see
|
||||
// discardUnlessAccepted below for the other half, which is bounding what the
|
||||
// volume accumulates across requests that were refused.
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: MAX_IMAGE_BYTES,
|
||||
files: MAX_IMAGES_PER_REQUEST,
|
||||
fields: MAX_TEXT_FIELDS,
|
||||
fieldSize: MAX_TEXT_FIELD_BYTES
|
||||
},
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!isAllowedImageType(file.mimetype)) {
|
||||
cb(new UnsupportedImageTypeError(
|
||||
`${file.mimetype} is not an accepted image type — allowed: ${ALLOWED_IMAGE_TYPES.join(', ')}`
|
||||
));
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Reads only the leading bytes — enough to identify a format, not enough to
|
||||
// care how large the file is. The handle is closed before anything is unlinked,
|
||||
// because an open handle makes the unlink fail on Windows.
|
||||
//
|
||||
// Reviewed for #180. The path is not caller-controlled despite arriving from a
|
||||
// request: multer composes it from `destination`, which is a server constant,
|
||||
// and `filename`, which the storage above sets to `randomUUID()` plus an
|
||||
// extension looked up from the validated content type. The caller's
|
||||
// `originalname` is never consulted, so no part of the path traverses anywhere.
|
||||
async function readHead(filePath: string): Promise<Buffer> {
|
||||
const handle = await fs.open(filePath, 'r');
|
||||
try {
|
||||
const buffer = Buffer.alloc(SIGNATURE_BYTES);
|
||||
const { bytesRead } = await handle.read(buffer, 0, SIGNATURE_BYTES, 0);
|
||||
return buffer.subarray(0, bytesRead);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Best effort: a file that cannot be removed should not turn a 400 into a 500,
|
||||
// but it must not be left behind quietly either.
|
||||
async function discardUploads(files: Express.Multer.File[]): Promise<void> {
|
||||
await Promise.all(
|
||||
files.map((file) =>
|
||||
fs.unlink(file.path).catch((err: unknown) => {
|
||||
console.error(`[upload] could not remove rejected file ${file.path}:`, err);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a request's uploaded files unless the request actually succeeded.
|
||||
*
|
||||
* multer writes to disk before any route logic runs, and its own cleanup only
|
||||
* covers errors it raised itself. Everything after that — a failed signature
|
||||
* check, a malformed `category_id`, a database error, a dropped connection —
|
||||
* previously left the bytes on the volume with nothing referencing them: no row
|
||||
* to find them by, and no bound on how many could accumulate. Bounding the size
|
||||
* of one upload does not help if every refused upload is kept forever (#180).
|
||||
*
|
||||
* Registered as soon as multer succeeds rather than at each `return`, so a
|
||||
* route added later inherits it instead of having to remember it. That is the
|
||||
* whole reason it is a hook and not a call: the failure it prevents is someone
|
||||
* adding a fourth early return.
|
||||
*
|
||||
* `close` rather than `finish`, so an aborted connection is covered too, and
|
||||
* `writableEnded` distinguishes a response that completed from one that never
|
||||
* did — the latter is not a success however its status code reads.
|
||||
*/
|
||||
function discardUnlessAccepted(req: Request, res: Response): void {
|
||||
res.on('close', () => {
|
||||
if (res.writableEnded && res.statusCode < 400) return;
|
||||
void discardUploads((req.files as Express.Multer.File[]) || []);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms each stored file actually is what it was declared to be.
|
||||
*
|
||||
* This cannot happen in multer's fileFilter, which runs before the stream has
|
||||
* been read — there are no bytes to look at yet. So the check runs after the
|
||||
* write.
|
||||
*
|
||||
* Checking only: removing the files is discardUnlessAccepted's job, and doing
|
||||
* it here as well would unlink twice and log an ENOENT for every refused
|
||||
* upload. That also covers the case this function used to miss — `readHead`
|
||||
* itself throwing, which returned no message and so cleaned up nothing.
|
||||
*
|
||||
* Returns the message to refuse with, or null when everything checks out.
|
||||
*/
|
||||
async function verifyUploadedImages(req: Request): Promise<string | null> {
|
||||
const files = (req.files as Express.Multer.File[]) || [];
|
||||
|
||||
for (const file of files) {
|
||||
const head = await readHead(file.path);
|
||||
if (!signatureMatches(file.mimetype, head)) {
|
||||
return `${file.originalname} does not contain ${file.mimetype} data`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds every accepted file so it carries no metadata (#226).
|
||||
*
|
||||
* After verification, deliberately: re-encoding a file whose bytes do not match
|
||||
* its declared type would be doing work on something already refused, and
|
||||
* sharp's own error would replace the clearer message that check produces.
|
||||
*
|
||||
* A failure here refuses the upload rather than storing the original. Storing
|
||||
* it would mean the one case where a photo keeps the coordinates it was taken
|
||||
* at is the case nobody was told about.
|
||||
*
|
||||
* Returns the message to refuse with, or null when every file was rebuilt.
|
||||
*/
|
||||
async function stripUploadedImages(req: Request): Promise<string | null> {
|
||||
const files = (req.files as Express.Multer.File[]) || [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
await reencodeInPlace(file.path, file.mimetype);
|
||||
} catch (err) {
|
||||
console.error(`[upload] could not re-encode ${file.path}:`, err);
|
||||
return `${file.originalname} could not be processed`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// No error-handling middleware is mounted on the app, so translate multer's
|
||||
// limit errors here instead of letting them surface as a generic 500.
|
||||
const uploadImages = (req: Request, res: Response, next: NextFunction) => {
|
||||
upload.array('images', MAX_IMAGES_PER_REQUEST)(req, res, (err: unknown) => {
|
||||
if (err instanceof UnsupportedImageTypeError) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
if (err instanceof multer.MulterError) {
|
||||
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
|
||||
return res.status(status).json({ error: err.message });
|
||||
}
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
// Every file is on disk by this point and multer will not clean up after
|
||||
// itself again, so the bytes become this request's responsibility before
|
||||
// anything else is allowed to fail.
|
||||
discardUnlessAccepted(req, res);
|
||||
|
||||
verifyUploadedImages(req)
|
||||
.then((problem) => {
|
||||
if (problem) {
|
||||
res.status(400).json({ error: problem });
|
||||
return null;
|
||||
}
|
||||
return stripUploadedImages(req);
|
||||
})
|
||||
.then((problem) => {
|
||||
// The first stage returns null both when it answered and when it found
|
||||
// nothing wrong, so the response itself is what distinguishes them.
|
||||
if (res.headersSent) return;
|
||||
if (problem) {
|
||||
res.status(400).json({ error: problem });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Records uploaded files as an item's images.
|
||||
*
|
||||
* Create and update wrote this loop separately, differing only in where the id
|
||||
* came from and where the sort order started — zero for a new item, one past
|
||||
* the current maximum for an existing one. Both are parameters now.
|
||||
*
|
||||
* It also means the `/uploads/` prefix is written once. That matters more than
|
||||
* it looks: #103 made the stored value the path `uploadUrl` joins an origin
|
||||
* onto, so it is a contract rather than a string, and two places to change it
|
||||
* is one place to forget.
|
||||
*/
|
||||
async function insertItemImages(
|
||||
client: PoolClient,
|
||||
itemId: number,
|
||||
files: Express.Multer.File[],
|
||||
firstSortOrder: number
|
||||
): Promise<void> {
|
||||
// Iterated by entry rather than by index, so there is no possibly-undefined
|
||||
// element to guard — the create path used to fall back to an empty filename,
|
||||
// which would have stored a path pointing at the uploads directory itself.
|
||||
for (const [offset, file] of files.entries()) {
|
||||
await client.query(
|
||||
`INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, $2, $3)`,
|
||||
[itemId, `/uploads/${file.filename}`, firstSortOrder + offset]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
uploadImages,
|
||||
verifyUploadedImages,
|
||||
stripUploadedImages,
|
||||
insertItemImages,
|
||||
MAX_IMAGES_PER_REQUEST,
|
||||
MAX_IMAGE_BYTES
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
import { sendMail } from '../mailer';
|
||||
import { getSettings } from '../adminSettings';
|
||||
|
||||
/**
|
||||
* Abuse alerts, sent directly rather than through the editable templates.
|
||||
*
|
||||
* An abuse alert is not copy anyone will want to reword, and making it editable
|
||||
* means it can be broken — a required placeholder removed from an alert nobody
|
||||
* reads until an incident is a poor way to discover the validation.
|
||||
*/
|
||||
|
||||
/** At most one of each kind per hour. */
|
||||
const ALERT_INTERVAL_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Throttled in memory rather than in the database.
|
||||
*
|
||||
* A restart loses it, so a deploy during an incident can send one extra alert.
|
||||
* That is a far better trade than writing to admin_settings from the request
|
||||
* path on every refused submission. This is a single-container deployment; were
|
||||
* it ever replicated, each replica would alert once per window and this would
|
||||
* have to move.
|
||||
*/
|
||||
const lastSent = new Map<string, number>();
|
||||
|
||||
function shouldSend(key: string, now: number): boolean {
|
||||
const previous = lastSent.get(key);
|
||||
if (previous !== undefined && now - previous < ALERT_INTERVAL_MS) return false;
|
||||
lastSent.set(key, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Exported for tests, which need each case to start from silence. */
|
||||
export function resetAlertThrottleForTests(): void {
|
||||
lastSent.clear();
|
||||
}
|
||||
|
||||
async function send(key: string, subject: string, html: string): Promise<void> {
|
||||
const { intakeNotifyEmail } = await getSettings();
|
||||
const to = intakeNotifyEmail?.trim();
|
||||
if (!to) return;
|
||||
if (!shouldSend(key, Date.now())) return;
|
||||
|
||||
await sendMail(to, subject, html);
|
||||
}
|
||||
|
||||
export async function alertCeilingReached(used: number, ceiling: number): Promise<void> {
|
||||
await send(
|
||||
'ceiling',
|
||||
'Intake submissions are being refused',
|
||||
`<p>The intake surface has taken ${used} submissions in the last 24 hours, which is at or ` +
|
||||
`over the ceiling of ${ceiling}. Further submissions are being refused until the window ` +
|
||||
`rolls.</p>` +
|
||||
`<p>The storefront, checkout and admin are unaffected. If this is legitimate, raise the ` +
|
||||
`ceiling or reset the window. If it is not, revoke the link being used.</p>`
|
||||
);
|
||||
}
|
||||
|
||||
export async function alertLinkThreshold(
|
||||
linkId: number,
|
||||
label: string,
|
||||
used: number,
|
||||
threshold: number
|
||||
): Promise<void> {
|
||||
await send(
|
||||
`link:${linkId}`,
|
||||
`An upload link is being used heavily: ${label}`,
|
||||
`<p>The link <strong>${label}</strong> has taken ${used} submissions in the last 24 hours, ` +
|
||||
`past the alert threshold of ${threshold}.</p>` +
|
||||
`<p>This is the signal that a link has been shared further than intended. If that is what ` +
|
||||
`has happened, revoke it from the Upload links screen — the submissions already received ` +
|
||||
`are kept, and are in the review queue.</p>`
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import crypto from 'crypto';
|
||||
import { trimTrailingSlashes } from '../utils';
|
||||
|
||||
/**
|
||||
* Links in the notification email that act without a login.
|
||||
*
|
||||
* Only two actions are signable, and neither can publish. The worst case of a
|
||||
* leaked link is a wasted API call or a hide the review queue can undo — which
|
||||
* is what makes it acceptable to put them in an inbox at all.
|
||||
*
|
||||
* Signed over the item, the action and the expiry together. Signing any subset
|
||||
* would let a link be replayed against a different item or upgraded to a
|
||||
* different action, and leaving the expiry out of the payload would let anyone
|
||||
* holding an expired link extend it by editing the timestamp in the URL.
|
||||
*/
|
||||
export type IntakeAction = 'regenerate' | 'discard';
|
||||
|
||||
/** Thirty days. Long enough to survive a holiday, short enough to lapse. */
|
||||
export const ACTION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function secret(): string | null {
|
||||
const value = process.env.INTAKE_ACTION_SECRET;
|
||||
return value && value.trim() !== '' ? value : null;
|
||||
}
|
||||
|
||||
export function signAction(itemId: number, action: IntakeAction, expiresAt: number): string {
|
||||
const key = secret();
|
||||
if (!key) throw new Error('INTAKE_ACTION_SECRET is not set');
|
||||
return crypto
|
||||
.createHmac('sha256', key)
|
||||
.update(`${itemId}:${action}:${expiresAt}`)
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compared through a second digest rather than directly, because
|
||||
* timingSafeEqual throws when the two buffers differ in length — and a
|
||||
* malformed signature from a truncated link is an ordinary thing to receive
|
||||
* rather than an exception. Same idiom as middleware/adminGate.ts.
|
||||
*/
|
||||
function digest(value: string): Buffer {
|
||||
return crypto.createHash('sha256').update(value).digest();
|
||||
}
|
||||
|
||||
export function verifyAction(
|
||||
itemId: number,
|
||||
action: IntakeAction,
|
||||
expiresAt: number,
|
||||
signature: string,
|
||||
now: number = Date.now()
|
||||
): boolean {
|
||||
if (!secret()) return false;
|
||||
if (!Number.isFinite(expiresAt) || now > expiresAt) return false;
|
||||
|
||||
const expected = signAction(itemId, action, expiresAt);
|
||||
return crypto.timingSafeEqual(digest(expected), digest(signature));
|
||||
}
|
||||
|
||||
/**
|
||||
* The absolute link, or null when one cannot be made.
|
||||
*
|
||||
* Null rather than a throw or a relative path. An unconfigured environment
|
||||
* still sends the notification with its review link — being told an item
|
||||
* arrived matters far more than the shortcuts — and a link that could not be
|
||||
* verified must never be offered in the first place.
|
||||
*/
|
||||
export function actionUrl(itemId: number, action: IntakeAction): string | null {
|
||||
const base = process.env.PUBLIC_URL;
|
||||
if (!secret() || !base || base.trim() === '') return null;
|
||||
|
||||
const expiresAt = Date.now() + ACTION_TTL_MS;
|
||||
const sig = signAction(itemId, action, expiresAt);
|
||||
const origin = trimTrailingSlashes(base);
|
||||
return `${origin}/api/intake-actions/${itemId}/${action}?expires=${expiresAt}&sig=${sig}`;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
/**
|
||||
* The client, or null when there is no key.
|
||||
*
|
||||
* Null rather than a throw, because an unconfigured environment is a working
|
||||
* one: submissions still arrive and wait undrafted. The worker treats null
|
||||
* exactly as it treats a failed call, which keeps one path rather than two.
|
||||
*
|
||||
* Constructed once and cached. The SDK holds a connection pool, and building
|
||||
* one per submission would be wasteful on a route a stranger can trigger.
|
||||
*/
|
||||
let cached: Anthropic | null = null;
|
||||
let resolved = false;
|
||||
|
||||
/**
|
||||
* The headers a key needs beyond the key itself.
|
||||
*
|
||||
* An *identity-linked* key — one issued against a workspace rather than
|
||||
* standing alone — is refused without an `anthropic-workspace-id` naming the
|
||||
* workspace the request acts in:
|
||||
*
|
||||
* 400 invalid_request_error: anthropic-workspace-id is required when
|
||||
* authenticating with an identity-linked API key
|
||||
*
|
||||
* Nothing about a key's shape says which kind it is, so this cannot be detected
|
||||
* from configuration — only from a real call, which is what #223's task 8 was
|
||||
* for and what found it (#271).
|
||||
*
|
||||
* Sent only when set. Plenty of keys need no workspace, and sending an empty
|
||||
* header would turn the ordinary case into a different error.
|
||||
*/
|
||||
function workspaceHeaders(): Record<string, string> | undefined {
|
||||
const workspaceId = process.env.ANTHROPIC_WORKSPACE_ID;
|
||||
if (workspaceId === undefined || workspaceId.trim() === '') return undefined;
|
||||
return { 'anthropic-workspace-id': workspaceId.trim() };
|
||||
}
|
||||
|
||||
export function getAnthropicClient(): Anthropic | null {
|
||||
if (resolved) return cached;
|
||||
|
||||
const key = process.env.ANTHROPIC_API_KEY;
|
||||
cached =
|
||||
key !== undefined && key.trim() !== ''
|
||||
? new Anthropic({ apiKey: key, defaultHeaders: workspaceHeaders() })
|
||||
: null;
|
||||
resolved = true;
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Exposed for tests, which need a fresh decision per case. */
|
||||
export function resetAnthropicClient(): void {
|
||||
cached = null;
|
||||
resolved = false;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { PoolClient } from 'pg';
|
||||
import { DraftOutcome } from './draftListing';
|
||||
|
||||
/**
|
||||
* Writes a finished draft to the database.
|
||||
*
|
||||
* The copy goes on `item_drafts`, never on the item. The item keeps its
|
||||
* placeholder name and description until a person approves them in the review
|
||||
* queue (#225) — nothing a model wrote reaches the catalogue unreviewed.
|
||||
*
|
||||
* The price is the deliberate exception, because #220 chose to price an item on
|
||||
* arrival rather than leave it unpriced. `price_source` records that the number
|
||||
* came from a model rather than a person, which is what lets the review queue
|
||||
* show it as unconfirmed.
|
||||
*/
|
||||
export async function applyDraft(
|
||||
client: PoolClient,
|
||||
itemId: number,
|
||||
outcome: DraftOutcome
|
||||
): Promise<void> {
|
||||
const { draft } = outcome;
|
||||
|
||||
// Checked against the real table before it is stored. The schema constrains
|
||||
// the shape of the answer but cannot enforce membership, and a category the
|
||||
// shop does not have would be invisible to every storefront filter — a draft
|
||||
// nobody could find, rather than an obvious error.
|
||||
const categoryId = draft.category === null
|
||||
? null
|
||||
: (
|
||||
await client.query<{ id: number }>(
|
||||
`SELECT id FROM categories WHERE lower(name) = lower($1)`,
|
||||
[draft.category]
|
||||
)
|
||||
).rows[0]?.id ?? null;
|
||||
|
||||
const hasPrice = draft.suggestedPriceCents !== null;
|
||||
|
||||
await client.query(
|
||||
`UPDATE item_drafts
|
||||
SET state = 'ready',
|
||||
model = $2,
|
||||
ai_name = $3,
|
||||
ai_description = $4,
|
||||
ai_category_id = $5,
|
||||
ai_tag_names = $6,
|
||||
ai_suggested_price_cents = $7,
|
||||
price_source = $8,
|
||||
input_tokens = $9,
|
||||
output_tokens = $10,
|
||||
cost_micros = $11,
|
||||
ai_error = NULL,
|
||||
drafted_at = now()
|
||||
WHERE item_id = $1`,
|
||||
[
|
||||
itemId,
|
||||
outcome.model,
|
||||
draft.name,
|
||||
draft.description,
|
||||
categoryId,
|
||||
draft.tags,
|
||||
draft.suggestedPriceCents,
|
||||
hasPrice ? 'ai' : 'default',
|
||||
outcome.inputTokens,
|
||||
outcome.outputTokens,
|
||||
outcome.costMicros
|
||||
]
|
||||
);
|
||||
|
||||
// Only when there is one. Absent, the item keeps the migration's default and
|
||||
// price_source stays 'default' — the review queue shows both the same way, as
|
||||
// a number nobody has chosen yet.
|
||||
if (hasPrice) {
|
||||
await client.query(`UPDATE items SET price_cents = $2 WHERE id = $1`, [
|
||||
itemId,
|
||||
draft.suggestedPriceCents
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { pool } from '../db';
|
||||
import { typeForExtension } from '../uploadTypes';
|
||||
import { isRembgConfigured, removeBackground } from './rembgClient';
|
||||
|
||||
/**
|
||||
* Swapping a photo for a cut-out of itself, and swapping it back.
|
||||
*
|
||||
* One module rather than two, because the worker's path and the admin's path
|
||||
* must produce identical results: a cut-out obtained either way has to be
|
||||
* undoable the same way. A near-copy that drifted would mean a photo the
|
||||
* Restore button could not restore.
|
||||
*
|
||||
* Nothing here deletes anything. The original file always stays on disk,
|
||||
* because the submitter's photos are often the only copy of an item no longer
|
||||
* in their hands — the same rule Discard follows in the review queue. A
|
||||
* cut-out is not as durable: `cutoutPathFor` is deterministic, so a photo that
|
||||
* is restored and then cut out again writes over the previous cut-out at the
|
||||
* same path. That is harmless — no original is ever touched — but it means
|
||||
* "every cut-out ever made" is not actually true, so this comment used to
|
||||
* overstate it.
|
||||
*/
|
||||
|
||||
interface ImageRow {
|
||||
image_path: string;
|
||||
original_image_path: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when there is no original to put back.
|
||||
*
|
||||
* A named class rather than a bare Error because the route has to tell this
|
||||
* apart from a database that is not answering. The two need opposite replies:
|
||||
* this one means another admin has already restored the photo and the work is
|
||||
* done, which is a 404; anything else means the application is in trouble and
|
||||
* must stay loud rather than being reported as "already done".
|
||||
*/
|
||||
export class NoOriginalToRestoreError extends Error {}
|
||||
|
||||
/**
|
||||
* The path a cut-out of `imagePath` is written to.
|
||||
*
|
||||
* Pure, so the naming rule can be checked without a database or a sidecar.
|
||||
* Always `.png` because the result is transparent, and the storefront's dark
|
||||
* theme would show a flat white background as a bright box behind every
|
||||
* product.
|
||||
*/
|
||||
export function cutoutPathFor(imagePath: string): string {
|
||||
const base = path.basename(imagePath, path.extname(imagePath));
|
||||
return `/uploads/${base}-cutout.png`;
|
||||
}
|
||||
|
||||
function uploadsDir(): string {
|
||||
return process.env.UPLOADS_DIR ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces one image with a cut-out, keeping the original.
|
||||
*
|
||||
* Idempotent by way of the `original_image_path IS NOT NULL` check rather than
|
||||
* a separate flag. That guard is load-bearing twice over: it makes a repeat
|
||||
* call a no-op, and it stops a second pass from recording the *cut-out* as the
|
||||
* original and losing the real one for good.
|
||||
*
|
||||
* Throws on every failure. Nothing is written to the row unless the file is
|
||||
* already on disk, so a caller that catches and moves on leaves the photo
|
||||
* exactly as it was.
|
||||
*/
|
||||
export async function removeImageBackground(imageId: number): Promise<void> {
|
||||
const { rows } = await pool.query<ImageRow>(
|
||||
`SELECT image_path, original_image_path FROM item_images WHERE id = $1`,
|
||||
[imageId]
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
throw new Error(`no image ${imageId}`);
|
||||
}
|
||||
if (row.original_image_path !== null) {
|
||||
// Already cut out. Doing it again would overwrite the record of where the
|
||||
// real original went.
|
||||
return;
|
||||
}
|
||||
|
||||
// basename only: image_path is stored as '/uploads/<name>' and the directory
|
||||
// it lives in is a server constant. Same rule readPhotos follows in the
|
||||
// drafting worker.
|
||||
const sourceName = path.basename(row.image_path);
|
||||
const mediaType = typeForExtension(path.extname(sourceName));
|
||||
if (mediaType === null) {
|
||||
throw new Error(`cannot read ${sourceName}: unrecognised extension`);
|
||||
}
|
||||
|
||||
const cutout = await removeBackground(
|
||||
await fs.readFile(path.join(uploadsDir(), sourceName)),
|
||||
mediaType
|
||||
);
|
||||
|
||||
const cutoutPath = cutoutPathFor(row.image_path);
|
||||
await fs.writeFile(path.join(uploadsDir(), path.basename(cutoutPath)), cutout);
|
||||
|
||||
// The row is pointed at the new file only after the file exists. The other
|
||||
// order would leave a window in which the storefront rendered a broken image.
|
||||
//
|
||||
// `original_image_path = image_path` reads the pre-update value, which is how
|
||||
// Postgres evaluates an UPDATE's right-hand side — so this records where the
|
||||
// photo came from in the same statement that moves it.
|
||||
await pool.query(
|
||||
`UPDATE item_images
|
||||
SET image_path = $2, original_image_path = image_path
|
||||
WHERE id = $1 AND original_image_path IS NULL`,
|
||||
[imageId, cutoutPath]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the original back, and turns off the submitter's auto-removal intent
|
||||
* for the item this photo belongs to.
|
||||
*
|
||||
* The cut-out file is left on disk deliberately. Removing a background is
|
||||
* exactly the operation that produces an occasional bad result on an unusual
|
||||
* object, so somebody restoring one is quite likely to try again — and this
|
||||
* module deletes nothing in any case.
|
||||
*
|
||||
* `item_drafts.remove_background` is written once at intake and otherwise
|
||||
* never updated — without this, a restored photo looks identical to one that
|
||||
* was simply never cut out, and Regenerate reads the same stale `true` and
|
||||
* cuts it out again, quietly undoing the admin's decision. An admin restoring
|
||||
* *any* photo on an item has overridden the submitter's request for that
|
||||
* item: the flag is per-item while the swap is per-photo, so there is no
|
||||
* per-photo place to record "leave this one alone" separately. Turning the
|
||||
* whole item's auto-removal off is the conservative direction — the
|
||||
* alternative is a worker that re-cuts a photo a person deliberately undid,
|
||||
* which is the bug this fixes.
|
||||
*
|
||||
* Both writes happen in one transaction so a restore that succeeded while the
|
||||
* flag update failed cannot reintroduce the bug it exists to close.
|
||||
*/
|
||||
export async function restoreImageOriginal(imageId: number): Promise<void> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query<{ item_id: number }>(
|
||||
`UPDATE item_images
|
||||
SET image_path = original_image_path, original_image_path = NULL
|
||||
WHERE id = $1 AND original_image_path IS NOT NULL
|
||||
RETURNING item_id`,
|
||||
[imageId]
|
||||
);
|
||||
const restored = rows[0];
|
||||
if (!restored) {
|
||||
await client.query('ROLLBACK');
|
||||
throw new NoOriginalToRestoreError(`image ${imageId} has no original to restore`);
|
||||
}
|
||||
|
||||
await client.query(`UPDATE item_drafts SET remove_background = false WHERE item_id = $1`, [
|
||||
restored.item_id
|
||||
]);
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (err) {
|
||||
if (!(err instanceof NoOriginalToRestoreError)) {
|
||||
await client.query('ROLLBACK');
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a whole-item removal actually did.
|
||||
*
|
||||
* `void` was enough for the drafting worker, which catches and logs and would
|
||||
* not fail a draft over a background — but not for an admin standing in front
|
||||
* of the screen, who needs to know whether the thing they pressed happened.
|
||||
* Three of four is the normal shape of a bad day here, not an exception, and
|
||||
* the count is what decides whether pressing it again is worth anything.
|
||||
*/
|
||||
export interface RemovalSummary {
|
||||
/** How many images the item has. */
|
||||
total: number;
|
||||
/** How many now carry a cut-out, including any that already did. */
|
||||
removed: number;
|
||||
/** Whether it stopped early because one of them failed. */
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a whole-item restore did.
|
||||
*
|
||||
* Carries `failed` for the same reason `RemovalSummary` does. Restoring is a
|
||||
* database swap with no sidecar in it, so it fails far less often than
|
||||
* removing does — but "far less often" is not "never", and a database error
|
||||
* partway through a four-photo restore is exactly the moment an admin needs
|
||||
* the count rather than a bare 500. A photo that was never cut out is skipped
|
||||
* rather than being an error either way.
|
||||
*/
|
||||
export interface RestoreSummary {
|
||||
total: number;
|
||||
/** How many were put back. Photos that were never cut out are not counted. */
|
||||
restored: number;
|
||||
/** Whether it stopped early because one of them failed. */
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
/** Every photo of one item, in order. */
|
||||
async function imageIdsFor(itemId: number): Promise<number[]> {
|
||||
const { rows } = await pool.query<{ id: number }>(
|
||||
`SELECT id FROM item_images WHERE item_id = $1 ORDER BY sort_order`,
|
||||
[itemId]
|
||||
);
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuts out every photo of one item.
|
||||
*
|
||||
* Sequential rather than parallel: the sidecar is assumed to handle one request
|
||||
* at a time, and neither caller is in a hurry.
|
||||
*
|
||||
* Stops at the first failure rather than pushing on. Six attempts against a
|
||||
* sidecar that is not answering helps nobody, and stopping costs nothing
|
||||
* because `removeImageBackground` skips a photo that already has an original
|
||||
* recorded — so pressing the button again resumes where this stopped instead of
|
||||
* starting over. The summary is what makes that retry an informed choice rather
|
||||
* than a guess.
|
||||
*
|
||||
* An unconfigured environment is not a failure, here as everywhere else in this
|
||||
* feature: nothing was attempted, so nothing went wrong.
|
||||
*/
|
||||
export async function removeBackgroundsForItem(itemId: number): Promise<RemovalSummary> {
|
||||
const imageIds = await imageIdsFor(itemId);
|
||||
if (!isRembgConfigured()) {
|
||||
return { total: imageIds.length, removed: 0, failed: false };
|
||||
}
|
||||
|
||||
let removed = 0;
|
||||
for (const imageId of imageIds) {
|
||||
try {
|
||||
await removeImageBackground(imageId);
|
||||
removed += 1;
|
||||
} catch (err) {
|
||||
// Logged rather than thrown. The caller gets the count, which is the
|
||||
// thing it can act on; the reason belongs in the log, because the admin's
|
||||
// next move is the same whatever it was.
|
||||
console.error(`[background-removal] item ${itemId}, image ${imageId}:`, err);
|
||||
return { total: imageIds.length, removed, failed: true };
|
||||
}
|
||||
}
|
||||
return { total: imageIds.length, removed, failed: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts every cut-out photo of one item back.
|
||||
*
|
||||
* A photo that was never cut out is skipped rather than refused — the mixed
|
||||
* state a partial removal leaves behind has to be restorable too, and half an
|
||||
* item is exactly when somebody reaches for this.
|
||||
*
|
||||
* Stops at the first genuine failure and reports the count, the same shape
|
||||
* `removeBackgroundsForItem` uses and for the same reason: a caller standing
|
||||
* in front of the screen needs to know how far it got, and rethrowing here
|
||||
* would discard that in favour of a bare 500. `NoOriginalToRestoreError` is
|
||||
* not a genuine failure — it is skipped, as before — so it never reaches this
|
||||
* stop.
|
||||
*
|
||||
* The `failed` path has no integration test, deliberately. The only failure it
|
||||
* can report is a database fault, and the only way to inject one into a real
|
||||
* run is to interfere with the single pool every integration suite in the
|
||||
* `--runInBand` process shares — the same pool `afterAll` calls `pool.end()`
|
||||
* on. Tests that did exactly that left the suite reporting a failure against
|
||||
* its own `afterAll` and leaking a handle that stopped it exiting. Nor is the
|
||||
* fault reachable through data alone: the swap's `WHERE original_image_path IS
|
||||
* NOT NULL` guarantees the value it writes into the `NOT NULL` `image_path`,
|
||||
* and `item_images` carries no unique, check or foreign-key constraint on
|
||||
* either column, so no row can be seeded that makes the statement fail. The
|
||||
* branch is covered instead by `tests/unit/backgroundRemoval.test.ts`, which
|
||||
* stubs the database module in its own module registry and shares nothing.
|
||||
*/
|
||||
export async function restoreOriginalsForItem(itemId: number): Promise<RestoreSummary> {
|
||||
const imageIds = await imageIdsFor(itemId);
|
||||
|
||||
let restored = 0;
|
||||
for (const imageId of imageIds) {
|
||||
try {
|
||||
await restoreImageOriginal(imageId);
|
||||
restored += 1;
|
||||
} catch (err) {
|
||||
// Only "there was nothing to restore" is skipped. Anything else is a
|
||||
// real failure, logged for the same reason removeBackgroundsForItem
|
||||
// logs rather than throws: the caller gets the count, which is the
|
||||
// thing it can act on, and the reason belongs in the log.
|
||||
if (err instanceof NoOriginalToRestoreError) continue;
|
||||
console.error(`[background-removal] restoring item ${itemId}, image ${imageId}:`, err);
|
||||
return { total: imageIds.length, restored, failed: true };
|
||||
}
|
||||
}
|
||||
return { total: imageIds.length, restored, failed: false };
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { pool } from '../db';
|
||||
import { getSettings } from '../adminSettings';
|
||||
|
||||
/** A rolling day. */
|
||||
export const WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Where the counting window starts.
|
||||
*
|
||||
* The later of "24 hours ago" and an explicit reset, so a reset forgives what
|
||||
* came before it without deleting anything — those submissions are real and
|
||||
* their items are sitting in the review queue either way.
|
||||
*
|
||||
* A reset older than the window, unparseable, or in the future falls back to
|
||||
* the rolling day. The malformed case matters most: an Invalid Date compares
|
||||
* false against everything, so a typo in this setting would silently disable
|
||||
* the ceiling it was written to impose.
|
||||
*/
|
||||
export function windowStart(now: Date, resetAt: string): Date {
|
||||
const rolling = new Date(now.getTime() - WINDOW_MS);
|
||||
if (resetAt.trim() === '') return rolling;
|
||||
|
||||
const reset = new Date(resetAt);
|
||||
if (Number.isNaN(reset.getTime())) return rolling;
|
||||
if (reset > now) return rolling;
|
||||
|
||||
return reset > rolling ? reset : rolling;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counted from the draft rows themselves rather than from a tally.
|
||||
*
|
||||
* Every submission creates exactly one item_drafts row, in the same transaction
|
||||
* that creates the item, so the rows are the truth. A separate counter would be
|
||||
* a second thing that can disagree with them — and the one that disagrees
|
||||
* silently is always the counter.
|
||||
*/
|
||||
export async function countSince(start: Date): Promise<number> {
|
||||
const { rows } = await pool.query<{ count: string }>(
|
||||
`SELECT count(*)::text AS count FROM item_drafts WHERE created_at > $1`,
|
||||
[start]
|
||||
);
|
||||
return Number(rows[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
export async function countForLinkSince(linkId: number, start: Date): Promise<number> {
|
||||
const { rows } = await pool.query<{ count: string }>(
|
||||
`SELECT count(*)::text AS count FROM item_drafts WHERE upload_link_id = $1 AND created_at > $2`,
|
||||
[linkId, start]
|
||||
);
|
||||
return Number(rows[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
export interface CapacityVerdict {
|
||||
allowed: boolean;
|
||||
used: number;
|
||||
ceiling: number;
|
||||
start: Date;
|
||||
}
|
||||
|
||||
/** Whether the intake surface as a whole has room for one more. */
|
||||
export async function checkCapacity(now: Date = new Date()): Promise<CapacityVerdict> {
|
||||
const { intakeDailyCeiling, intakeCeilingResetAt } = await getSettings();
|
||||
const start = windowStart(now, intakeCeilingResetAt);
|
||||
const used = await countSince(start);
|
||||
|
||||
return { allowed: used < intakeDailyCeiling, used, ceiling: intakeDailyCeiling, start };
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod';
|
||||
import { getSettings } from '../adminSettings';
|
||||
import { DraftSchema, DraftResult } from './draftSchema';
|
||||
import { buildSystemPrompt, buildUserContent } from './draftPrompt';
|
||||
import { costMicros } from './models';
|
||||
|
||||
/**
|
||||
* Read from Admin settings rather than the environment, so the choice can be
|
||||
* changed without a redeploy. getSettings supplies the fallback, so there is no
|
||||
* second default here to disagree with the one in the catalogue.
|
||||
*/
|
||||
async function draftingModel(): Promise<string> {
|
||||
return (await getSettings()).draftingModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enough for a listing and its tags, and low enough that a model which starts
|
||||
* rambling is cut off rather than billed for indefinitely.
|
||||
*/
|
||||
const MAX_TOKENS = 2000;
|
||||
|
||||
export interface DraftInput {
|
||||
photos: { mediaType: string; base64: string }[];
|
||||
note: string | null;
|
||||
categories: string[];
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface DraftOutcome {
|
||||
draft: DraftResult;
|
||||
model: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
costMicros: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One submission, one draft.
|
||||
*
|
||||
* The client is a parameter rather than a module import so every test can pass
|
||||
* a stub. A test that reaches the real API is a defect in the test: this runs
|
||||
* on a public route and each call costs money.
|
||||
*/
|
||||
export async function draftListing(
|
||||
client: Anthropic,
|
||||
input: DraftInput
|
||||
): Promise<DraftOutcome> {
|
||||
const model = await draftingModel();
|
||||
|
||||
const response = await client.messages.parse({
|
||||
model,
|
||||
max_tokens: MAX_TOKENS,
|
||||
system: buildSystemPrompt(input.categories, input.tags),
|
||||
messages: [{ role: 'user', content: buildUserContent(input.photos, input.note) as never }],
|
||||
output_config: { format: zodOutputFormat(DraftSchema) }
|
||||
});
|
||||
|
||||
// Null when the response did not satisfy the schema. Guarded rather than
|
||||
// asserted: the SDK's own examples reach for it with `?.`, and a model
|
||||
// answering in prose is exactly the case worth failing cleanly on.
|
||||
const draft = response.parsed_output;
|
||||
if (!draft) {
|
||||
throw new Error('the model did not return a draft matching the expected shape');
|
||||
}
|
||||
|
||||
const inputTokens = response.usage?.input_tokens ?? 0;
|
||||
const outputTokens = response.usage?.output_tokens ?? 0;
|
||||
|
||||
return {
|
||||
draft,
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
costMicros: costMicros(model, inputTokens, outputTokens)
|
||||
};
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* What the model is told, and what it is shown.
|
||||
*
|
||||
* Pure and separately tested because this is where the correctness of every
|
||||
* draft is decided. Nothing downstream can distinguish an observed detail from
|
||||
* an invented one — the description arrives as prose either way — so the only
|
||||
* place that distinction can be enforced is here, in the instruction.
|
||||
*
|
||||
* On a one-of-a-kind item an invented "1930s hand-thrown stoneware" is a false
|
||||
* claim on a storefront, and it is the shop that answers for it rather than the
|
||||
* model. The submitter's note is the only trustworthy source for anything a
|
||||
* photograph cannot show.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Listed rather than described, so the model chooses from what exists instead
|
||||
* of inventing a taxonomy the storefront filters know nothing about.
|
||||
*/
|
||||
function offer(values: string[]): string {
|
||||
return values.length > 0 ? values.join(', ') : '(none defined yet)';
|
||||
}
|
||||
|
||||
export function buildSystemPrompt(categories: string[], tags: string[]): string {
|
||||
return [
|
||||
'You write short listings for a shop that sells one-of-a-kind second-hand items.',
|
||||
'',
|
||||
'You are given photographs of a single item, and sometimes a note from the person',
|
||||
'sending it in.',
|
||||
'',
|
||||
'Describe only what you can see in the photographs, plus whatever the note tells you.',
|
||||
'Do not state a material, age, maker, or provenance that is neither visible nor in the',
|
||||
'note. If you do not know something, leave it out rather than guessing — a wrong detail',
|
||||
'here becomes a false claim on a public shop, and the shop answers for it rather than you.',
|
||||
'Mention visible damage plainly; a buyer finding it later is worse than reading about it now.',
|
||||
'',
|
||||
`Choose a category from this list, or null if none fits: ${offer(categories)}`,
|
||||
`Choose tags from this list, or an empty list if none fit: ${offer(tags)}`,
|
||||
'Do not invent categories or tags that are not listed.',
|
||||
'',
|
||||
'Suggest a price in cents if the photographs and note give you enough to judge one,',
|
||||
'or null if they do not. A person reviews everything before it is listed.'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
interface Photo {
|
||||
mediaType: string;
|
||||
base64: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The photos, then the note.
|
||||
*
|
||||
* Images first because the note refers to them. The note is quoted and labelled
|
||||
* as coming from the sender rather than merged into the instruction: it is
|
||||
* untrusted text from an unauthenticated stranger, and it should read as
|
||||
* evidence to weigh rather than as something the shop is asserting.
|
||||
*/
|
||||
export function buildUserContent(photos: Photo[], note: string | null): unknown[] {
|
||||
const blocks: unknown[] = photos.map((photo) => ({
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: photo.mediaType, data: photo.base64 }
|
||||
}));
|
||||
|
||||
// Whitespace counts as absent. Otherwise an accidental space arrives looking
|
||||
// like something the sender meant to say.
|
||||
const hasNote = note !== null && note.trim() !== '';
|
||||
|
||||
blocks.push({
|
||||
type: 'text',
|
||||
text: hasNote
|
||||
? `The sender wrote this about the item:\n\n${note}`
|
||||
: 'The sender left no note, so the photographs are all you have.'
|
||||
});
|
||||
|
||||
return blocks;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* What the model must return, enforced rather than hoped for.
|
||||
*
|
||||
* Constraining the shape is the difference between a bad draft and a crash:
|
||||
* the SDK validates the response against this before any of it reaches the
|
||||
* database, so a model that answers in prose or invents a field becomes a
|
||||
* caught error rather than a row full of nonsense.
|
||||
*
|
||||
* Every field the model may decline to answer is nullable, because it is told
|
||||
* to say nothing rather than guess. A null category means it did not recognise
|
||||
* one, which is a better answer than a wrong one and is exactly what the review
|
||||
* queue exists to resolve.
|
||||
*/
|
||||
|
||||
/** Beyond anything this shop sells, so an absurd number is caught here. */
|
||||
const MAX_SUGGESTED_PRICE_CENTS = 1_000_000;
|
||||
|
||||
export const DraftSchema = z.object({
|
||||
/** A short title. Until this lands, the item is named for its submission. */
|
||||
name: z.string().min(1).max(200),
|
||||
/** The storefront body, rendered with html:false like every other stored body. */
|
||||
description: z.string().min(1).max(4000),
|
||||
/**
|
||||
* Chosen from the categories it was given, or null. The prompt supplies the
|
||||
* closed set; this cannot enforce membership, so applyDraft checks the answer
|
||||
* against the real table before writing anything.
|
||||
*/
|
||||
category: z.string().nullable(),
|
||||
/** Also from a supplied set, and also checked on the way in rather than here. */
|
||||
tags: z.array(z.string()),
|
||||
/**
|
||||
* Cents, or null when it will not guess. Integer and bounded at both ends,
|
||||
* because a fractional, negative or absurd figure reaching the review queue
|
||||
* is a number somebody has to notice is wrong — and being trustworthy at a
|
||||
* glance is that queue's whole job.
|
||||
*/
|
||||
suggestedPriceCents: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(MAX_SUGGESTED_PRICE_CENTS)
|
||||
.nullable()
|
||||
});
|
||||
|
||||
export type DraftResult = z.infer<typeof DraftSchema>;
|
||||
@@ -1,204 +0,0 @@
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { pool } from '../db';
|
||||
import { typeForExtension } from '../uploadTypes';
|
||||
import { getAnthropicClient } from './anthropicClient';
|
||||
import { draftListing } from './draftListing';
|
||||
import { applyDraft } from './applyDraft';
|
||||
import { notifyDraftReady } from './notifyDraft';
|
||||
import { removeBackgroundsForItem } from './backgroundRemoval';
|
||||
|
||||
/**
|
||||
* Turns queued submissions into drafts.
|
||||
*
|
||||
* Driven from two places: a call at the end of a successful submission, so a
|
||||
* draft is usually waiting by the time anybody looks, and a cron sweeper, so a
|
||||
* restart mid-draft is recoverable rather than a permanently stalled row.
|
||||
*
|
||||
* The governing rule is that a submission is the only irreplaceable thing here.
|
||||
* The photos are often the only copy of an item no longer in the sender's
|
||||
* hands, so every failure below leaves the row and its images intact and merely
|
||||
* undrafted. Nothing in this file deletes anything.
|
||||
*
|
||||
* Background removal (#281) follows drafting rather than running on its own
|
||||
* pass. That couples the two: an environment with no ANTHROPIC_API_KEY drafts
|
||||
* nothing, so it cuts out nothing either. That is the intended trade — a
|
||||
* separate pass would re-attempt an unreachable sidecar on every sweep for a
|
||||
* row that is going to sit at 'queued' indefinitely — and the admin's per-photo
|
||||
* control in the review queue is the way to do it by hand meanwhile.
|
||||
*/
|
||||
|
||||
/** Three tries, then it waits for a person rather than burning money on a loop. */
|
||||
export const MAX_ATTEMPTS = 3;
|
||||
|
||||
/** Small, because each one is an API call and the sweeper comes round again. */
|
||||
const DEFAULT_BATCH = 5;
|
||||
|
||||
type Photo = { mediaType: string; base64: string };
|
||||
|
||||
interface QueuedRow {
|
||||
item_id: number;
|
||||
submitter_note: string | null;
|
||||
remove_background: boolean;
|
||||
}
|
||||
|
||||
export interface SweepResult {
|
||||
drafted: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
async function readPhotos(itemId: number): Promise<Photo[]> {
|
||||
const { rows } = await pool.query<{ image_path: string }>(
|
||||
`SELECT image_path FROM item_images WHERE item_id = $1 ORDER BY sort_order`,
|
||||
[itemId]
|
||||
);
|
||||
|
||||
const photos: Photo[] = [];
|
||||
for (const row of rows) {
|
||||
// basename only: image_path is stored as '/uploads/<name>' and the
|
||||
// directory it lives in is a server constant. Same rule as handleRow in
|
||||
// backfillImageReencode, but using the shared typeForExtension rather than
|
||||
// that file's private copy of the map.
|
||||
const file = path.join(process.env.UPLOADS_DIR ?? '', path.basename(row.image_path));
|
||||
const mediaType = typeForExtension(path.extname(file));
|
||||
// Null for anything the app would refuse to serve. Sending it to the model
|
||||
// would be paying to have it rejected.
|
||||
if (mediaType === null) continue;
|
||||
photos.push({ mediaType, base64: (await fs.readFile(file)).toString('base64') });
|
||||
}
|
||||
return photos;
|
||||
}
|
||||
|
||||
async function namesOf(table: 'categories' | 'tags'): Promise<string[]> {
|
||||
// The one query in this codebase that cannot be parameterized, rather than one
|
||||
// that merely has not been. A bound parameter is a *value*: Postgres will not
|
||||
// accept `SELECT name FROM $1`, because an identifier has to be part of the
|
||||
// parsed statement. So the choice is interpolation or nothing.
|
||||
//
|
||||
// What makes it safe is the type. `table` is the closed union
|
||||
// 'categories' | 'tags', so the only two strings that can reach this line are
|
||||
// both written above it, and neither is derived from a request. See #294.
|
||||
const { rows } = await pool.query<{ name: string }>(`SELECT name FROM ${table} ORDER BY name`);
|
||||
return rows.map((row) => row.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a failure without ever losing the submission.
|
||||
*
|
||||
* The row stays reachable either way: 'queued' while tries remain, so the
|
||||
* sweeper picks it up again, and 'failed' once they are spent, so it stops
|
||||
* costing money and waits for a person. The item and its photos are untouched
|
||||
* in both cases.
|
||||
*/
|
||||
async function recordFailure(itemId: number, message: string): Promise<void> {
|
||||
await pool.query(
|
||||
`UPDATE item_drafts
|
||||
SET attempts = attempts + 1,
|
||||
ai_error = $2,
|
||||
state = CASE WHEN attempts + 1 >= $3 THEN 'failed' ELSE 'queued' END
|
||||
WHERE item_id = $1`,
|
||||
[itemId, message.slice(0, 500), MAX_ATTEMPTS]
|
||||
);
|
||||
}
|
||||
|
||||
/** Photos are passed in rather than re-read: the caller has already loaded them
|
||||
* to check there is at least one, and reading every file off disk twice per
|
||||
* submission is a cost for nothing. */
|
||||
async function draftOne(
|
||||
client: Anthropic,
|
||||
itemId: number,
|
||||
note: string | null,
|
||||
photos: Photo[]
|
||||
): Promise<void> {
|
||||
const outcome = await draftListing(client, {
|
||||
photos,
|
||||
note,
|
||||
categories: await namesOf('categories'),
|
||||
tags: await namesOf('tags')
|
||||
});
|
||||
|
||||
const db = await pool.connect();
|
||||
try {
|
||||
await db.query('BEGIN');
|
||||
await applyDraft(db, itemId, outcome);
|
||||
await db.query('COMMIT');
|
||||
} catch (err) {
|
||||
await db.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
db.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function draftQueued(limit = DEFAULT_BATCH): Promise<SweepResult> {
|
||||
const { rows } = await pool.query<QueuedRow>(
|
||||
`SELECT item_id, submitter_note, remove_background FROM item_drafts
|
||||
WHERE state = 'queued' AND attempts < $2
|
||||
ORDER BY created_at
|
||||
LIMIT $1`,
|
||||
[limit, MAX_ATTEMPTS]
|
||||
);
|
||||
|
||||
// Unconfigured is not a failure and must not spend an attempt. A fortnight
|
||||
// without a key would otherwise exhaust the retries and mark every waiting
|
||||
// submission failed, with nothing wrong with any of them.
|
||||
const client = getAnthropicClient();
|
||||
if (client === null) {
|
||||
return { drafted: 0, failed: 0, skipped: rows.length };
|
||||
}
|
||||
|
||||
let drafted = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const photos = await readPhotos(row.item_id);
|
||||
if (photos.length === 0) {
|
||||
throw new Error('no readable photos');
|
||||
}
|
||||
await draftOne(client, row.item_id, row.submitter_note, photos);
|
||||
drafted++;
|
||||
|
||||
// Deliberately after the draft is committed, and catching for itself.
|
||||
//
|
||||
// This is the sender's tick from the submission page, honoured here so
|
||||
// they never waited for it — and a failure must not mark a draft that was
|
||||
// written correctly as failed.
|
||||
//
|
||||
// removeBackgroundsForItem no longer rejects over a single photo failing
|
||||
// (#293 gave it a summary instead, for the admin screen that acts on the
|
||||
// count) — so this .catch now fires only if the image-listing query
|
||||
// itself throws, which is rare enough to warrant a log and nothing more.
|
||||
// A per-photo failure comes back as `failed: true` in the summary, which
|
||||
// this sweep discards; the photo keeps its original in that case, and the
|
||||
// admin's per-photo control in the review queue is still there to do it
|
||||
// by hand.
|
||||
//
|
||||
// Awaited, unlike the notification below, so a sweep that has returned
|
||||
// has finished its work. Nothing is waiting on this: the worker is off
|
||||
// the request path, which is the whole reason drafting lives here.
|
||||
if (row.remove_background) {
|
||||
await removeBackgroundsForItem(row.item_id).catch((err) =>
|
||||
console.error(`[drafting] background removal for item ${row.item_id}:`, err)
|
||||
);
|
||||
}
|
||||
|
||||
// Fire and forget, and deliberately after the draft is committed. A mail
|
||||
// failure must never mark a draft that was written correctly as failed —
|
||||
// the queue is what the admin actually works from, and the email is a
|
||||
// convenience on top of it.
|
||||
void notifyDraftReady(row.item_id).catch((err) =>
|
||||
console.error(`[drafting] notifying for item ${row.item_id}:`, err)
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[drafting] item ${row.item_id}: ${message}`);
|
||||
await recordFailure(row.item_id, message);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return { drafted, failed, skipped: 0 };
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* The models that may draft a listing, and what each one costs.
|
||||
*
|
||||
* One catalogue rather than two lists. The Admin settings dropdown needs the
|
||||
* models, `costMicros` needs their rates, and the price shown beside a model in
|
||||
* Admin has to be the price it is actually billed at — which it cannot be if
|
||||
* the list and the rates are maintained separately.
|
||||
*
|
||||
* Rates are dollars per million tokens, confirmed against the pricing page on
|
||||
* 2026-08-31 rather than recalled. Worth checking again when a model is added:
|
||||
* an increase to $3/$15 had been scheduled for 2026-09-01 and was cancelled,
|
||||
* with Sonnet's $2/$10 made permanent.
|
||||
*/
|
||||
export interface DraftingModel {
|
||||
id: string;
|
||||
/** Shown in the Admin dropdown. */
|
||||
label: string;
|
||||
/** Dollars per million input tokens. */
|
||||
inputRate: number;
|
||||
/** Dollars per million output tokens. */
|
||||
outputRate: number;
|
||||
}
|
||||
|
||||
export const DRAFTING_MODELS: readonly DraftingModel[] = [
|
||||
{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5', inputRate: 2, outputRate: 10 },
|
||||
{ id: 'claude-opus-5', label: 'Claude Opus 5', inputRate: 5, outputRate: 25 },
|
||||
{ id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', inputRate: 1, outputRate: 5 }
|
||||
];
|
||||
|
||||
/**
|
||||
* Sonnet, not Opus. The task is writing a description from a photograph rather
|
||||
* than reasoning, and this runs once per submission on a route a stranger with
|
||||
* a link can trigger. Opus costs two and a half times as much per item.
|
||||
*/
|
||||
export const DEFAULT_DRAFTING_MODEL = 'claude-sonnet-5';
|
||||
|
||||
export function isDraftingModel(id: string): boolean {
|
||||
return DRAFTING_MODELS.some((model) => model.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately not zero. An unrecognised model pricing at nothing would make a
|
||||
* budget read as unspent however much was really spent, which is the one
|
||||
* failure a spend guard must not have. Set to the most expensive rate here, so
|
||||
* an unknown model errs towards over- rather than under-reporting.
|
||||
*/
|
||||
const FALLBACK_RATE = { inputRate: 5, outputRate: 25 };
|
||||
|
||||
/**
|
||||
* Whole micros, so a cost never carries a floating-point fraction into the
|
||||
* database. Rates are per million tokens and a micro is a millionth of a
|
||||
* dollar, so the two cancel and the arithmetic is just tokens times rate.
|
||||
*/
|
||||
export function costMicros(model: string, inputTokens: number, outputTokens: number): number {
|
||||
const rate = DRAFTING_MODELS.find((m) => m.id === model) ?? FALLBACK_RATE;
|
||||
return Math.round(inputTokens * rate.inputRate + outputTokens * rate.outputRate);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { pool } from '../db';
|
||||
import { sendMail } from '../mailer';
|
||||
import { renderTemplate } from '../emailTemplates';
|
||||
import { loadStoredTemplate } from '../routes/adminEmailTemplates';
|
||||
import { getSettings } from '../adminSettings';
|
||||
import { trimTrailingSlashes } from '../utils';
|
||||
import { actionUrl } from './actionLinks';
|
||||
|
||||
interface NotifyRow {
|
||||
item_name: string;
|
||||
price_cents: number;
|
||||
ai_name: string | null;
|
||||
ai_description: string | null;
|
||||
submitter_note: string | null;
|
||||
link_label: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the admin an item arrived and has been drafted.
|
||||
*
|
||||
* Everything here is best-effort by design. The review queue is the source of
|
||||
* truth: a ready draft is visible and actionable whether or not this ever sent,
|
||||
* so a missing recipient, an SMTP outage, or a template that will not render
|
||||
* must all end in a log line rather than an exception reaching the worker and
|
||||
* marking a perfectly good draft as failed.
|
||||
*/
|
||||
export async function notifyDraftReady(itemId: number): Promise<void> {
|
||||
const { intakeNotifyEmail } = await getSettings();
|
||||
const to = intakeNotifyEmail?.trim();
|
||||
if (!to) {
|
||||
// Not an error, and deliberately not a warning either. Nobody has said
|
||||
// where to send it, and the draft is waiting in the queue regardless.
|
||||
return;
|
||||
}
|
||||
|
||||
const { rows } = await pool.query<NotifyRow>(
|
||||
`SELECT i.name AS item_name, i.price_cents,
|
||||
d.ai_name, d.ai_description, d.submitter_note,
|
||||
l.label AS link_label
|
||||
FROM item_drafts d
|
||||
JOIN items i ON i.id = d.item_id
|
||||
LEFT JOIN upload_links l ON l.id = d.upload_link_id
|
||||
WHERE d.item_id = $1`,
|
||||
[itemId]
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return;
|
||||
|
||||
const base = trimTrailingSlashes(process.env.PUBLIC_URL ?? '');
|
||||
|
||||
const template = renderTemplate('intakeDraft', await loadStoredTemplate('intakeDraft'), {
|
||||
itemName: row.item_name,
|
||||
draftName: row.ai_name ?? row.item_name,
|
||||
// Said plainly rather than left blank. An empty description in a
|
||||
// notification reads as a bug; "no description was drafted" reads as the
|
||||
// fact that it is, and tells the admin what to expect on the screen.
|
||||
draftDescription: row.ai_description ?? 'No description was drafted for this item.',
|
||||
price: `$${(row.price_cents / 100).toFixed(2)}`,
|
||||
submitterNote: row.submitter_note ?? 'The sender left no note.',
|
||||
linkLabel: row.link_label ?? 'an upload link',
|
||||
reviewUrl: `${base}/admin`,
|
||||
// Empty rather than a broken link when there is no secret to sign with.
|
||||
// The body renders without them; a link that could not be verified would
|
||||
// be worse than none.
|
||||
regenerateUrl: actionUrl(itemId, 'regenerate') ?? '',
|
||||
discardUrl: actionUrl(itemId, 'discard') ?? ''
|
||||
});
|
||||
|
||||
await sendMail(to, template.subject, template.html);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Where an item's price came from, and when that changes.
|
||||
*
|
||||
* This is the protection that used to live in the schema. Items are priced on
|
||||
* arrival — the model's suggestion, or the 80.00 default — so nothing stops a
|
||||
* number nobody chose from reaching the storefront except the review queue
|
||||
* showing that it was never chosen.
|
||||
*
|
||||
* Pure and separately tested because the failure is silent. An item that sells
|
||||
* at a default price looks exactly like one that sells at a chosen price;
|
||||
* 80.00 was picked precisely because it reads as a decision rather than as an
|
||||
* obvious sentinel the way 0.00 would.
|
||||
*/
|
||||
export type PriceSource = 'default' | 'ai' | 'admin';
|
||||
|
||||
/**
|
||||
* Editing the number is the admin taking responsibility for it, and it is the
|
||||
* only thing that can. Publishing without touching the field deliberately does
|
||||
* NOT confirm it — that would turn "I did not look at this" into "I approved
|
||||
* this", which is the exact misrecording the review queue exists to prevent.
|
||||
*/
|
||||
export function nextPriceSource(
|
||||
current: PriceSource,
|
||||
submittedCents: number,
|
||||
storedCents: number
|
||||
): PriceSource {
|
||||
if (current === 'admin') return 'admin';
|
||||
return submittedCents === storedCents ? current : 'admin';
|
||||
}
|
||||
|
||||
/** Anything a person did not choose, which the screen marks visibly. */
|
||||
export function isUnconfirmed(source: PriceSource): boolean {
|
||||
return source !== 'admin';
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { trimTrailingSlashes } from '../utils';
|
||||
import { SIGNATURE_BYTES, signatureMatches } from '../uploadTypes';
|
||||
|
||||
/**
|
||||
* The one place that talks to the background-removal sidecar.
|
||||
*
|
||||
* A sidecar rather than in-process inference: the application runs in a
|
||||
* container, and putting Python and ONNX into the image would add roughly
|
||||
* 300 MB to one already over a gigabyte. See
|
||||
* docs/ops/image-background-removal-stack.md for the measurements.
|
||||
*/
|
||||
|
||||
/**
|
||||
* NEVER remove this, and never make it configurable.
|
||||
*
|
||||
* The sidecar's default model is `bria-rmbg`, and BRIA's RMBG models are
|
||||
* licensed for NON-COMMERCIAL use. This is a shop. The default is reached by
|
||||
* simply not naming a model, so it is a licensing problem that happens
|
||||
* silently and produces a perfectly good image — there is nothing in the
|
||||
* output that could reveal it.
|
||||
*
|
||||
* `u2net` is Apache-2.0, and also ten times faster (1.1–2.3 s against
|
||||
* 14–20 s) at a sixth the size, so nothing is being traded away for it.
|
||||
*/
|
||||
const MODEL = 'u2net';
|
||||
|
||||
/**
|
||||
* Generous on purpose. The sidecar takes about 40 seconds to answer after a
|
||||
* container start and its first call per model downloads 168 MB, so a tight
|
||||
* timeout would turn an ordinary cold start into a failure. Nobody is waiting
|
||||
* on this in the worker's path, and an admin who clicked a button would rather
|
||||
* wait than be told it did not work.
|
||||
*/
|
||||
const TIMEOUT_MS = 120_000;
|
||||
|
||||
/**
|
||||
* Thrown only when the sidecar was actually contacted and did not answer
|
||||
* usably — unreachable, timed out, answered with a non-2xx status, or
|
||||
* answered with something that is not a PNG.
|
||||
*
|
||||
* Deliberately not thrown for "REMBG_URL is not set": that failure happens
|
||||
* before any attempt to contact anything, so lumping it in here would tell a
|
||||
* caller "the service did not answer" about a service nothing ever tried to
|
||||
* reach. A caller distinguishes the two to avoid exactly that (#281 review).
|
||||
*/
|
||||
export class SidecarRequestError extends Error {}
|
||||
|
||||
/** The configured base URL, or null when there is none. */
|
||||
function baseUrl(): string | null {
|
||||
const raw = process.env.REMBG_URL;
|
||||
if (raw === undefined || raw.trim() === '') return null;
|
||||
return trimTrailingSlashes(raw.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the feature exists in this environment.
|
||||
*
|
||||
* Unconfigured is not a failure. It means the submitter sees no checkbox, the
|
||||
* admin sees no control and the worker skips the step — an unconfigured
|
||||
* environment must be a working one, which is the same rule
|
||||
* `getAnthropicClient` follows by returning null rather than throwing.
|
||||
*/
|
||||
export function isRembgConfigured(): boolean {
|
||||
return baseUrl() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cut-out, as PNG bytes.
|
||||
*
|
||||
* Rejects on every failure — unconfigured, unreachable, a non-2xx answer, or a
|
||||
* body that is not actually a PNG. Every caller catches, and none of them lets
|
||||
* the rejection reach a submission or a draft.
|
||||
*/
|
||||
export async function removeBackground(bytes: Buffer, mediaType: string): Promise<Buffer> {
|
||||
const base = baseUrl();
|
||||
if (base === null) {
|
||||
throw new Error('REMBG_URL is not set');
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
// A copy through Uint8Array because Buffer is not a BlobPart. The filename is
|
||||
// a constant: the sidecar does not use it, and passing the stored name would
|
||||
// put a value from the uploads volume into an outbound request for nothing.
|
||||
body.append('file', new Blob([new Uint8Array(bytes)], { type: mediaType }), 'photo');
|
||||
body.append('model', MODEL);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${base}/api/remove`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS)
|
||||
});
|
||||
} catch (err) {
|
||||
// Unreachable, refused, or timed out — fetch throws for all three rather
|
||||
// than returning a response, so this is the only place that can catch
|
||||
// them and mark them as a sidecar failure rather than a generic error.
|
||||
throw new SidecarRequestError(
|
||||
`rembg did not answer: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new SidecarRequestError(`rembg answered ${res.status}`);
|
||||
}
|
||||
|
||||
const out = Buffer.from(await res.arrayBuffer());
|
||||
|
||||
// The bytes, not the Content-Type header. A proxy error page served as
|
||||
// image/png would otherwise be written over a photograph — the same reason
|
||||
// uploads are checked by signature rather than by what the caller declared.
|
||||
if (!signatureMatches('image/png', out.subarray(0, SIGNATURE_BYTES))) {
|
||||
throw new SidecarRequestError('rembg response is not a PNG');
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
+80
-211
@@ -2,64 +2,31 @@
|
||||
// filters. Kept apart from the route so the rules can be unit-tested without a
|
||||
// database, and so items.ts stays a thin handler.
|
||||
|
||||
import { Expression, SqlBool, sql } from 'kysely';
|
||||
import { ItemStatus } from './types';
|
||||
import { ItemContext } from './itemSelect';
|
||||
export type { ItemStatus };
|
||||
|
||||
export class FilterError extends Error {}
|
||||
|
||||
export interface ItemFilters {
|
||||
// Several categories, combined as OR — a customer picking Furniture and
|
||||
// Decor wants both, not the empty intersection. Deliberately the opposite of
|
||||
// how tagIds combine below, which is AND; the two controls say so in the UI
|
||||
// rather than leaving it to be discovered.
|
||||
//
|
||||
// Each selected id still expands to its descendants, so choosing two parents
|
||||
// means "anything filed under either of them".
|
||||
categoryIds: number[];
|
||||
categoryId: number | null;
|
||||
tagIds: number[];
|
||||
minPriceCents: number | null;
|
||||
maxPriceCents: number | null;
|
||||
// Several statuses rather than one, because the control this exists to serve
|
||||
// is not a status filter. "Not sold" is available-or-reserved on the
|
||||
// storefront and available-or-reserved-or-pending in the admin, so it cannot
|
||||
// be expressed as equality against a single value. A single-status filter is
|
||||
// still expressible: it arrives as a list of one, which is how the admin's
|
||||
// old `?status=sold` keeps working unchanged.
|
||||
//
|
||||
// Null means the caller expressed no preference, which is distinct from
|
||||
// asking for every status — the storefront turns the first into its default
|
||||
// and the second into an explicit list.
|
||||
status: ItemStatus[] | null;
|
||||
status: ItemStatus | null;
|
||||
// Storefront only: "just the items I have favorited". Which customer that
|
||||
// means is not part of the parsed filter — it comes from the session at build
|
||||
// time, so a query string can never name someone else's favorites.
|
||||
favoritesOnly: boolean;
|
||||
}
|
||||
|
||||
export type ItemStatus = 'available' | 'reserved' | 'sold';
|
||||
|
||||
// Matched exactly, not case-insensitively: `items.status` only ever holds these
|
||||
// lowercase values, so accepting 'Reserved' would quietly return nothing rather
|
||||
// than reporting that the filter was wrong.
|
||||
const ITEM_STATUSES: readonly string[] = ['pending', 'available', 'reserved', 'sold'];
|
||||
const ITEM_STATUSES: readonly string[] = ['available', 'reserved', 'sold'];
|
||||
|
||||
// Storefront-invalid statuses. This parser is shared with the admin routes,
|
||||
// where filtering by 'pending' is exactly the point, so the public routes have
|
||||
// to refuse it themselves rather than the parser refusing it for everyone.
|
||||
export const NON_PUBLIC_STATUSES: readonly ItemStatus[] = ['pending'];
|
||||
|
||||
// What the storefront lists when the caller expressed no preference. Named here
|
||||
// rather than implied by the absence of a parameter, because the absence is now
|
||||
// meaningful: before this change no status meant every status, and afterwards it
|
||||
// means these two. Anything reading a shared link from before will get the new
|
||||
// meaning, which is the accepted cost of the default changing.
|
||||
export const STOREFRONT_DEFAULT_STATUSES: readonly ItemStatus[] = ['available', 'reserved'];
|
||||
|
||||
// What "All" can mean on the storefront, which is not all of them. Pending items
|
||||
// are excluded from every public read unconditionally, so a filter labelled All
|
||||
// must not promise the fourth — a label that delivers less than it says is the
|
||||
// shape this codebase keeps designing against.
|
||||
export const STOREFRONT_ALL_STATUSES: readonly ItemStatus[] = ['available', 'reserved', 'sold'];
|
||||
export interface BuiltFilter {
|
||||
clauses: string[];
|
||||
params: unknown[];
|
||||
}
|
||||
|
||||
// Deliberately excludes a leading sign and any decimal point: every filter
|
||||
// value is a non-negative integer (an id, or a price in cents), so '-1' and
|
||||
@@ -114,112 +81,26 @@ function parsePrice(value: unknown, name: string): number | null {
|
||||
return parseNonNegativeInteger(raw, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Comma-separated, the same shape `tags` and `status` already use.
|
||||
*
|
||||
* The parameter keeps its singular name so that every `?category=1` link,
|
||||
* bookmark and shared URL written before this went multi-valued still parses —
|
||||
* as a list of one, needing no alias and leaving no way to give the filter
|
||||
* twice with different meanings.
|
||||
*/
|
||||
function parseCategoryIds(value: unknown): number[] {
|
||||
const raw = singleValue(value, 'category');
|
||||
const categoryIds: number[] = [];
|
||||
if (!raw) {
|
||||
return categoryIds;
|
||||
}
|
||||
for (const part of raw.split(',')) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed === '') {
|
||||
continue;
|
||||
}
|
||||
const id = parseId(trimmed, 'category');
|
||||
// Duplicates are harmless to the OR below, but they would show twice in
|
||||
// any caller that renders the parsed filter back.
|
||||
if (!categoryIds.includes(id)) {
|
||||
categoryIds.push(id);
|
||||
}
|
||||
}
|
||||
return categoryIds;
|
||||
}
|
||||
|
||||
function parseTagIds(value: unknown): number[] {
|
||||
const raw = singleValue(value, 'tags');
|
||||
const tagIds: number[] = [];
|
||||
if (!raw) {
|
||||
return tagIds;
|
||||
}
|
||||
for (const part of raw.split(',')) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed === '') {
|
||||
continue;
|
||||
}
|
||||
const id = parseId(trimmed, 'tags');
|
||||
// Duplicates would inflate the required-match count in itemFilterExpressions
|
||||
// and make the filter match nothing at all.
|
||||
if (!tagIds.includes(id)) {
|
||||
tagIds.push(id);
|
||||
}
|
||||
}
|
||||
return tagIds;
|
||||
}
|
||||
|
||||
// Comma-separated, matching how `tags` already works, so the two multi-value
|
||||
// parameters in this parser read the same way in a URL.
|
||||
//
|
||||
// An unrecognised name is refused rather than dropped. Silently ignoring one
|
||||
// would turn `?status=available,sold_out` into "available only" — narrower than
|
||||
// what was asked for, and indistinguishable from a filter that worked.
|
||||
function parseStatus(value: unknown): ItemStatus[] | null {
|
||||
const raw = singleValue(value, 'status');
|
||||
if (raw === null || raw === '') {
|
||||
return null;
|
||||
}
|
||||
const statuses: ItemStatus[] = [];
|
||||
for (const part of raw.split(',')) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed === '') {
|
||||
continue;
|
||||
}
|
||||
if (!ITEM_STATUSES.includes(trimmed)) {
|
||||
throw new FilterError('invalid status');
|
||||
}
|
||||
// Duplicates are harmless in `= ANY(...)`, but removing them keeps the
|
||||
// parsed filter a faithful description of what was asked for.
|
||||
if (!statuses.includes(trimmed as ItemStatus)) {
|
||||
statuses.push(trimmed as ItemStatus);
|
||||
}
|
||||
}
|
||||
// `?status=,,` asked for something and named nothing. Returning null would
|
||||
// silently mean "no status filter", which on the storefront now means the
|
||||
// default rather than everything — a different answer from the one requested.
|
||||
if (statuses.length === 0) {
|
||||
throw new FilterError('invalid status');
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
function parseFavoritesOnly(value: unknown): boolean {
|
||||
const raw = singleValue(value, 'favorites');
|
||||
if (raw === null || raw === '') {
|
||||
return false;
|
||||
}
|
||||
if (TRUE_VALUES.includes(raw)) {
|
||||
return true;
|
||||
}
|
||||
if (FALSE_VALUES.includes(raw)) {
|
||||
return false;
|
||||
}
|
||||
throw new FilterError('invalid favorites');
|
||||
}
|
||||
|
||||
// The per-field parsing lives in the helpers above; what stays here is the
|
||||
// order they run in and the one rule that spans two fields. Order is
|
||||
// deliberate and observable: a query wrong in two ways reports the first
|
||||
// field, so moving these lines around changes which error a caller sees.
|
||||
export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
|
||||
const categoryIds = parseCategoryIds(query.category);
|
||||
const tagIds = parseTagIds(query.tags);
|
||||
const categoryRaw = singleValue(query.category, 'category');
|
||||
const categoryId = categoryRaw === null || categoryRaw === '' ? null : parseId(categoryRaw, 'category');
|
||||
|
||||
const tagsRaw = singleValue(query.tags, 'tags');
|
||||
const tagIds: number[] = [];
|
||||
if (tagsRaw) {
|
||||
for (const part of tagsRaw.split(',')) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed === '') {
|
||||
continue;
|
||||
}
|
||||
const id = parseId(trimmed, 'tags');
|
||||
// Duplicates would inflate the required-match count below and make the
|
||||
// filter match nothing at all.
|
||||
if (!tagIds.includes(id)) {
|
||||
tagIds.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const minPriceCents = parsePrice(query.min_price, 'min_price');
|
||||
const maxPriceCents = parsePrice(query.max_price, 'max_price');
|
||||
@@ -227,27 +108,30 @@ export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
|
||||
throw new FilterError('min_price may not exceed max_price');
|
||||
}
|
||||
|
||||
const status = parseStatus(query.status);
|
||||
const favoritesOnly = parseFavoritesOnly(query.favorites);
|
||||
const statusRaw = singleValue(query.status, 'status');
|
||||
let status: ItemStatus | null = null;
|
||||
if (statusRaw !== null && statusRaw !== '') {
|
||||
if (!ITEM_STATUSES.includes(statusRaw)) {
|
||||
throw new FilterError('invalid status');
|
||||
}
|
||||
status = statusRaw as ItemStatus;
|
||||
}
|
||||
|
||||
return { categoryIds, tagIds, minPriceCents, maxPriceCents, status, favoritesOnly };
|
||||
const favoritesRaw = singleValue(query.favorites, 'favorites');
|
||||
let favoritesOnly = false;
|
||||
if (favoritesRaw !== null && favoritesRaw !== '') {
|
||||
if (TRUE_VALUES.includes(favoritesRaw)) {
|
||||
favoritesOnly = true;
|
||||
} else if (!FALSE_VALUES.includes(favoritesRaw)) {
|
||||
throw new FilterError('invalid favorites');
|
||||
}
|
||||
}
|
||||
|
||||
return { categoryId, tagIds, minPriceCents, maxPriceCents, status, favoritesOnly };
|
||||
}
|
||||
|
||||
// Composes the filter clauses as Kysely expressions.
|
||||
//
|
||||
// This returned `{ clauses: string[]; params: unknown[] }` until #308, and both
|
||||
// callers spliced the clauses straight into query text. The invariant that made
|
||||
// that safe — only a placeholder index may ever be interpolated into a clause,
|
||||
// never a value — was a sixteen-line comment and two tests standing between an
|
||||
// edit and a live injection on a route reachable without signing in.
|
||||
//
|
||||
// It is now a property of the type system. `${value}` inside a Kysely `sql`
|
||||
// template emits a bind parameter, never text, and the builder expressions
|
||||
// cannot express interpolation at all. The two tests at the bottom of
|
||||
// itemFilters.test.ts still exist and now assert against the SQL Kysely
|
||||
// actually emits, which is a stronger claim than the one they used to make.
|
||||
//
|
||||
// `startIndex` is gone with the splicing it existed for.
|
||||
// Returns WHERE fragments plus their parameters, with placeholders numbered
|
||||
// from `startIndex` so the caller can splice these in after its own params.
|
||||
//
|
||||
// `favoritesCustomerId` is required rather than optional so a caller has to say
|
||||
// whose favorites it means, even when it means nobody's. Both routes already
|
||||
@@ -255,88 +139,73 @@ export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
|
||||
// a programming error — but it is here so that a future caller which forgets
|
||||
// the guard fails loudly instead of quietly ignoring the filter and listing the
|
||||
// whole catalogue.
|
||||
export function itemFilterExpressions(
|
||||
eb: ItemContext,
|
||||
export function buildItemFilterSql(
|
||||
filters: ItemFilters,
|
||||
startIndex: number,
|
||||
favoritesCustomerId: number | null
|
||||
): Expression<SqlBool>[] {
|
||||
const clauses: Expression<SqlBool>[] = [];
|
||||
): BuiltFilter {
|
||||
const clauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let next = startIndex;
|
||||
|
||||
if (filters.categoryIds.length) {
|
||||
if (filters.categoryId !== null) {
|
||||
params.push(filters.categoryId);
|
||||
// Selecting a category means "and everything filed beneath it", so walk the
|
||||
// tree down from each chosen node. A recursive CTE keeps the tree
|
||||
// tree down from the chosen node. A recursive CTE keeps the tree
|
||||
// un-denormalized: reparenting stays a single UPDATE with no stored paths
|
||||
// to rewrite.
|
||||
//
|
||||
// Seeded with `= ANY(...)` rather than one id, so every selected root is
|
||||
// walked in the same recursion. That also gives the OR for free: the union
|
||||
// of the subtrees is exactly "filed under any of these", and an item filed
|
||||
// under two selected branches appears once because IN is a set test.
|
||||
//
|
||||
// Still a `sql` template, because the builder expresses a recursive CTE no
|
||||
// better than this does. `${filters.categoryIds}` is one bind parameter
|
||||
// holding the whole array — not a placeholder list — which is why no
|
||||
// sql.param() ceremony appears here. See src/db-kysely/CONVENTIONS.md.
|
||||
clauses.push(sql<SqlBool>`i.category_id IN (
|
||||
clauses.push(`i.category_id IN (
|
||||
WITH RECURSIVE subtree AS (
|
||||
SELECT id FROM categories WHERE id = ANY(${filters.categoryIds}::int[])
|
||||
SELECT id FROM categories WHERE id = $${next}
|
||||
UNION ALL
|
||||
SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id
|
||||
)
|
||||
SELECT id FROM subtree
|
||||
)`);
|
||||
next++;
|
||||
}
|
||||
|
||||
if (filters.tagIds.length) {
|
||||
params.push(filters.tagIds, filters.tagIds.length);
|
||||
// AND, not OR: the item must carry every selected tag. Matching with
|
||||
// `tag_id = ANY(...)` alone would return items holding just one of them, so
|
||||
// the count of matched rows has to equal the number requested.
|
||||
clauses.push(sql<SqlBool>`(SELECT COUNT(*) FROM item_tags it
|
||||
WHERE it.item_id = i.id AND it.tag_id = ANY(${filters.tagIds}::int[])) = ${filters.tagIds.length}`);
|
||||
clauses.push(
|
||||
`(SELECT COUNT(*) FROM item_tags it
|
||||
WHERE it.item_id = i.id AND it.tag_id = ANY($${next}::int[])) = $${next + 1}`
|
||||
);
|
||||
next += 2;
|
||||
}
|
||||
|
||||
if (filters.minPriceCents !== null) {
|
||||
clauses.push(eb('i.price_cents', '>=', filters.minPriceCents));
|
||||
params.push(filters.minPriceCents);
|
||||
clauses.push(`i.price_cents >= $${next}`);
|
||||
next++;
|
||||
}
|
||||
|
||||
if (filters.maxPriceCents !== null) {
|
||||
clauses.push(eb('i.price_cents', '<=', filters.maxPriceCents));
|
||||
params.push(filters.maxPriceCents);
|
||||
clauses.push(`i.price_cents <= $${next}`);
|
||||
next++;
|
||||
}
|
||||
|
||||
if (filters.status !== null) {
|
||||
// `in` replaces the `= ANY($n::text[])` this used to build. Kysely emits
|
||||
// the placeholder list itself, so one status and several use the same
|
||||
// expression and the explicit ::text[] cast is no longer needed.
|
||||
//
|
||||
// The empty list is spelled out rather than left to `in`, which would emit
|
||||
// `in ()` — a Postgres syntax error, where `= ANY` on an empty array was
|
||||
// valid and matched nothing. Unreachable through parseItemFilters, which
|
||||
// refuses a list that names nothing, but the obvious alternative is wrong
|
||||
// in the opposite direction: dropping the clause entirely would make an
|
||||
// empty status filter match *every* status, where the behaviour this
|
||||
// replaced matched none. See #307.
|
||||
clauses.push(
|
||||
filters.status.length ? eb('i.status', 'in', filters.status) : sql<SqlBool>`false`
|
||||
);
|
||||
params.push(filters.status);
|
||||
clauses.push(`i.status = $${next}`);
|
||||
next++;
|
||||
}
|
||||
|
||||
if (filters.favoritesOnly) {
|
||||
if (favoritesCustomerId === null) {
|
||||
throw new Error('favorites filter requires a customer id');
|
||||
}
|
||||
params.push(favoritesCustomerId);
|
||||
// EXISTS rather than a join: an item is favorited by a customer at most
|
||||
// once, but joining would still risk multiplying rows if that ever changed,
|
||||
// and this reads as the membership test it is.
|
||||
clauses.push(
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('favorites as f')
|
||||
.select('f.item_id')
|
||||
.whereRef('f.item_id', '=', 'i.id')
|
||||
.where('f.customer_id', '=', favoritesCustomerId)
|
||||
)
|
||||
);
|
||||
clauses.push(`EXISTS (SELECT 1 FROM favorites f WHERE f.item_id = i.id AND f.customer_id = $${next})`);
|
||||
next++;
|
||||
}
|
||||
|
||||
return clauses;
|
||||
return { clauses, params };
|
||||
}
|
||||
|
||||
+34
-190
@@ -1,199 +1,43 @@
|
||||
// Shared item query shapes for the public and admin routes, and the row types
|
||||
// they return.
|
||||
// Shared item SELECT shapes for the public and admin routes.
|
||||
//
|
||||
// The types live here rather than in types.ts because they describe a
|
||||
// projection, not a table. adminItemQuery takes every column of items and
|
||||
// publicItemQuery names its columns, so the storefront never sees
|
||||
// paypal_order_id or reserved_until — typing both as "an items row" would
|
||||
// quietly re-admit exactly the columns that projection was written to exclude.
|
||||
//
|
||||
// These were SQL string constants until #308. They had to be kept in step with
|
||||
// their row types by hand, because `pool.query<T>` asserts a shape and never
|
||||
// checks it against the SQL, so dropping a column from a select without
|
||||
// dropping it from its type compiled cleanly and went undefined at run time —
|
||||
// and only the integration suite ever caught it. Built through Kysely, that is
|
||||
// a compile error, because the row type now follows from the projection.
|
||||
//
|
||||
// Images and tags are pulled as aggregate subqueries rather than LEFT JOIN +
|
||||
// Images and tags are pulled as scalar subqueries rather than LEFT JOIN +
|
||||
// GROUP BY. Joining two one-to-many relations in the same query multiplies
|
||||
// their rows together — an item with 2 images and 3 tags would aggregate 6
|
||||
// rows, silently repeating every image three times. Subqueries keep each
|
||||
// aggregate independent and drop the GROUP BY entirely. `jsonArrayFrom` emits
|
||||
// `coalesce(json_agg(agg), '[]')`, which is what these hand-wrote before.
|
||||
// aggregate independent and drop the GROUP BY entirely.
|
||||
|
||||
import { ExpressionBuilder, Generated, Kysely } from 'kysely';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { db } from './db';
|
||||
import { DB } from './db-kysely/schema';
|
||||
import { ItemStatus, ItemImage, ItemTag } from './types';
|
||||
const IMAGES_SUBQUERY = `
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', img.id, 'image_path', img.image_path, 'sort_order', img.sort_order)
|
||||
ORDER BY img.sort_order)
|
||||
FROM item_images img
|
||||
WHERE img.item_id = i.id
|
||||
), '[]') AS images`;
|
||||
|
||||
/**
|
||||
* The mirror types `items.status` as `Generated<string>` because Postgres holds
|
||||
* it as CHECK-constrained text rather than a native enum, so `kysely-codegen`
|
||||
* has nothing narrower to emit. `types.ts` already states the real domain.
|
||||
*
|
||||
* Narrowed here, once, rather than asserted at each call site. `$castTo` at the
|
||||
* call site would have replaced the entire row type with an assertion — which
|
||||
* would silently accept a projection that had lost a column, and losing the
|
||||
* compile error on exactly that is what this change exists to prevent.
|
||||
*/
|
||||
type ItemsWithStatus = Omit<DB['items'], 'status'> & { status: Generated<ItemStatus> };
|
||||
type ItemDB = Omit<DB, 'items'> & { items: ItemsWithStatus };
|
||||
const TAGS_SUBQUERY = `
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'color', t.color) ORDER BY t.name)
|
||||
FROM item_tags it
|
||||
JOIN tags t ON t.id = it.tag_id
|
||||
WHERE it.item_id = i.id
|
||||
), '[]') AS tags`;
|
||||
|
||||
const itemDb = db as unknown as Kysely<ItemDB>;
|
||||
const FROM_CLAUSE = `
|
||||
FROM items i
|
||||
LEFT JOIN categories c ON c.id = i.category_id`;
|
||||
|
||||
/**
|
||||
* The aliases every item query and every filter clause is written against.
|
||||
*
|
||||
* `i` and `c` are kept from the SQL these replaced. Not because short names are
|
||||
* better, but because the filter clauses, the subquery correlations and the
|
||||
* ORDER BY all reference them, and renaming them in the same change that moved
|
||||
* the builder would have made the diff unreadable against the SQL it replaces.
|
||||
*/
|
||||
export type ItemContext = ExpressionBuilder<
|
||||
ItemDB & { i: ItemDB['items']; c: ItemDB['categories'] },
|
||||
'i' | 'c'
|
||||
>;
|
||||
// The storefront gets an explicit column list — it has no business seeing
|
||||
// paypal_order_id or reserved_until.
|
||||
export const PUBLIC_ITEM_SELECT = `
|
||||
SELECT i.id, i.name, i.description, i.price_cents, i.status, i.created_at, i.category_id,
|
||||
c.name AS category_name,
|
||||
${IMAGES_SUBQUERY},
|
||||
${TAGS_SUBQUERY}
|
||||
${FROM_CLAUSE}`;
|
||||
|
||||
/** The public image fields. Correlated to the outer item by `whereRef`. */
|
||||
function imagesFor(eb: ItemContext) {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('item_images as img')
|
||||
.select(['img.id', 'img.image_path', 'img.sort_order'])
|
||||
.whereRef('img.item_id', '=', 'i.id')
|
||||
.orderBy('img.sort_order')
|
||||
).as('images');
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only images, carrying `original_image_path` alongside the public
|
||||
* fields — the field the inventory screen needs to know whether a photo has a
|
||||
* cut-out to restore (#293).
|
||||
*
|
||||
* A separate function rather than a flag on `imagesFor`, for the same reason
|
||||
* `publicItemQuery` names its columns instead of taking them all: an original
|
||||
* filename is internal, nobody's business on the storefront, and a boolean in
|
||||
* the middle of the thing that keeps it off the public API is one edit away
|
||||
* from being passed wrongly.
|
||||
*/
|
||||
function adminImagesFor(eb: ItemContext) {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('item_images as img')
|
||||
.select(['img.id', 'img.image_path', 'img.sort_order', 'img.original_image_path'])
|
||||
.whereRef('img.item_id', '=', 'i.id')
|
||||
.orderBy('img.sort_order')
|
||||
).as('images');
|
||||
}
|
||||
|
||||
function tagsFor(eb: ItemContext) {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('item_tags as it')
|
||||
.innerJoin('tags as t', 't.id', 'it.tag_id')
|
||||
.select(['t.id', 't.name', 't.color'])
|
||||
.whereRef('it.item_id', '=', 'i.id')
|
||||
.orderBy('t.name')
|
||||
).as('tags');
|
||||
}
|
||||
|
||||
/**
|
||||
* The storefront's projection — an explicit column list, because it has no
|
||||
* business seeing paypal_order_id or reserved_until.
|
||||
*
|
||||
* A function rather than a constant so each caller gets a fresh builder. Kysely
|
||||
* builders are immutable, so sharing one would be safe, but a function makes it
|
||||
* obvious that adding a `where` does not affect anyone else.
|
||||
*/
|
||||
export function publicItemQuery() {
|
||||
return itemDb
|
||||
.selectFrom('items as i')
|
||||
.leftJoin('categories as c', 'c.id', 'i.category_id')
|
||||
.select([
|
||||
'i.id',
|
||||
'i.name',
|
||||
'i.description',
|
||||
'i.price_cents',
|
||||
'i.status',
|
||||
'i.created_at',
|
||||
'i.category_id',
|
||||
'c.name as category_name'
|
||||
])
|
||||
.select(imagesFor)
|
||||
.select(tagsFor);
|
||||
}
|
||||
|
||||
/** The admin projection — every item column, plus the admin image fields. */
|
||||
export function adminItemQuery() {
|
||||
return itemDb
|
||||
.selectFrom('items as i')
|
||||
.leftJoin('categories as c', 'c.id', 'i.category_id')
|
||||
.selectAll('i')
|
||||
.select('c.name as category_name')
|
||||
.select(adminImagesFor)
|
||||
.select(tagsFor);
|
||||
}
|
||||
|
||||
/** The columns every item select returns, whichever of the two it is. */
|
||||
interface ItemRowBase {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
price_cents: number;
|
||||
status: ItemStatus;
|
||||
created_at: Date;
|
||||
category_id: number | null;
|
||||
category_name: string | null;
|
||||
// json_agg with a COALESCE fallback, so these are always arrays and never null.
|
||||
images: ItemImage[];
|
||||
tags: ItemTag[];
|
||||
}
|
||||
|
||||
/** What publicItemQuery returns. Deliberately no payment or reservation columns. */
|
||||
export type PublicItemRow = ItemRowBase;
|
||||
|
||||
/**
|
||||
* An admin item's image: everything `ItemImage` has, plus where the
|
||||
* background-removed photo's original went. `null` for a photo that was never
|
||||
* cut out.
|
||||
*/
|
||||
export interface AdminItemImage extends ItemImage {
|
||||
original_image_path: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What adminItemQuery returns: `i.*`, so every column on the table.
|
||||
*
|
||||
* The extra fields are the ones the storefront is not allowed to see, which is
|
||||
* the whole reason the two selects differ. `images` is narrowed rather than
|
||||
* inherited as-is, to match `ADMIN_IMAGES_SUBQUERY` carrying
|
||||
* `original_image_path` where the public select's images do not.
|
||||
*/
|
||||
export interface AdminItemRow extends ItemRowBase {
|
||||
reserved_until: Date | null;
|
||||
sold_at: Date | null;
|
||||
paypal_order_id: string | null;
|
||||
images: AdminItemImage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare `items` row, as `RETURNING *` gives it back.
|
||||
*
|
||||
* Distinct from the two select rows above and not interchangeable with them:
|
||||
* this is the table, so it has no category_name, no images and no tags. Those
|
||||
* come from the joins and subqueries the selects add, and typing a RETURNING *
|
||||
* as AdminItemRow would promise three fields that are not in the result.
|
||||
*/
|
||||
export interface ItemRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
price_cents: number;
|
||||
status: ItemStatus;
|
||||
reserved_until: Date | null;
|
||||
sold_at: Date | null;
|
||||
paypal_order_id: string | null;
|
||||
created_at: Date;
|
||||
category_id: number | null;
|
||||
}
|
||||
export const ADMIN_ITEM_SELECT = `
|
||||
SELECT i.*,
|
||||
c.name AS category_name,
|
||||
${IMAGES_SUBQUERY},
|
||||
${TAGS_SUBQUERY}
|
||||
${FROM_CLAUSE}`;
|
||||
|
||||
+3
-118
@@ -1,19 +1,5 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
// Defaults written for Gmail. An environment on a different provider — QA is on
|
||||
// Brevo — has to set host, port and SMTP_SECURE explicitly rather than
|
||||
// inheriting these, and getting that wrong fails at send time rather than at
|
||||
// boot. See #64 on validating this at startup instead.
|
||||
// #260 put the first awaited send on a user-facing request path (the admin
|
||||
// creating an upload link). nodemailer's defaults are two minutes to connect
|
||||
// and ten minutes on the socket, which is fine for a fire-and-forget send but
|
||||
// is not a bound anyone waiting on a response can live with: the link row and
|
||||
// its token are already committed by the time sendMail is called, the token
|
||||
// is shown exactly once, and a request that hangs long enough for the browser
|
||||
// or reverse proxy to give up first loses it for good. Five seconds each is
|
||||
// long enough for a reachable host and short enough that a dead one fails
|
||||
// fast, leaving the admin with the "not emailed" warning and a link they can
|
||||
// still copy, instead of a stuck spinner and a token nobody ever saw.
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||
port: parseInt(process.env.SMTP_PORT || '465', 10),
|
||||
@@ -21,119 +7,18 @@ const transporter = nodemailer.createTransport({
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASSWORD
|
||||
},
|
||||
connectionTimeout: 5000,
|
||||
greetingTimeout: 5000,
|
||||
socketTimeout: 5000
|
||||
}
|
||||
});
|
||||
|
||||
interface ParsedAddress {
|
||||
local: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
// Lowercased, and with any `+suffix` removed from the local part. Returns null
|
||||
// for anything that is not a usable address, so a caller can refuse rather than
|
||||
// compare nonsense.
|
||||
function parseAddress(address: string): ParsedAddress | null {
|
||||
const trimmed = address.trim().toLowerCase();
|
||||
const at = trimmed.lastIndexOf('@');
|
||||
// Needs something on both sides of a single trailing @.
|
||||
if (at <= 0 || at === trimmed.length - 1) {
|
||||
return null;
|
||||
}
|
||||
const localWithSuffix = trimmed.slice(0, at);
|
||||
return {
|
||||
// split always yields at least one element, so this cannot actually be
|
||||
// undefined — but String.split's type cannot express that.
|
||||
local: localWithSuffix.split('+')[0] ?? localWithSuffix,
|
||||
domain: trimmed.slice(at + 1)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this environment is permitted to email this recipient.
|
||||
*
|
||||
* `allowlist` is the raw MAIL_ALLOWLIST value: a comma-separated list where an
|
||||
* entry is either a full address, which also covers its `+suffix` variants, or
|
||||
* `@domain`, which covers every mailbox there.
|
||||
*
|
||||
* Undefined means unrestricted, which is production — it must be able to mail
|
||||
* real customers. Present but empty means refuse everyone: someone writing
|
||||
* `MAIL_ALLOWLIST=` is expressing an intent to restrict, and reading that as
|
||||
* "no restriction" would turn a typo into an outbound mail incident.
|
||||
*
|
||||
* Comparison is by exact equality on both halves, never a suffix test, so a
|
||||
* lookalike domain ending in an allowed one does not get through.
|
||||
*
|
||||
* Exported for its unit test. This function is the entire safety property of
|
||||
* mail in a non-production environment, and it is pure, so it is worth testing
|
||||
* directly rather than through a send.
|
||||
*/
|
||||
export function isAllowedRecipient(to: string, allowlist: string | undefined): boolean {
|
||||
if (allowlist === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const entries = allowlist
|
||||
.split(',')
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter((entry) => entry !== '');
|
||||
|
||||
if (entries.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const recipient = parseAddress(to);
|
||||
if (!recipient) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return entries.some((entry) => {
|
||||
if (entry.startsWith('@')) {
|
||||
return recipient.domain === entry.slice(1);
|
||||
}
|
||||
const allowed = parseAddress(entry);
|
||||
return allowed !== null && allowed.local === recipient.local && allowed.domain === recipient.domain;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What a send attempt actually did.
|
||||
*
|
||||
* `sendMail` returns early in two cases that used to be indistinguishable from
|
||||
* success — no SMTP credentials, and a recipient outside MAIL_ALLOWLIST — which
|
||||
* meant a caller could report "emailed" for a message nobody would ever
|
||||
* receive. QA restricts delivery by design, so that was not a hypothetical: it
|
||||
* is the normal case there. See #260.
|
||||
*/
|
||||
export type MailOutcome = 'sent' | 'skipped-unconfigured' | 'skipped-blocked';
|
||||
|
||||
export async function sendMail(to: string, subject: string, html: string): Promise<MailOutcome> {
|
||||
export async function sendMail(to: string, subject: string, html: string): Promise<void> {
|
||||
if (!process.env.SMTP_USER || !process.env.SMTP_PASSWORD) {
|
||||
console.warn(`SMTP not configured — skipping email to ${to}: "${subject}"`);
|
||||
return 'skipped-unconfigured';
|
||||
return;
|
||||
}
|
||||
|
||||
// Guarded here rather than at the four call sites, so every sender is covered
|
||||
// by construction and a fifth added later cannot bypass it by forgetting.
|
||||
//
|
||||
// Skipping rather than throwing, and reporting the skip through MailOutcome
|
||||
// rather than pretending nothing happened: three of the callers already
|
||||
// swallow send failures into a log, so throwing would mostly be caught and
|
||||
// logged anyway while risking a 500 on the signup path. The flow under test
|
||||
// finishes, and both the log and the returned outcome say why no mail
|
||||
// arrived — which is the part that was missing when QA was simply muted.
|
||||
if (!isAllowedRecipient(to, process.env.MAIL_ALLOWLIST)) {
|
||||
console.warn(`[mail-blocked] ${to} is not on MAIL_ALLOWLIST — skipping "${subject}"`);
|
||||
return 'skipped-blocked';
|
||||
}
|
||||
|
||||
await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM || process.env.SMTP_USER,
|
||||
to,
|
||||
subject,
|
||||
html
|
||||
});
|
||||
return 'sent';
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import crypto from 'crypto';
|
||||
|
||||
// The header Nginx Proxy Manager injects on the authentik-gated location. The
|
||||
// name is not a secret and does not need to be — the value is.
|
||||
export const ADMIN_GATE_HEADER = 'x-admin-gate';
|
||||
|
||||
// Hashed before comparing for two reasons. timingSafeEqual throws on buffers of
|
||||
// unequal length, so comparing raw values would turn a short header into a 500
|
||||
// instead of a 403; and a length check before comparing would leak the secret's
|
||||
// length. Digests are always 32 bytes, so neither problem arises.
|
||||
function digest(value: string): Buffer {
|
||||
return crypto.createHash('sha256').update(value, 'utf8').digest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Defence in depth for the admin API.
|
||||
*
|
||||
* Authorization for `/admin` and `/api/admin` lives entirely in one
|
||||
* `auth_request` regex in an Nginx Proxy Manager config outside this
|
||||
* repository. That control is real and it works, but it is invisible from the
|
||||
* code, untested here, and bypassed completely by anything that reaches the
|
||||
* published container port directly. See #63.
|
||||
*
|
||||
* With `ADMIN_GATE_SECRET` set, the proxy injects the matching header and this
|
||||
* refuses anything that arrives without it.
|
||||
*
|
||||
* Unset — or empty, which cannot mean "enforce" without letting an empty header
|
||||
* through — this is a no-op and the API is proxy-protected exactly as before.
|
||||
* That keeps local development and the existing admin tests working untouched,
|
||||
* and means shipping the image before configuring the proxy cannot take the
|
||||
* admin panel down. `server.ts` warns at boot when it is inactive, so the
|
||||
* inactive state is visible rather than silent.
|
||||
*
|
||||
* Mounted on the admin routers rather than on a path prefix, deliberately. An
|
||||
* admin router added later at a path the proxy regex does not match will
|
||||
* receive no header and refuse loudly on the first request, instead of being
|
||||
* quietly public — which is the failure #63 was most concerned about.
|
||||
*/
|
||||
export function requireAdminGate(req: Request, res: Response, next: NextFunction): void {
|
||||
const secret = process.env.ADMIN_GATE_SECRET;
|
||||
if (!secret) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const provided = req.get(ADMIN_GATE_HEADER);
|
||||
if (typeof provided !== 'string' || !crypto.timingSafeEqual(digest(provided), digest(secret))) {
|
||||
// Logged because a 403 from behind a proxy is otherwise very hard to
|
||||
// diagnose — most often it means the proxy config and the stack's secret
|
||||
// have drifted apart. The value sent is deliberately not echoed.
|
||||
console.warn(
|
||||
`[admin-gate] refused ${req.method} ${req.originalUrl} — ` +
|
||||
`${provided === undefined ? 'no' : 'incorrect'} ${ADMIN_GATE_HEADER} header`
|
||||
);
|
||||
res.status(403).json({ error: 'forbidden' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
@@ -2,11 +2,6 @@ import { Request, Response, NextFunction } from 'express';
|
||||
import { pool } from '../db';
|
||||
|
||||
declare global {
|
||||
// A namespace is the only way to spell an Express type augmentation — the
|
||||
// interface has to merge into the one Express declares, and Express declares
|
||||
// it inside a namespace. There is no ES module form of this, so the rule is
|
||||
// disabled here rather than worked around.
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Express {
|
||||
interface Request {
|
||||
customerId?: number;
|
||||
@@ -14,11 +9,6 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
/** Who a session cookie belongs to, if it is still valid. */
|
||||
interface SessionOwnerRow {
|
||||
customer_id: number;
|
||||
}
|
||||
|
||||
export async function attachCustomer(req: Request, _res: Response, next: NextFunction): Promise<void> {
|
||||
const token = req.cookies?.rd_session;
|
||||
if (!token) return next();
|
||||
@@ -26,15 +16,14 @@ export async function attachCustomer(req: Request, _res: Response, next: NextFun
|
||||
// than when its 30-day cookie eventually expires. Register, login and
|
||||
// password reset all mint sessions, so checking here covers every path
|
||||
// instead of three separate ones.
|
||||
const { rows } = await pool.query<SessionOwnerRow>(
|
||||
const { rows } = await pool.query(
|
||||
`SELECT s.customer_id
|
||||
FROM customer_sessions s
|
||||
JOIN customers c ON c.id = s.customer_id
|
||||
WHERE s.token = $1 AND s.expires_at > now() AND c.disabled_at IS NULL`,
|
||||
[token]
|
||||
);
|
||||
const [session] = rows;
|
||||
if (session) req.customerId = session.customer_id;
|
||||
if (rows.length) req.customerId = rows[0].customer_id;
|
||||
next();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* A name for a passkey the customer did not name themselves (#38).
|
||||
*
|
||||
* The management screen (#40) lists credentials and offers to revoke them, so
|
||||
* two entries that read identically are a screen where the customer cannot tell
|
||||
* which device they are removing. A default that says something is the
|
||||
* difference between "Passkey, Passkey, Passkey" and a list worth showing.
|
||||
*
|
||||
* Derived from the authenticator's transports, which is the only thing the
|
||||
* ceremony learns about the device. It is a hint rather than a fact — the
|
||||
* browser reports what the authenticator claims — so these are deliberately
|
||||
* vague. "This device" is honest about a platform authenticator in a way that
|
||||
* guessing "MacBook" would not be.
|
||||
*/
|
||||
|
||||
/** The fallback when the authenticator reports nothing usable. */
|
||||
export const GENERIC_CREDENTIAL_NAME = 'Passkey';
|
||||
|
||||
export function defaultCredentialName(transports: readonly string[] | null | undefined): string {
|
||||
if (!transports || transports.length === 0) return GENERIC_CREDENTIAL_NAME;
|
||||
|
||||
// Checked in this order because an authenticator can report several. A phone
|
||||
// used as a cross-device passkey reports `hybrid` and often `internal` too,
|
||||
// and "Phone or tablet" is the more useful of the two readings — `internal`
|
||||
// alone means the authenticator built into the machine being used.
|
||||
if (transports.includes('hybrid')) return 'Phone or tablet';
|
||||
if (transports.includes('internal')) return 'This device';
|
||||
if (transports.some((t) => t === 'usb' || t === 'nfc' || t === 'ble')) return 'Security key';
|
||||
|
||||
return GENERIC_CREDENTIAL_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer's own name for a passkey, or null when they gave none.
|
||||
*
|
||||
* Trimmed, because a name of spaces is a name nobody can read in a list, and
|
||||
* bounded because this is rendered — a customer is naming their laptop, not
|
||||
* writing prose, and an unbounded string in a table cell is a layout problem
|
||||
* rather than an expressive one.
|
||||
*/
|
||||
export const MAX_CREDENTIAL_NAME_LENGTH = 64;
|
||||
|
||||
export function readCredentialName(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') return null;
|
||||
return trimmed.slice(0, MAX_CREDENTIAL_NAME_LENGTH);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* Who this application is, as far as WebAuthn is concerned (#37).
|
||||
*
|
||||
* ## Why this is derived rather than written down
|
||||
*
|
||||
* The Relying Party ID is a domain, and **a credential is bound to it
|
||||
* permanently**. A passkey registered against one RP ID cannot be used against
|
||||
* another — there is no migration, no re-signing, and no way to carry one over.
|
||||
* So the RP ID is the one piece of configuration that must never be wrong, and
|
||||
* must never be a value someone remembered to change.
|
||||
*
|
||||
* It comes from `PUBLIC_URL`, which is the same value every customer-facing
|
||||
* link is already built from. That makes the RP ID correct by construction in
|
||||
* any environment where mail works, and wrong only in environments where the
|
||||
* links were already wrong.
|
||||
*
|
||||
* ## The consequence worth stating plainly
|
||||
*
|
||||
* Each environment is a different Relying Party:
|
||||
*
|
||||
* | Environment | RP ID | Effect |
|
||||
* | --- | --- | --- |
|
||||
* | Local | `localhost` | A secure context by exception, so passkeys work |
|
||||
* | QA | the QA hostname | Registered here, usable only here |
|
||||
* | Production | the production hostname | Different credentials again |
|
||||
*
|
||||
* **QA can prove the flow and can never prove the credentials.** A passkey
|
||||
* registered in QA will not sign in to production, and that is correct rather
|
||||
* than a bug to work around.
|
||||
*
|
||||
* It also means **#313 destroys every passkey registered before it**. Moving to
|
||||
* `redefined-designs.com` changes the RP ID, so credentials bound to
|
||||
* `*.bermudalamb.synology.me` stop working at the cutover with no way back.
|
||||
* This code needs no change when that happens — it follows `PUBLIC_URL` — but
|
||||
* anyone who registered a passkey beforehand has to register it again. That is
|
||||
* free today, because production is not live and no real customer holds one,
|
||||
* and it stops being free the moment the shop opens.
|
||||
*/
|
||||
|
||||
/** Everything the ceremonies need to identify this Relying Party. */
|
||||
export interface RelyingParty {
|
||||
/** The RP ID: a bare domain, no scheme and no port. */
|
||||
id: string;
|
||||
/** Shown by the authenticator when it asks the customer to confirm. */
|
||||
name: string;
|
||||
/**
|
||||
* Origins a ceremony may legitimately come from.
|
||||
*
|
||||
* A list rather than one string because local development serves the app from
|
||||
* two: Vite on 5173 during `npm run dev`, and the backend on 3000 when the
|
||||
* built frontend is served by Express. Both are `localhost`, so both are the
|
||||
* same Relying Party — only the port differs, and the port is not part of the
|
||||
* RP ID. Deployed environments have exactly one.
|
||||
*/
|
||||
origins: string[];
|
||||
}
|
||||
|
||||
export const RELYING_PARTY_NAME = 'Redefined Designs';
|
||||
|
||||
/**
|
||||
* Local development, where `PUBLIC_URL` is legitimately unset.
|
||||
*
|
||||
* `envValidation` requires `PUBLIC_URL` only when SMTP is configured, so a local
|
||||
* setup that cannot send mail does not have it — and refusing to start there
|
||||
* would break every such setup to prevent nothing. `localhost` is a secure
|
||||
* context by exception in every browser that implements WebAuthn, so this works
|
||||
* without TLS.
|
||||
*/
|
||||
const LOCAL_ORIGINS = ['http://localhost:5173', 'http://localhost:3000'];
|
||||
|
||||
/**
|
||||
* The Relying Party for this environment.
|
||||
*
|
||||
* Takes the environment as an argument so it can be tested without touching
|
||||
* `process.env`, and reads it on each call rather than at import time: the
|
||||
* module would otherwise capture whatever was set when it was first required,
|
||||
* which in tests is whatever the previous suite happened to leave behind.
|
||||
*
|
||||
* Throws on a `PUBLIC_URL` that is set but unparseable. That is a deployment
|
||||
* that will also produce broken links in every email, so failing here is not
|
||||
* the first thing to go wrong — it is the first thing to *say so*.
|
||||
*/
|
||||
export function relyingParty(env: NodeJS.ProcessEnv = process.env): RelyingParty {
|
||||
const publicUrl = (env.PUBLIC_URL ?? '').trim();
|
||||
|
||||
if (publicUrl === '') {
|
||||
return { id: 'localhost', name: RELYING_PARTY_NAME, origins: LOCAL_ORIGINS };
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(publicUrl);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`PUBLIC_URL is not a URL (${publicUrl}), so the WebAuthn Relying Party ID cannot be ` +
|
||||
'derived from it. Every passkey is bound permanently to that ID, so this is refused ' +
|
||||
'rather than guessed at.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
// `hostname` rather than `host`: the RP ID is a domain and must not carry a
|
||||
// port. `host` includes one when the URL has it, and an RP ID of
|
||||
// "example.com:8443" matches nothing.
|
||||
id: parsed.hostname,
|
||||
name: RELYING_PARTY_NAME,
|
||||
// `origin` normalises away any path, trailing slash or default port, which
|
||||
// is exactly the string the browser will report.
|
||||
origins: [parsed.origin]
|
||||
};
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Whether an authenticator's signature counter is acceptable (#39).
|
||||
*
|
||||
* #37 deliberately left this open, because the schema only had to hold the
|
||||
* value and the policy belongs with the ceremony that enforces it. This is that
|
||||
* policy.
|
||||
*
|
||||
* ## The counter, and why a naive rule is wrong
|
||||
*
|
||||
* A hardware authenticator increments a counter on every assertion. If a
|
||||
* credential is cloned, the two copies drift, and a counter that fails to
|
||||
* advance is the signal that has happened. Requiring it to increase is the
|
||||
* whole point of storing it.
|
||||
*
|
||||
* **But most passkeys never increment it.** A synced credential — iCloud
|
||||
* Keychain, Google Password Manager — exists on several devices by design, so a
|
||||
* per-device counter would be meaningless and the specification allows
|
||||
* reporting zero forever. Requiring an increase from those would refuse every
|
||||
* sign-in from the authenticators most customers actually use.
|
||||
*
|
||||
* So the rule is conditional on what the authenticator claims about itself:
|
||||
*
|
||||
* - **Both zero** — it does not implement counters. Accept, and keep accepting.
|
||||
* There is no signal here to read, and inventing one refuses real customers.
|
||||
* - **Anything else** — it does implement them, so require a strict increase.
|
||||
* A counter that stalls or goes backwards is the clone signal, and refusing
|
||||
* is the entire reason the column exists.
|
||||
*
|
||||
* The asymmetry is deliberate: an authenticator that has ever reported a
|
||||
* non-zero counter is held to the strict rule from then on, so one cannot
|
||||
* downgrade itself to zero to escape the check.
|
||||
*/
|
||||
|
||||
export interface CounterVerdict {
|
||||
ok: boolean;
|
||||
/** Why it was refused, for the log. Never shown to the caller. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function checkSignatureCounter(stored: number, received: number): CounterVerdict {
|
||||
if (stored === 0 && received === 0) return { ok: true };
|
||||
|
||||
if (received > stored) return { ok: true };
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
`signature counter did not advance (stored ${stored}, received ${received}) — ` +
|
||||
'the credential may have been cloned'
|
||||
};
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
/**
|
||||
* How expensive a password hash is, and why that differs under test.
|
||||
*
|
||||
* bcrypt's cost is exponential: each step doubles the work. Twelve is the right
|
||||
* number for real passwords and the wrong one for a test suite that registers
|
||||
* around thirty-five customers and asserts nothing about any of their hashes.
|
||||
* `bcryptjs` is a pure-JS implementation, so it pays that cost several times
|
||||
* over compared with a native build, and the integration suite spent most of
|
||||
* its wall clock there. On a loaded runner that pushed
|
||||
* adminInventory.integration.test.ts past its twenty-second timeout, which read
|
||||
* as a foreign key violation somewhere else entirely — see #242.
|
||||
*
|
||||
* Deliberately not configurable.
|
||||
* ------------------------------
|
||||
* An environment variable here would be a way to weaken password hashing in
|
||||
* production by misconfiguration, and nothing needs to tune this. The only way
|
||||
* to reach the cheap cost is NODE_ENV=test, which a deployed container would
|
||||
* also announce loudly by refusing to serve the built frontend — app.ts gates
|
||||
* static file serving on the same value. A setting that quietly degrades a
|
||||
* security property should be unreachable rather than merely warned about,
|
||||
* which is the same reasoning that made DEMO_MODE strict.
|
||||
*/
|
||||
|
||||
/** What real passwords are hashed with, everywhere that is not a test run. */
|
||||
export const PRODUCTION_ROUNDS = 12;
|
||||
|
||||
/**
|
||||
* What tests hash with. 2^8 = 256 times less work than production.
|
||||
*
|
||||
* Four is bcrypt's own floor, so this is as cheap as the algorithm allows. It
|
||||
* is a fine number for a suite whose passwords are fixtures; it would be a
|
||||
* serious defect anywhere a real one is stored.
|
||||
*/
|
||||
export const TEST_ROUNDS = 4;
|
||||
|
||||
/**
|
||||
* The cost for an environment, from NODE_ENV.
|
||||
*
|
||||
* Pure and exported for its test: this is the whole of the policy, and the
|
||||
* failure it guards against is silent. Only the exact string 'test' earns the
|
||||
* cheap cost — an unset NODE_ENV, or anything else, gets the strong one, so the
|
||||
* dangerous direction requires saying so explicitly.
|
||||
*/
|
||||
export function hashRoundsFor(nodeEnv: string | undefined): number {
|
||||
return nodeEnv === 'test' ? TEST_ROUNDS : PRODUCTION_ROUNDS;
|
||||
}
|
||||
|
||||
/** Resolved once at import: NODE_ENV does not change while the process runs. */
|
||||
export const PASSWORD_HASH_ROUNDS = hashRoundsFor(process.env.NODE_ENV);
|
||||
|
||||
/**
|
||||
* Whether a supplied password matches a stored hash that may not exist (#340).
|
||||
*
|
||||
* `customers.password_hash` became nullable when social sign-in arrived, and a
|
||||
* null one is not an edge case to tidy away — it is a customer who signed up
|
||||
* through Google and has never set a password. There is nothing to compare
|
||||
* against, so the answer is no.
|
||||
*
|
||||
* This exists because the alternative is worse than a wrong answer.
|
||||
* `bcrypt.compare` throws `Illegal arguments` on a null hash rather than
|
||||
* returning false, so every call site that forgot the check would answer a
|
||||
* sign-in attempt with a 500 instead of a refusal — and a 500 on the login route
|
||||
* is also an oracle, since it happens for exactly the accounts that have no
|
||||
* password.
|
||||
*
|
||||
* One function rather than a null check repeated at each call site, so the
|
||||
* question is asked the same way in all three places and a fourth cannot forget
|
||||
* to ask it.
|
||||
*/
|
||||
export async function passwordMatches(
|
||||
supplied: unknown,
|
||||
storedHash: string | null | undefined
|
||||
): Promise<boolean> {
|
||||
if (!storedHash) return false;
|
||||
return bcrypt.compare(String(supplied ?? ''), storedHash);
|
||||
}
|
||||
+3
-177
@@ -1,4 +1,4 @@
|
||||
import rateLimit, { ipKeyGenerator, MemoryStore } from 'express-rate-limit';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { Request } from 'express';
|
||||
|
||||
// First rate limiting in the codebase. The password-reset endpoints need it
|
||||
@@ -20,30 +20,9 @@ const MAX_REQUESTS = 5;
|
||||
// This key only makes sense on a request that carries an email. Applying the
|
||||
// same limiter to an endpoint without one collapses every caller into a single
|
||||
// `ip:` bucket, which is a shared allowance rather than a per-caller one.
|
||||
//
|
||||
// The caller half goes through ipKeyGenerator rather than using req.ip raw.
|
||||
// A raw IPv6 address is the full 128 bits, but a residential IPv6 customer is
|
||||
// delegated an entire prefix and can source every request from a different
|
||||
// address inside it for free — so keyed on the exact address this limiter
|
||||
// counted each request as a new caller and never bound at all. That is not a
|
||||
// small miss: this limiter is the only thing stopping anyone making the server
|
||||
// send unlimited mail to any address they choose. express-rate-limit reported
|
||||
// it as ERR_ERL_KEY_GEN_IPV6 on every boot; see #84.
|
||||
//
|
||||
// The helper's default groups IPv6 by /56 rather than /64. That is the
|
||||
// deliberate choice: /56 covers a whole delegated site, so an attacker cannot
|
||||
// escape the bucket by moving within their own allocation. It does mean
|
||||
// several households behind one delegation share an allowance — acceptable
|
||||
// here only because the key also contains the email address, so they collide
|
||||
// just when targeting the same account. IPv4 is returned unchanged.
|
||||
//
|
||||
// Exported for the unit test. The limiter's own allowance is not worth
|
||||
// asserting in a test — its store is process-wide, so exhausting it leaks into
|
||||
// every later test from the same address — but the key function is pure and
|
||||
// is where the bug actually was.
|
||||
export function keyByCallerAndEmail(req: Request): string {
|
||||
function keyByCallerAndEmail(req: Request): string {
|
||||
const email = typeof req.body?.email === 'string' ? req.body.email.toLowerCase().trim() : '';
|
||||
return `${ipKeyGenerator(req.ip ?? '')}:${email}`;
|
||||
return `${req.ip}:${email}`;
|
||||
}
|
||||
|
||||
export const passwordResetRequestLimiter = rateLimit({
|
||||
@@ -54,156 +33,3 @@ export const passwordResetRequestLimiter = rateLimit({
|
||||
legacyHeaders: false,
|
||||
message: { error: 'too many attempts, please try again later' }
|
||||
});
|
||||
|
||||
// Client error reports carry no email, so this one is keyed on the caller
|
||||
// alone — deliberately not reusing passwordResetRequestLimiter, whose comment
|
||||
// above explains why its key is wrong for an endpoint without an email.
|
||||
//
|
||||
// `trust proxy` is set in app.ts, so `req.ip` is the real client address from
|
||||
// X-Forwarded-For rather than Nginx Proxy Manager's, making this a per-customer
|
||||
// allowance rather than one shared by everybody behind the proxy.
|
||||
//
|
||||
// Generous, because hitting the limit is harmless: the reporter ignores the
|
||||
// response either way. It exists so a render loop cannot fill the log.
|
||||
const CLIENT_ERROR_WINDOW_MS = 15 * 60 * 1000;
|
||||
const CLIENT_ERROR_MAX_REQUESTS = 30;
|
||||
|
||||
export const clientErrorLimiter = rateLimit({
|
||||
windowMs: CLIENT_ERROR_WINDOW_MS,
|
||||
limit: CLIENT_ERROR_MAX_REQUESTS,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
message: { error: 'too many reports' }
|
||||
});
|
||||
|
||||
// Resending a verification email makes the server send mail on request, which
|
||||
// is the same class of endpoint as password reset and needs the same treatment.
|
||||
//
|
||||
// Keyed on the customer id, which is tighter than either limiter above and
|
||||
// sidesteps the IPv6 problem of #84 entirely: the caller is signed in, so there
|
||||
// is an identity better than an address to count against, and no amount of
|
||||
// moving within a delegated prefix changes it. It also means one customer
|
||||
// cannot spend anyone else's allowance, which keying on IP would allow.
|
||||
//
|
||||
// It does NOT make the store's process-wide lifetime a non-issue for tests, as
|
||||
// was assumed at first. resetDb truncates with RESTART IDENTITY, so every
|
||||
// integration test's first customer is id 1 and they all share one bucket:
|
||||
// three tests that each send once exhaust the allowance for the fourth. The
|
||||
// store below is explicit and exported so a test can clear it, rather than
|
||||
// tests being written around an allowance they cannot see.
|
||||
//
|
||||
// Must be mounted *after* requireCustomer. Before it, req.customerId is
|
||||
// undefined and every anonymous caller would share a single bucket — the same
|
||||
// collapse the passwordResetRequestLimiter comment warns about.
|
||||
export function keyByCustomer(req: Request): string {
|
||||
return `customer:${req.customerId ?? 'anonymous'}`;
|
||||
}
|
||||
|
||||
// Three an hour is generous for someone who genuinely lost the mail, and
|
||||
// useless to anybody hammering it. The window is longer than the 15 minutes
|
||||
// used above because the failure it guards against is slower: a verification
|
||||
// link lasts 24 hours, so there is no reason to want a fourth inside an hour.
|
||||
const VERIFICATION_RESEND_WINDOW_MS = 60 * 60 * 1000;
|
||||
const VERIFICATION_RESEND_MAX = 3;
|
||||
|
||||
// Exported only so the integration suite can clear it between tests. See the
|
||||
// note above: recycled customer ids make the allowance leak across tests.
|
||||
export const verificationResendStore = new MemoryStore();
|
||||
|
||||
export const verificationResendLimiter = rateLimit({
|
||||
windowMs: VERIFICATION_RESEND_WINDOW_MS,
|
||||
limit: VERIFICATION_RESEND_MAX,
|
||||
keyGenerator: keyByCustomer,
|
||||
store: verificationResendStore,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
// Says what actually happened rather than only that a limit was hit. The mail
|
||||
// almost certainly did send, so "check your spam folder" is both the more
|
||||
// useful instruction and the more honest one.
|
||||
message: {
|
||||
error: 'we have already sent several verification emails recently. Check your spam folder, and try again later.'
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Keyed on the caller alone, because an intake submission carries no email.
|
||||
*
|
||||
* The comment on `keyByCallerAndEmail` warns that a bare `ip:` bucket is a
|
||||
* shared allowance rather than a per-caller one, and that trade is accepted
|
||||
* here deliberately: the *link* is the per-caller identity, and its
|
||||
* `submission_count` against `max_submissions` is the per-caller cap. This
|
||||
* limiter exists for a different job — bounding what one address can throw at
|
||||
* an unauthenticated endpoint that writes files to disk.
|
||||
*
|
||||
* ipKeyGenerator rather than `req.ip` raw, for the reason #84 records: a
|
||||
* residential IPv6 customer is delegated a whole prefix and can source every
|
||||
* request from a different address inside it for free, so keying on the exact
|
||||
* address counts each one as a new caller and never bounds anything.
|
||||
*/
|
||||
export function keyByCaller(req: Request): string {
|
||||
return ipKeyGenerator(req.ip ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Two limiters rather than one, because the two requests cost different things.
|
||||
*
|
||||
* Reading a link is a page load: it hits one indexed row and writes nothing.
|
||||
* Submitting writes up to six files to the uploads volume. Counting them
|
||||
* against a single allowance meant reloading the page consumed the budget for
|
||||
* sending items, and at twenty apiece that allowance ran out after ten items —
|
||||
* for exactly the person this feature is for, somebody working through a box
|
||||
* of stock. The comment here used to say refusing them costs a consignment,
|
||||
* while the number quietly did it.
|
||||
*
|
||||
* Both still key on the caller alone, since a submission carries no email. The
|
||||
* `keyByCallerAndEmail` comment warns that a bare `ip:` bucket is a shared
|
||||
* allowance rather than a per-caller one, and that trade is accepted here: the
|
||||
* link is the per-caller identity and its `max_submissions` is the per-caller
|
||||
* cap, while these bound what one address can throw at an unauthenticated
|
||||
* endpoint.
|
||||
*/
|
||||
export const intakeViewLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
// Generous, because it is a page load. Someone re-reading the form, losing
|
||||
// their signal, or coming back to it should never be told to wait.
|
||||
limit: 120,
|
||||
keyGenerator: keyByCaller,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
message: { error: 'too many requests — please try again shortly' }
|
||||
});
|
||||
|
||||
export const intakeSubmitLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
// Each of these writes files, so this is the one worth bounding. Thirty in a
|
||||
// quarter of an hour is more than anyone photographing items can manage and
|
||||
// far less than a script would want.
|
||||
limit: 30,
|
||||
keyGenerator: keyByCaller,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
message: { error: 'too many submissions — please try again later' }
|
||||
});
|
||||
|
||||
/**
|
||||
* Starting a Google sign-in (#341).
|
||||
*
|
||||
* The route mints three secrets and issues a redirect, which is cheap but not
|
||||
* free, and it is reachable without a session by anyone who knows the URL.
|
||||
*
|
||||
* Generous, because a customer who bounces off Google's consent screen and
|
||||
* tries again is doing something entirely reasonable and must never be told to
|
||||
* wait. The limit exists so a loop cannot spend the server's entropy and fill
|
||||
* the log, not to police customers.
|
||||
*
|
||||
* Keyed on the caller alone: this endpoint carries no email, which is the
|
||||
* distinction the comment on the client-error limiter above draws.
|
||||
*/
|
||||
export const googleSignInLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
limit: 60,
|
||||
keyGenerator: keyByCaller,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
message: { error: 'too many sign-in attempts — please try again shortly' }
|
||||
});
|
||||
|
||||
+105
-294
@@ -1,34 +1,62 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { PoolClient } from 'pg';
|
||||
import { pool, requireRow } from '../db';
|
||||
import { adminItemQuery, AdminItemRow, ItemRecord } from '../itemSelect';
|
||||
import { ItemStatus } from '../types';
|
||||
import { pool } from '../db';
|
||||
import { ADMIN_ITEM_SELECT } from '../itemSelect';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { parseItemFilters, itemFilterExpressions, FilterError } from '../itemFilters';
|
||||
import { readId, tagColorFor } from '../utils';
|
||||
import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters';
|
||||
import { tagColorFor } from '../utils';
|
||||
import { notifyFavoritersOfSale, notifyFavoritersOfRemoval, collectFavoriteRecipients } from '../favoriteAlerts';
|
||||
import { removeBackgroundsForItem, restoreOriginalsForItem } from '../intake/backgroundRemoval';
|
||||
import { rotateItemImage, ImageNotOnItemError } from '../imageRotation';
|
||||
import { RotateDirection } from '../imageProcessing';
|
||||
// The upload pipeline moved to src/imageUpload.ts when #222's public intake
|
||||
// endpoint became a second caller. Mounting uploadImages gets the type
|
||||
// allowlist, the magic-byte check, and the EXIF-stripping re-encode together —
|
||||
// which is the point of it being one module rather than something each route
|
||||
// assembles for itself.
|
||||
import { uploadImages, insertItemImages } from '../imageUpload';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** The next image slot, from a COALESCE'd MAX so it is never null. */
|
||||
interface MaxSortRow {
|
||||
max_sort: number;
|
||||
}
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/app/uploads';
|
||||
|
||||
/** Just the status column, read before deciding whether a transition is legal. */
|
||||
interface ItemStatusRow {
|
||||
status: ItemStatus;
|
||||
}
|
||||
// Multer writes to disk with no size cap unless one is given, so a single
|
||||
// request could fill the uploads volume. Bound every dimension of the
|
||||
// multipart body: image count, bytes per image, and the small text fields
|
||||
// (name/description/price) that accompany them.
|
||||
const MAX_IMAGES_PER_REQUEST = 6;
|
||||
// 8 MB, not 8 MiB — this is the ceiling S5693 treats as safe, and 8 * 1024 *
|
||||
// 1024 sits just over it. Plenty for a product photo either way.
|
||||
const MAX_IMAGE_BYTES = 8_000_000;
|
||||
const MAX_TEXT_FIELDS = 8;
|
||||
const MAX_TEXT_FIELD_BYTES = 64 * 1024;
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: UPLOADS_DIR,
|
||||
// Stored names come from a CSPRNG rather than a timestamp plus Math.random,
|
||||
// which is predictable enough that a caller could guess (or collide with)
|
||||
// another upload's path.
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${randomUUID()}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: MAX_IMAGE_BYTES,
|
||||
files: MAX_IMAGES_PER_REQUEST,
|
||||
fields: MAX_TEXT_FIELDS,
|
||||
fieldSize: MAX_TEXT_FIELD_BYTES
|
||||
}
|
||||
});
|
||||
|
||||
// No error-handling middleware is mounted on the app, so translate multer's
|
||||
// limit errors here instead of letting them surface as a generic 500.
|
||||
const uploadImages = (req: Request, res: Response, next: NextFunction) => {
|
||||
upload.array('images', MAX_IMAGES_PER_REQUEST)(req, res, (err: unknown) => {
|
||||
if (err instanceof multer.MulterError) {
|
||||
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
|
||||
return res.status(status).json({ error: err.message });
|
||||
}
|
||||
return next(err);
|
||||
});
|
||||
};
|
||||
|
||||
// The multipart body carries category_id and tags as text fields. An absent
|
||||
// field means "leave as-is" on update, which is why these return undefined
|
||||
@@ -86,32 +114,6 @@ async function setItemTags(client: PoolClient, itemId: number, tagIds: number[])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The two optional fields the item form submits as multipart text.
|
||||
*
|
||||
* Both routes parsed them and refused them identically, eight lines each. The
|
||||
* distinction being preserved is that `undefined` means "not submitted", which
|
||||
* update reads as "leave as-is" — so an unparseable value has to be told apart
|
||||
* from an absent one, which is what makes this more than a null check and worth
|
||||
* having in one place.
|
||||
*/
|
||||
type ParsedItemFields =
|
||||
| { ok: true; categoryId: number | null | undefined; tagNames: string[] | undefined }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function readOptionalItemFields(body: Record<string, unknown>): ParsedItemFields {
|
||||
const categoryId = readCategoryId(body.category_id);
|
||||
if (categoryId === undefined && body.category_id !== undefined) {
|
||||
return { ok: false, error: 'invalid category_id' };
|
||||
}
|
||||
const tagNames = readTagNames(body.tags);
|
||||
if (tagNames === undefined && body.tags !== undefined) {
|
||||
return { ok: false, error: 'invalid tags' };
|
||||
}
|
||||
return { ok: true, categoryId, tagNames };
|
||||
}
|
||||
|
||||
router.get('/items', asyncRoute(async (req: Request, res: Response) => {
|
||||
// Same parser and query builder as the storefront, so admin filtering cannot
|
||||
// drift from what customers see. The one addition is `status`, which is how
|
||||
@@ -132,46 +134,45 @@ router.get('/items', asyncRoute(async (req: Request, res: Response) => {
|
||||
return res.status(400).json({ error: 'favorites is not a valid inventory filter' });
|
||||
}
|
||||
|
||||
// No interpolation, and nothing to argue about. Until #308 this assembled
|
||||
// `${ADMIN_ITEM_SELECT} ${where}` from clauses composed at run time, and
|
||||
// sixteen lines in itemFilters.ts explained why that was safe. The clauses
|
||||
// are Kysely expressions now: a value cannot reach the SQL text, because the
|
||||
// types do not let it.
|
||||
const rows: AdminItemRow[] = await adminItemQuery()
|
||||
.where((eb) => eb.and(itemFilterExpressions(eb, filters, null)))
|
||||
.orderBy('i.created_at', 'desc')
|
||||
.execute();
|
||||
|
||||
const { clauses, params } = buildItemFilterSql(filters, 1, null);
|
||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
const { rows } = await pool.query(`${ADMIN_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params);
|
||||
res.json(rows);
|
||||
}));
|
||||
|
||||
router.post('/items', uploadImages, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { name, description, price } = req.body;
|
||||
|
||||
const parsed = readOptionalItemFields(req.body);
|
||||
if (!parsed.ok) return res.status(400).json({ error: parsed.error });
|
||||
const { categoryId, tagNames } = parsed;
|
||||
const categoryId = readCategoryId(req.body.category_id);
|
||||
if (categoryId === undefined && req.body.category_id !== undefined) {
|
||||
return res.status(400).json({ error: 'invalid category_id' });
|
||||
}
|
||||
const tagNames = readTagNames(req.body.tags);
|
||||
if (tagNames === undefined && req.body.tags !== undefined) {
|
||||
return res.status(400).json({ error: 'invalid tags' });
|
||||
}
|
||||
|
||||
const files = (req.files as Express.Multer.File[]) || [];
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query<ItemRecord>(
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO items (name, description, price_cents, category_id) VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[name, description, Math.round(parseFloat(price) * 100), categoryId ?? null]
|
||||
);
|
||||
const item = requireRow(rows, 'the item INSERT');
|
||||
await insertItemImages(client, item.id, files, 0);
|
||||
const item = rows[0];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
await client.query(
|
||||
`INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, $2, $3)`,
|
||||
[item.id, `/uploads/${files[i].filename}`, i]
|
||||
);
|
||||
}
|
||||
if (tagNames) {
|
||||
await setItemTags(client, item.id, await resolveTagIds(client, tagNames));
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
// No interpolation here at all now: the whole query is a constant and the id
|
||||
// is bound as $1. It always was bound — what changed is that a reader no
|
||||
// longer has to check that the interpolated half carries no caller data,
|
||||
// because there is no interpolated half. See #294.
|
||||
const full = await adminItemQuery().where('i.id', '=', item.id).execute();
|
||||
res.json(requireRow(full, 'the item just inserted'));
|
||||
const { rows: full } = await pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [item.id]);
|
||||
res.json(full[0]);
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(err);
|
||||
@@ -182,14 +183,16 @@ router.post('/items', uploadImages, asyncRoute(async (req: Request, res: Respons
|
||||
}));
|
||||
|
||||
router.put('/items/:id', uploadImages, asyncRoute(async (req: Request, res: Response) => {
|
||||
const itemId = readId(req.params.id);
|
||||
if (itemId === null) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const { name, description, price } = req.body;
|
||||
|
||||
const parsed = readOptionalItemFields(req.body);
|
||||
if (!parsed.ok) return res.status(400).json({ error: parsed.error });
|
||||
const { categoryId, tagNames } = parsed;
|
||||
const categoryId = readCategoryId(req.body.category_id);
|
||||
if (categoryId === undefined && req.body.category_id !== undefined) {
|
||||
return res.status(400).json({ error: 'invalid category_id' });
|
||||
}
|
||||
const tagNames = readTagNames(req.body.tags);
|
||||
if (tagNames === undefined && req.body.tags !== undefined) {
|
||||
return res.status(400).json({ error: 'invalid tags' });
|
||||
}
|
||||
|
||||
const files = (req.files as Express.Multer.File[]) || [];
|
||||
const client = await pool.connect();
|
||||
@@ -197,42 +200,32 @@ router.put('/items/:id', uploadImages, asyncRoute(async (req: Request, res: Resp
|
||||
await client.query('BEGIN');
|
||||
await client.query(
|
||||
`UPDATE items SET name=$1, description=$2, price_cents=$3 WHERE id=$4`,
|
||||
[name, description, Math.round(parseFloat(price) * 100), itemId]
|
||||
[name, description, Math.round(parseFloat(price) * 100), req.params.id]
|
||||
);
|
||||
// Only touch the category when the field was actually submitted, so a
|
||||
// caller that omits it doesn't silently uncategorize the item.
|
||||
if (categoryId !== undefined) {
|
||||
await client.query(`UPDATE items SET category_id=$1 WHERE id=$2`, [categoryId, itemId]);
|
||||
await client.query(`UPDATE items SET category_id=$1 WHERE id=$2`, [categoryId, req.params.id]);
|
||||
}
|
||||
if (tagNames) {
|
||||
await setItemTags(client, itemId, await resolveTagIds(client, tagNames));
|
||||
await setItemTags(client, Number(req.params.id), await resolveTagIds(client, tagNames));
|
||||
}
|
||||
if (files.length) {
|
||||
const { rows: existing } = await client.query<MaxSortRow>(
|
||||
const { rows: existing } = await client.query(
|
||||
`SELECT COALESCE(MAX(sort_order), -1) AS max_sort FROM item_images WHERE item_id = $1`,
|
||||
[itemId]
|
||||
[req.params.id]
|
||||
);
|
||||
// COALESCE'd MAX, so the aggregate always returns exactly one row.
|
||||
const nextSort = requireRow(existing, 'the MAX(sort_order) aggregate').max_sort + 1;
|
||||
// Number(), as the setItemTags call above already does: a matched route
|
||||
// always has this param, but noUncheckedIndexedAccess cannot know that,
|
||||
// and the helper's typed parameter surfaces what the old inline query's
|
||||
// unknown[] hid.
|
||||
await insertItemImages(client, itemId, files, nextSort);
|
||||
let nextSort = existing[0].max_sort + 1;
|
||||
for (const file of files) {
|
||||
await client.query(
|
||||
`INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, $2, $3)`,
|
||||
[req.params.id, `/uploads/${file.filename}`, nextSort++]
|
||||
);
|
||||
}
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
// The same constant as the create route above. itemId is caller-controlled
|
||||
// and goes through the driver as a bound parameter; it never reaches the
|
||||
// query text.
|
||||
const full = await adminItemQuery().where('i.id', '=', itemId).execute();
|
||||
// The create route beside this one has always used requireRow here. This
|
||||
// one did not, so an UPDATE matching nothing committed happily, the SELECT
|
||||
// returned nothing, and the caller got 200 with an empty body — a success
|
||||
// it could do nothing with, and no record anywhere that the item was
|
||||
// missing. See #207.
|
||||
const updated = full[0];
|
||||
if (!updated) return res.status(404).json({ error: 'not found' });
|
||||
res.json(updated);
|
||||
const { rows: full } = await pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [req.params.id]);
|
||||
res.json(full[0]);
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(err);
|
||||
@@ -243,14 +236,7 @@ router.put('/items/:id', uploadImages, asyncRoute(async (req: Request, res: Resp
|
||||
}));
|
||||
|
||||
router.delete('/items/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||
// 404 rather than the 500 a raw Number() produced: 'abc' became NaN, reached
|
||||
// Postgres as the text "NaN", raised 22P02 on an integer column and told the
|
||||
// caller the server had broken. An item that cannot exist is not found (#207).
|
||||
//
|
||||
// A well-formed but absent id still answers 204. DELETE is idempotent and the
|
||||
// caller's intent — that the item should not exist — is satisfied either way.
|
||||
const itemId = readId(req.params.id);
|
||||
if (itemId === null) return res.status(404).json({ error: 'not found' });
|
||||
const itemId = Number(req.params.id);
|
||||
|
||||
// Collected before the delete: favorites cascade with the item, so after it
|
||||
// is gone there is no record of who was watching. Restricted to unsold items
|
||||
@@ -261,208 +247,33 @@ router.delete('/items/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||
|
||||
// Sent only once the delete has succeeded, so nobody hears about a withdrawal
|
||||
// that did not happen.
|
||||
await notifyFavoritersOfRemoval(recipients);
|
||||
notifyFavoritersOfRemoval(recipients);
|
||||
res.status(204).end();
|
||||
}));
|
||||
|
||||
router.delete('/items/:id/images/:imageId', asyncRoute(async (req: Request, res: Response) => {
|
||||
// Both ids, not just the first. A route carrying two of them can guard one
|
||||
// and forget the other, and the forgotten one fails exactly as loudly (#207).
|
||||
const itemId = readId(req.params.id);
|
||||
const imageId = readId(req.params.imageId);
|
||||
if (itemId === null || imageId === null) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
await pool.query(`DELETE FROM item_images WHERE id = $1 AND item_id = $2`, [imageId, itemId]);
|
||||
await pool.query(`DELETE FROM item_images WHERE id = $1 AND item_id = $2`, [req.params.imageId, req.params.id]);
|
||||
res.status(204).end();
|
||||
}));
|
||||
|
||||
router.post('/items/:id/mark-sold', asyncRoute(async (req: Request, res: Response) => {
|
||||
const itemId = readId(req.params.id);
|
||||
if (itemId === null) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const { rows } = await pool.query<ItemRecord>(
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE items SET status='sold', sold_at=now() WHERE id=$1 RETURNING *`,
|
||||
[itemId]
|
||||
[req.params.id]
|
||||
);
|
||||
const sold = rows[0];
|
||||
if (!sold) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
// No buyer to exclude: an admin marking an item sold has no associated
|
||||
// customer, so everyone watching it hears about it. Sent only after the row
|
||||
// is known to exist, so nobody is told about a sale that did not happen.
|
||||
await notifyFavoritersOfSale([sold.id], null);
|
||||
res.json(sold);
|
||||
}));
|
||||
|
||||
// Publishing is the existing mark-available: it already sets status='available'
|
||||
// and clears sold_at, reserved_until and paypal_order_id, all of which are
|
||||
// no-ops on a pending item. A second endpoint running the same UPDATE would be
|
||||
// duplication, so the admin UI labels that button "Publish" when the item is
|
||||
// pending. This is the reverse, and it is not symmetrical — see the guard.
|
||||
router.post('/items/:id/unpublish', asyncRoute(async (req: Request, res: Response) => {
|
||||
// Guarded before the lookup, so a malformed id is 404 rather than the 500 the
|
||||
// raw string produced at Postgres. The absent case below was already right;
|
||||
// only the unreadable one was not (#207).
|
||||
const itemId = readId(req.params.id);
|
||||
if (itemId === null) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const { rows } = await pool.query<ItemStatusRow>(`SELECT status FROM items WHERE id = $1`, [itemId]);
|
||||
if (!rows.length) {
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
|
||||
const status = requireRow(rows, 'the item status lookup').status;
|
||||
if (status === 'pending') {
|
||||
return res.status(400).json({ error: 'this item is already pending' });
|
||||
}
|
||||
// Reserved and sold are not drafts. A reserved item is in someone's cart
|
||||
// right now and hiding it would strand them mid-checkout; a sold item is a
|
||||
// record of something that happened, and pulling it back would quietly
|
||||
// rewrite that. Both are refused by name so the reason is on screen rather
|
||||
// than left to be guessed from a generic error.
|
||||
if (status === 'reserved') {
|
||||
return res.status(400).json({ error: 'a customer is holding this item — it cannot be unpublished' });
|
||||
}
|
||||
if (status === 'sold') {
|
||||
return res.status(400).json({ error: 'a sold item cannot be unpublished' });
|
||||
}
|
||||
|
||||
const { rows: updated } = await pool.query<ItemRecord>(
|
||||
`UPDATE items SET status='pending' WHERE id=$1 RETURNING *`,
|
||||
[itemId]
|
||||
);
|
||||
res.json(updated[0]);
|
||||
// customer, so everyone watching it hears about it.
|
||||
await notifyFavoritersOfSale([Number(req.params.id)], null);
|
||||
res.json(rows[0]);
|
||||
}));
|
||||
|
||||
router.post('/items/:id/mark-available', asyncRoute(async (req: Request, res: Response) => {
|
||||
const itemId = readId(req.params.id);
|
||||
if (itemId === null) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const { rows } = await pool.query<ItemRecord>(
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE items SET status='available', sold_at=NULL, reserved_until=NULL, paypal_order_id=NULL
|
||||
WHERE id=$1 RETURNING *`,
|
||||
[itemId]
|
||||
[req.params.id]
|
||||
);
|
||||
const available = rows[0];
|
||||
if (!available) return res.status(404).json({ error: 'not found' });
|
||||
res.json(available);
|
||||
res.json(rows[0]);
|
||||
}));
|
||||
|
||||
/**
|
||||
* Whether an item with this id exists.
|
||||
*
|
||||
* Checked before acting so an absent item is a 404 rather than a cheerful
|
||||
* summary of nothing. `removeBackgroundsForItem` would happily report
|
||||
* `total: 0` for an id that was never an item, which is true and useless.
|
||||
*/
|
||||
async function itemExists(itemId: number): Promise<boolean> {
|
||||
const { rows } = await pool.query(`SELECT 1 FROM items WHERE id = $1`, [itemId]);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the background from every photo of one item.
|
||||
*
|
||||
* Per item rather than per photo because an upload is one item: the front, the
|
||||
* back and the chipped base are three views of one thing, not three things to
|
||||
* cut out separately (#293).
|
||||
*
|
||||
* Answers 200 once the id is valid, even when the sidecar fails. Unlike the
|
||||
* per-photo endpoints in #281, this acts on several images, so "did it work"
|
||||
* has no single answer — two of four is the normal shape of a bad day here.
|
||||
* A 502 would throw away the count, which is the only thing that makes the
|
||||
* outcome actionable. Non-200 is reserved for not being able to try at all.
|
||||
*
|
||||
* No status check. A sold item's photos are still the shop's photos, and
|
||||
* improving them changes nothing about the sale — the guards on `unpublish`
|
||||
* protect a checkout in progress and a completed sale, neither of which is at
|
||||
* stake in a photograph's background.
|
||||
*/
|
||||
router.post('/items/:id/remove-backgrounds', asyncRoute(async (req: Request, res: Response) => {
|
||||
const itemId = readId(req.params.id);
|
||||
if (itemId === null || !(await itemExists(itemId))) {
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
|
||||
res.json(await removeBackgroundsForItem(itemId));
|
||||
}));
|
||||
|
||||
/**
|
||||
* Put every original back.
|
||||
*
|
||||
* The reason removing is safe to try. Photos that were never cut out are
|
||||
* skipped rather than refused, so a half-done item — what a partial failure
|
||||
* leaves behind — is restorable too.
|
||||
*
|
||||
* Answers 200 once the id is valid, same as remove-backgrounds and for the
|
||||
* same reason: `restoreOriginalsForItem` stops at the first genuine failure
|
||||
* rather than throwing, so there is always a summary to return, never a bare
|
||||
* 500 that discards how far it got.
|
||||
*/
|
||||
router.post('/items/:id/restore-originals', asyncRoute(async (req: Request, res: Response) => {
|
||||
const itemId = readId(req.params.id);
|
||||
if (itemId === null || !(await itemExists(itemId))) {
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
|
||||
res.json(await restoreOriginalsForItem(itemId));
|
||||
}));
|
||||
|
||||
/**
|
||||
* Turn one photo a quarter turn.
|
||||
*
|
||||
* On the item rather than on the draft, which is the decision that makes the
|
||||
* inventory editor free later: an image belongs to an item whether or not a
|
||||
* draft row exists, so the second screen to want this is the same call from a
|
||||
* different place, with no new backend at all.
|
||||
*
|
||||
* Unlike the per-item background endpoints, this acts on exactly one file, so
|
||||
* it can honestly answer whether it worked. 204 rather than 200 because
|
||||
* rotation changes no column — the paths are identical afterwards and only the
|
||||
* bytes differ, so there is no row worth returning, which is also why
|
||||
* DELETE /items/:id/images/:imageId is a 204.
|
||||
*
|
||||
* A factory rather than two copied handlers: the direction is the only thing
|
||||
* that differs. Two paths rather than one endpoint taking a direction in the
|
||||
* body matches how remove-background and restore-original are already spelled.
|
||||
*/
|
||||
function rotationRoute(direction: RotateDirection) {
|
||||
return asyncRoute(async (req: Request, res: Response) => {
|
||||
// Both ids, not just the first. A route carrying two of them can guard one
|
||||
// and forget the other, and the forgotten one fails as a 500 rather than
|
||||
// the 404 that "no such photo" actually means (#207).
|
||||
const itemId = readId(req.params.id);
|
||||
const imageId = readId(req.params.imageId);
|
||||
if (itemId === null || imageId === null) {
|
||||
return res.status(404).json({ error: 'no such photo on this item' });
|
||||
}
|
||||
|
||||
try {
|
||||
await rotateItemImage(itemId, imageId, direction);
|
||||
} catch (err) {
|
||||
// Only "not on this item" is a 404, and it is indistinguishable from an
|
||||
// absent one on purpose: an image id is a serial, and confirming which
|
||||
// ids exist is not something this endpoint should do. Everything else is
|
||||
// a real fault and stays loud — the file is untouched in every one of
|
||||
// those cases, because rotateInPlace renames over the original only once
|
||||
// the new file has been written successfully.
|
||||
if (err instanceof ImageNotOnItemError) {
|
||||
return res.status(404).json({ error: 'no such photo on this item' });
|
||||
}
|
||||
console.error(`[rotation] item ${itemId}, image ${imageId}:`, err);
|
||||
return res.status(500).json({
|
||||
error:
|
||||
err instanceof Error
|
||||
? `this photo could not be rotated: ${err.message}`
|
||||
: 'this photo could not be rotated'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(204).end();
|
||||
});
|
||||
}
|
||||
|
||||
router.post('/items/:id/images/:imageId/rotate-left', rotationRoute('left'));
|
||||
router.post('/items/:id/images/:imageId/rotate-right', rotationRoute('right'));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,65 +1,18 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { sql } from 'kysely';
|
||||
import { db, requireRow } from '../db';
|
||||
import { pool } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
|
||||
/**
|
||||
* The one file using the builder (#218, reconverted for Kysely in #305), chosen
|
||||
* because it is awkward rather than because it is easy — a recursive CTE, a
|
||||
* correlated count, and an array match.
|
||||
*
|
||||
* The pool is still available and most of the application still uses it. This
|
||||
* is one file converted, not a cutover. See src/db-kysely/CONVENTIONS.md.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The four columns this API answers with, named once.
|
||||
*
|
||||
* Under Drizzle this was a mapping — `{ parent_id: categories.parentId }` — and
|
||||
* it existed because the generated mirror was camelCase while this API answers
|
||||
* snake_case, so selecting the table directly changed the JSON contract with no
|
||||
* test noticing. The generated types now carry the database's own names, so
|
||||
* there is nothing left to translate and this is just a list of columns four
|
||||
* selects happen to share.
|
||||
*/
|
||||
const CATEGORY_COLUMNS = ['id', 'name', 'parent_id', 'sort_order'] as const;
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Postgres unique-violation SQLSTATE — raised by the two partial indexes that
|
||||
// stop siblings sharing a name.
|
||||
const UNIQUE_VIOLATION = '23505';
|
||||
|
||||
/**
|
||||
* Whether a thrown error is that unique violation.
|
||||
*
|
||||
* Both shapes are accepted deliberately. Drizzle wrapped driver errors, moving
|
||||
* this SQLSTATE from `err.code` to `err.cause.code`, and the check that only
|
||||
* looked at `err.code` still compiled, never matched, and turned two 409s into
|
||||
* 500s — a conversion hazard with no type error behind it. Kysely uses the `pg`
|
||||
* driver directly and is expected to leave it on `err.code`, but "expected" is
|
||||
* the word that caused the bug last time, so the tolerant check stays and an
|
||||
* integration test proves the 409 rather than assuming it. See #218, #305.
|
||||
*/
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
const direct = (err as { code?: string }).code;
|
||||
const wrapped = (err as { cause?: { code?: string } }).cause?.code;
|
||||
return direct === UNIQUE_VIOLATION || wrapped === UNIQUE_VIOLATION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks down from a node, collecting it and every descendant. Used both for
|
||||
* cycle detection on reparent and for reporting the blast radius of a delete.
|
||||
*
|
||||
* Still a `sql` template: the CTE is recursive and is consumed in two different
|
||||
* shapes, and expressing it through the builder buys nothing over SQL that is
|
||||
* already correct and reviewed. The important part is that `${id}` is a bind
|
||||
* parameter, not text — there is no way to spell string interpolation in this
|
||||
* template by accident, which is the property the whole adoption is for.
|
||||
*/
|
||||
const subtreeOf = (id: number) => sql`
|
||||
// Walks down from a node, collecting it and every descendant. Used both for
|
||||
// cycle detection on reparent and for reporting the blast radius of a delete.
|
||||
const SUBTREE_CTE = `
|
||||
WITH RECURSIVE subtree AS (
|
||||
SELECT id FROM categories WHERE id = ${id}
|
||||
SELECT id FROM categories WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id
|
||||
)`;
|
||||
@@ -80,33 +33,17 @@ function readParentId(value: unknown): number | null | undefined {
|
||||
}
|
||||
|
||||
async function parentExists(id: number): Promise<boolean> {
|
||||
const row = await db
|
||||
.selectFrom('categories')
|
||||
.select('id')
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst();
|
||||
return row !== undefined;
|
||||
const { rows } = await pool.query(`SELECT 1 FROM categories WHERE id = $1`, [id]);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
|
||||
const rows = await db
|
||||
.selectFrom('categories')
|
||||
.select(CATEGORY_COLUMNS)
|
||||
// Literal text rather than interpolated column references, and here that is
|
||||
// a free choice rather than a workaround: the fragment binds no values, so
|
||||
// there is nothing to parameterize. Under Drizzle this had to be literal,
|
||||
// because interpolating the columns rendered them unqualified and Postgres
|
||||
// resolved both sides against items, answering with a plausible wrong
|
||||
// number rather than an error (#218).
|
||||
.select(
|
||||
sql<number>`(SELECT COUNT(*)::int FROM items WHERE items.category_id = categories.id)`.as(
|
||||
'item_count'
|
||||
)
|
||||
)
|
||||
.orderBy('sort_order')
|
||||
.orderBy(sql`lower(categories.name)`)
|
||||
.execute();
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT c.id, c.name, c.parent_id, c.sort_order,
|
||||
(SELECT COUNT(*)::int FROM items i WHERE i.category_id = c.id) AS item_count
|
||||
FROM categories c
|
||||
ORDER BY c.sort_order, lower(c.name)`
|
||||
);
|
||||
res.json(rows);
|
||||
}));
|
||||
|
||||
@@ -128,72 +65,28 @@ router.post('/', asyncRoute(async (req: Request, res: Response) => {
|
||||
const sortOrder = Number.isSafeInteger(req.body.sort_order) ? req.body.sort_order : 0;
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.insertInto('categories')
|
||||
.values({ name, parent_id: parent, sort_order: sortOrder })
|
||||
.returning(CATEGORY_COLUMNS)
|
||||
.execute();
|
||||
|
||||
res.status(201).json({ ...requireRow(rows, 'the category INSERT'), item_count: 0 });
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO categories (name, parent_id, sort_order) VALUES ($1, $2, $3)
|
||||
RETURNING id, name, parent_id, sort_order`,
|
||||
[name, parent, sortOrder]
|
||||
);
|
||||
res.status(201).json({ ...rows[0], item_count: 0 });
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) {
|
||||
if ((err as { code?: string }).code === UNIQUE_VIOLATION) {
|
||||
return res.status(409).json({ error: 'a category with that name already exists here' });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}));
|
||||
|
||||
// Works out what parent_id an update should land on. Absent means "leave it
|
||||
// alone", so the current value is echoed back rather than treated as a clear.
|
||||
// Returns the refusal instead of sending it, keeping the response the
|
||||
// handler's business and the two ways a parent can be invalid out of its body.
|
||||
type ParentResolution = { error: string } | { parent: number | null };
|
||||
|
||||
async function resolveParentId(
|
||||
submitted: unknown,
|
||||
id: number,
|
||||
current: number | null
|
||||
): Promise<ParentResolution> {
|
||||
if (submitted === undefined) {
|
||||
return { parent: current };
|
||||
}
|
||||
|
||||
const parsed = readParentId(submitted);
|
||||
if (parsed === undefined) {
|
||||
return { error: 'invalid parent_id' };
|
||||
}
|
||||
if (parsed === null) {
|
||||
return { parent: null };
|
||||
}
|
||||
if (!(await parentExists(parsed))) {
|
||||
return { error: 'parent category does not exist' };
|
||||
}
|
||||
|
||||
// Moving a node beneath itself or one of its own descendants would detach
|
||||
// that whole branch from the tree into an unreachable cycle.
|
||||
const cycle = await sql<{ found: number }>`
|
||||
${subtreeOf(id)} SELECT 1 AS found FROM subtree WHERE id = ${parsed}
|
||||
`.execute(db);
|
||||
if (cycle.rows.length) {
|
||||
return { error: 'a category cannot be moved beneath itself' };
|
||||
}
|
||||
|
||||
return { parent: parsed };
|
||||
}
|
||||
|
||||
router.put('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
const current = await db
|
||||
.selectFrom('categories')
|
||||
.select(CATEGORY_COLUMNS)
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!current) {
|
||||
const existing = await pool.query(`SELECT id, name, parent_id, sort_order FROM categories WHERE id = $1`, [id]);
|
||||
if (!existing.rows.length) {
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
|
||||
let name = current.name;
|
||||
let name = existing.rows[0].name;
|
||||
if (req.body.name !== undefined) {
|
||||
const parsed = readName(req.body.name);
|
||||
if (!parsed) {
|
||||
@@ -202,27 +95,42 @@ router.put('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||
name = parsed;
|
||||
}
|
||||
|
||||
const resolved = await resolveParentId(req.body.parent_id, id, current.parent_id);
|
||||
if ('error' in resolved) {
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
let parent = existing.rows[0].parent_id;
|
||||
if (req.body.parent_id !== undefined) {
|
||||
const parsed = readParentId(req.body.parent_id);
|
||||
if (parsed === undefined) {
|
||||
return res.status(400).json({ error: 'invalid parent_id' });
|
||||
}
|
||||
if (parsed !== null) {
|
||||
if (!(await parentExists(parsed))) {
|
||||
return res.status(400).json({ error: 'parent category does not exist' });
|
||||
}
|
||||
// Moving a node beneath itself or one of its own descendants would
|
||||
// detach that whole branch from the tree into an unreachable cycle.
|
||||
const { rows: cycle } = await pool.query(
|
||||
`${SUBTREE_CTE} SELECT 1 FROM subtree WHERE id = $2`,
|
||||
[id, parsed]
|
||||
);
|
||||
if (cycle.length) {
|
||||
return res.status(400).json({ error: 'a category cannot be moved beneath itself' });
|
||||
}
|
||||
}
|
||||
parent = parsed;
|
||||
}
|
||||
const parent = resolved.parent;
|
||||
|
||||
const sortOrder = Number.isSafeInteger(req.body.sort_order)
|
||||
? req.body.sort_order
|
||||
: current.sort_order;
|
||||
: existing.rows[0].sort_order;
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.updateTable('categories')
|
||||
.set({ name, parent_id: parent, sort_order: sortOrder })
|
||||
.where('id', '=', id)
|
||||
.returning(CATEGORY_COLUMNS)
|
||||
.execute();
|
||||
|
||||
res.json(requireRow(rows, 'the category UPDATE'));
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE categories SET name = $1, parent_id = $2, sort_order = $3 WHERE id = $4
|
||||
RETURNING id, name, parent_id, sort_order`,
|
||||
[name, parent, sortOrder, id]
|
||||
);
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) {
|
||||
if ((err as { code?: string }).code === UNIQUE_VIOLATION) {
|
||||
return res.status(409).json({ error: 'a category with that name already exists here' });
|
||||
}
|
||||
throw err;
|
||||
@@ -231,34 +139,22 @@ router.put('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||
|
||||
router.delete('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
|
||||
const subtree = await sql<{ id: number }>`
|
||||
${subtreeOf(id)} SELECT id FROM subtree
|
||||
`.execute(db);
|
||||
if (!subtree.rows.length) {
|
||||
const { rows: subtree } = await pool.query(`${SUBTREE_CTE} SELECT id FROM subtree`, [id]);
|
||||
if (!subtree.length) {
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
|
||||
const ids = subtree.rows.map((row) => row.id);
|
||||
|
||||
// `in` rather than the ANY(...::int[]) this replaced. Kysely emits the
|
||||
// placeholder list itself, so it is correct by construction and there is no
|
||||
// template to forget anything in. `ids` is never empty — the length check
|
||||
// above returned already if it were.
|
||||
const affected = await db
|
||||
.selectFrom('items')
|
||||
.select(sql<number>`COUNT(*)::int`.as('n'))
|
||||
.where('category_id', 'in', ids)
|
||||
.execute();
|
||||
const ids = subtree.map((row: { id: number }) => row.id);
|
||||
const { rows: affected } = await pool.query(
|
||||
`SELECT COUNT(*)::int AS n FROM items WHERE category_id = ANY($1::int[])`,
|
||||
[ids]
|
||||
);
|
||||
|
||||
// The FK cascade takes the descendants; items fall back to NULL rather than
|
||||
// being deleted along with their category.
|
||||
await db.deleteFrom('categories').where('id', '=', id).execute();
|
||||
await pool.query(`DELETE FROM categories WHERE id = $1`, [id]);
|
||||
|
||||
res.json({
|
||||
deleted_categories: ids.length,
|
||||
uncategorized_items: requireRow(affected, 'the affected-items COUNT').n
|
||||
});
|
||||
res.json({ deleted_categories: ids.length, uncategorized_items: affected[0].n });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { isRembgConfigured } from '../intake/rembgClient';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* What the admin screens can offer in this environment.
|
||||
*
|
||||
* Behind `requireAdminGate` like every other admin router, and deliberately not
|
||||
* folded into `/api/config` — the same reasoning `adminVersion.ts` records.
|
||||
* That endpoint is public and the storefront fetches it on every load; nothing
|
||||
* here is any of a customer's business.
|
||||
*
|
||||
* It exists because the inventory screen has no other way to learn this.
|
||||
* `GET /api/admin/item-drafts` carries the flag for the review queue, but
|
||||
* `GET /api/admin/items` answers a bare array with several consumers, and
|
||||
* changing its shape for one boolean would be a worse trade than one small
|
||||
* route.
|
||||
*
|
||||
* Not wrapped in `asyncRoute` because the handler is synchronous: it reads an
|
||||
* environment variable, so there is no promise to reject.
|
||||
*/
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
res.json({ backgroundRemoval: isRembgConfigured() });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,94 +1,13 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool, requireRow } from '../db';
|
||||
import { pool } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { isValidEmail, readId } from '../utils';
|
||||
import { sendMail } from '../mailer';
|
||||
import { renderTemplate, greeting } from '../emailTemplates';
|
||||
import { getSettings } from '../adminSettings';
|
||||
import { loadStoredTemplate } from './adminEmailTemplates';
|
||||
import { issueVerificationEmail } from '../customerVerification';
|
||||
|
||||
/**
|
||||
* Row shapes for the reads here, kept in step with their SQL by hand.
|
||||
*
|
||||
* NOTE ON THE AGGREGATES. Postgres returns COUNT as bigint and SUM as numeric,
|
||||
* and node-postgres hands both back as **strings** — only an explicit ::int cast
|
||||
* comes back as a number. So order_count and total_spent_cents are strings while
|
||||
* reserved_count, which is cast, is a number. Verified against the database
|
||||
* rather than assumed.
|
||||
*
|
||||
* The admin UI declares both as `number` and survives on coercion: `a - b` and
|
||||
* `v / 100` both coerce a numeric string. The first `+` written against them
|
||||
* will concatenate instead. Typed honestly here so the mismatch is visible
|
||||
* rather than inherited.
|
||||
*/
|
||||
interface CustomerListRow {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
email_verified: boolean;
|
||||
marketing_consent: boolean;
|
||||
created_at: Date;
|
||||
disabled_at: Date | null;
|
||||
order_count: string;
|
||||
total_spent_cents: string;
|
||||
last_order_at: Date | null;
|
||||
reserved_count: number;
|
||||
}
|
||||
|
||||
interface CustomerDetailRow {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
email_verified: boolean;
|
||||
marketing_consent: boolean;
|
||||
marketing_consent_at: Date | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
interface AdminOrderRow {
|
||||
id: number;
|
||||
processor: string;
|
||||
processor_order_id: string | null;
|
||||
amount_cents: number | null;
|
||||
status: string | null;
|
||||
created_at: Date;
|
||||
item_name: string;
|
||||
}
|
||||
|
||||
interface ReservedItemRow {
|
||||
item_id: number;
|
||||
name: string;
|
||||
price_cents: number;
|
||||
added_at: Date;
|
||||
expires_at: Date;
|
||||
}
|
||||
|
||||
/** What the cart-clearing DELETEs return, so the caller can release the items. */
|
||||
interface HeldItemRow {
|
||||
item_id: number;
|
||||
}
|
||||
|
||||
interface IdRow {
|
||||
id: number;
|
||||
}
|
||||
|
||||
/** One recorded admin-initiated address change (#337). */
|
||||
interface EmailChangeRow {
|
||||
id: number;
|
||||
previous_email: string;
|
||||
new_email: string;
|
||||
reason: string;
|
||||
changed_at: Date;
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
|
||||
const { rows } = await pool.query<CustomerListRow>(`
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
c.id, c.email, nullif(btrim(concat_ws(' ', c.first_name, c.last_name)), '') AS name,
|
||||
c.email_verified, c.marketing_consent, c.created_at, c.disabled_at,
|
||||
c.id, c.email, c.name, c.email_verified, c.marketing_consent, c.created_at, c.disabled_at,
|
||||
COUNT(o.id) FILTER (WHERE o.status = 'completed') AS order_count,
|
||||
COALESCE(SUM(o.amount_cents) FILTER (WHERE o.status = 'completed'), 0) AS total_spent_cents,
|
||||
MAX(o.created_at) AS last_order_at,
|
||||
@@ -119,7 +38,7 @@ router.post('/:id/disable', asyncRoute(async (req: Request, res: Response) => {
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const { rows } = await client.query<IdRow>(
|
||||
const { rows } = await client.query(
|
||||
`UPDATE customers SET disabled_at = now() WHERE id = $1 RETURNING id`,
|
||||
[req.params.id]
|
||||
);
|
||||
@@ -136,7 +55,7 @@ router.post('/:id/disable', asyncRoute(async (req: Request, res: Response) => {
|
||||
// A disabled account cannot check out, so holding one-of-a-kind stock off
|
||||
// the storefront until the expiry sweep serves nobody. Guarded on
|
||||
// 'reserved' so a sold item is never resurrected.
|
||||
const { rows: held } = await client.query<HeldItemRow>(
|
||||
const { rows: held } = await client.query(
|
||||
`DELETE FROM cart_items ci
|
||||
USING carts ca
|
||||
WHERE ci.cart_id = ca.id AND ca.customer_id = $1
|
||||
@@ -165,7 +84,7 @@ router.post('/:id/disable', asyncRoute(async (req: Request, res: Response) => {
|
||||
// well have been sold to someone else in the meantime, and silently re-reserving
|
||||
// them would be worse than making the customer add them again.
|
||||
router.post('/:id/enable', asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query<IdRow>(
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE customers SET disabled_at = NULL WHERE id = $1 RETURNING id`,
|
||||
[req.params.id]
|
||||
);
|
||||
@@ -174,7 +93,7 @@ router.post('/:id/enable', asyncRoute(async (req: Request, res: Response) => {
|
||||
}));
|
||||
|
||||
router.get('/:id/reserved', asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query<ReservedItemRow>(
|
||||
const { rows } = await pool.query(
|
||||
`SELECT i.id AS item_id, i.name, i.price_cents, ci.added_at, ci.expires_at
|
||||
FROM cart_items ci
|
||||
JOIN carts ca ON ca.id = ci.cart_id
|
||||
@@ -193,7 +112,7 @@ router.post('/:id/reserved/:itemId/release', asyncRoute(async (req: Request, res
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query<HeldItemRow>(
|
||||
const { rows } = await client.query(
|
||||
`DELETE FROM cart_items ci
|
||||
USING carts ca
|
||||
WHERE ci.cart_id = ca.id AND ca.customer_id = $1 AND ci.item_id = $2
|
||||
@@ -220,183 +139,15 @@ router.post('/:id/reserved/:itemId/release', asyncRoute(async (req: Request, res
|
||||
}
|
||||
}));
|
||||
|
||||
/** The reason the operator typed, or null if it is not usable as one. */
|
||||
function readReason(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
// A length floor rather than merely non-empty. The record exists to
|
||||
// distinguish a verified recovery from a takeover afterwards, and "ok" cannot
|
||||
// do that — but no floor high enough to be gamed is worth having either, so
|
||||
// this asks for a sentence and trusts the person writing it.
|
||||
if (trimmed.length < 10) return null;
|
||||
// Bounded because it is free text going into a TEXT column from a form.
|
||||
return trimmed.slice(0, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moving an account to an address its owner can actually reach (#337).
|
||||
*
|
||||
* This is the third step of the only recovery route a customer who has lost
|
||||
* their mailbox has, and there is deliberately no self-service equivalent: the
|
||||
* email address is the root of trust for every other route, this shop holds no
|
||||
* second proof of identity, and anything invented to fill that gap would be a
|
||||
* weaker credential than the one it replaced. So the route is manual, and
|
||||
* `docs/ops/account-recovery.md` describes the verification that has to happen
|
||||
* before this endpoint is called.
|
||||
*
|
||||
* The uncomfortable part, stated plainly: this operation and an account takeover
|
||||
* are the same operation. They differ only in whether the verification was
|
||||
* sound, and nothing here can check that. What this can do is make the change
|
||||
* recorded, announced, and reversible in its effects — which is what everything
|
||||
* below is for.
|
||||
*
|
||||
* No current-password check, unlike the customer's own change. There is no
|
||||
* password to ask for; the whole premise is that the person asking cannot prove
|
||||
* anything the system can verify. The admin gate is the only authorisation, and
|
||||
* the operator's judgement is the only verification.
|
||||
*/
|
||||
router.put('/:id/email', asyncRoute(async (req: Request, res: Response) => {
|
||||
const id = readId(req.params.id);
|
||||
if (id === null) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const { email, reason } = req.body ?? {};
|
||||
|
||||
const normalized = String(email ?? '').toLowerCase().trim();
|
||||
if (!normalized || !isValidEmail(normalized)) {
|
||||
return res.status(400).json({ error: 'a valid email is required' });
|
||||
}
|
||||
|
||||
const stated = readReason(reason);
|
||||
if (stated === null) {
|
||||
return res.status(400).json({
|
||||
error: 'say why this account is being moved — a sentence naming how the customer was verified'
|
||||
});
|
||||
}
|
||||
|
||||
const { rows } = await pool.query<{ id: number; email: string; first_name: string | null; last_name: string | null }>(
|
||||
`SELECT id, email, first_name, last_name FROM customers WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
const customer = rows[0];
|
||||
if (!customer) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
if (normalized === customer.email) {
|
||||
return res.status(400).json({ error: 'that is already this customer’s email address' });
|
||||
}
|
||||
|
||||
const { rows: taken } = await pool.query<IdRow>(`SELECT id FROM customers WHERE email = $1`, [normalized]);
|
||||
if (taken.length) {
|
||||
return res.status(409).json({ error: 'another account already uses this email address' });
|
||||
}
|
||||
|
||||
// Captured before the update, because it is where the notice has to go and
|
||||
// the row will not be able to answer for it a moment from now.
|
||||
const previousEmail = customer.email;
|
||||
|
||||
let passkeysRemoved = 0;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
await client.query(
|
||||
// Unverified, exactly as the self-service change leaves it. Nobody has
|
||||
// demonstrated receiving mail at this address yet — a customer describing
|
||||
// it over the phone is not that, and it is the commonest way this goes
|
||||
// wrong harmlessly.
|
||||
`UPDATE customers SET email = $1, email_verified = false WHERE id = $2`,
|
||||
[normalized, id]
|
||||
);
|
||||
|
||||
// Everything the previous holder of this account had, on the reasoning #42
|
||||
// settled for password reset. An account being moved to a recovered address
|
||||
// is in the same position as one being recovered by reset, and the same
|
||||
// argument applies with more force: here somebody the system cannot
|
||||
// identify has asked for the change, so a session or a credential surviving
|
||||
// it would be one the new owner cannot see and cannot revoke.
|
||||
await client.query(`DELETE FROM customer_sessions WHERE customer_id = $1`, [id]);
|
||||
const removed = await client.query(`DELETE FROM customer_credentials WHERE customer_id = $1`, [id]);
|
||||
passkeysRemoved = removed.rowCount ?? 0;
|
||||
await client.query(`DELETE FROM webauthn_challenges WHERE customer_id = $1`, [id]);
|
||||
|
||||
// Reset links already sent are addressed to the old mailbox, which is the
|
||||
// one this change is taking away. Leaving them live would let whoever still
|
||||
// reads it take the account straight back.
|
||||
await client.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [id]);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO customer_email_changes (customer_id, previous_email, new_email, reason)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[id, previousEmail, normalized, stated]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
// Both sends happen after the row is written, never before, so a change that
|
||||
// failed cannot produce mail saying it succeeded.
|
||||
await issueVerificationEmail(id, normalized, customer.first_name, customer.last_name);
|
||||
|
||||
// To the address being replaced, which is the whole point. If the recovery
|
||||
// was sound this reaches nobody, and that costs nothing. If it was not, it
|
||||
// reaches the real owner — who is the only person who can say so, and the
|
||||
// only reason this endpoint is safe to have at all.
|
||||
const { greetingFormat, greetingFallback } = await getSettings();
|
||||
const notice = renderTemplate('emailChangedByAdmin', await loadStoredTemplate('emailChangedByAdmin'), {
|
||||
greeting: greeting(customer.first_name, greetingFormat, greetingFallback, customer.last_name),
|
||||
firstName: customer.first_name ?? '',
|
||||
lastName: customer.last_name ?? '',
|
||||
newEmail: normalized
|
||||
});
|
||||
sendMail(previousEmail, notice.subject, notice.html)
|
||||
.catch(err => console.error('admin email change notice send failed', err));
|
||||
|
||||
const { rows: updated } = await pool.query<CustomerDetailRow>(
|
||||
`SELECT id, email, nullif(btrim(concat_ws(' ', first_name, last_name)), '') AS name,
|
||||
email_verified, marketing_consent, marketing_consent_at, created_at
|
||||
FROM customers WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
customer: requireRow(updated, 'the customer after the admin email change'),
|
||||
previousEmail,
|
||||
// Reported so the operator can tell the customer what they will have to set
|
||||
// up again, and so a surprising number is visible at the moment it happens
|
||||
// rather than never.
|
||||
passkeysRemoved
|
||||
});
|
||||
}));
|
||||
|
||||
/** What has been done to this account's address, and why (#337). */
|
||||
router.get('/:id/email-changes', asyncRoute(async (req: Request, res: Response) => {
|
||||
const id = readId(req.params.id);
|
||||
if (id === null) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const { rows } = await pool.query<EmailChangeRow>(
|
||||
`SELECT id, previous_email, new_email, reason, changed_at
|
||||
FROM customer_email_changes
|
||||
WHERE customer_id = $1
|
||||
ORDER BY changed_at DESC`,
|
||||
[id]
|
||||
);
|
||||
res.json(rows);
|
||||
}));
|
||||
|
||||
router.get('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows: customerRows } = await pool.query<CustomerDetailRow>(
|
||||
`SELECT id, email, nullif(btrim(concat_ws(' ', first_name, last_name)), '') AS name,
|
||||
email_verified, marketing_consent, marketing_consent_at, created_at
|
||||
const { rows: customerRows } = await pool.query(
|
||||
`SELECT id, email, name, email_verified, marketing_consent, marketing_consent_at, created_at
|
||||
FROM customers WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!customerRows.length) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const { rows: orderRows } = await pool.query<AdminOrderRow>(
|
||||
const { rows: orderRows } = await pool.query(
|
||||
`SELECT o.id, o.processor, o.processor_order_id, o.amount_cents, o.status, o.created_at, i.name AS item_name
|
||||
FROM orders o JOIN items i ON i.id = o.item_id
|
||||
WHERE o.customer_id = $1
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import {
|
||||
TEMPLATES,
|
||||
TemplateKey,
|
||||
StoredTemplate,
|
||||
missingPlaceholders,
|
||||
renderTemplate,
|
||||
formatDuration,
|
||||
greeting,
|
||||
SAMPLE_VALUES
|
||||
} from '../emailTemplates';
|
||||
import { getSettings } from '../adminSettings';
|
||||
|
||||
/** A row of the admin_settings key/value store. */
|
||||
interface SettingRow {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
const KEYS = Object.keys(TEMPLATES) as TemplateKey[];
|
||||
|
||||
// Stored in admin_settings rather than a table of their own: it is already a
|
||||
// key/value store with a settled read/write shape, and five templates is not a
|
||||
// schema.
|
||||
const settingKey = (key: TemplateKey, part: 'subject' | 'body') => `email_${key}_${part}`;
|
||||
|
||||
function isTemplateKey(value: unknown): value is TemplateKey {
|
||||
return typeof value === 'string' && (KEYS as string[]).includes(value);
|
||||
}
|
||||
|
||||
export async function loadStoredTemplate(key: TemplateKey): Promise<StoredTemplate> {
|
||||
const { rows } = await pool.query<SettingRow>(`SELECT key, value FROM admin_settings WHERE key = ANY($1)`, [
|
||||
[settingKey(key, 'subject'), settingKey(key, 'body')]
|
||||
]);
|
||||
const stored: StoredTemplate = {};
|
||||
for (const row of rows) {
|
||||
if (row.key === settingKey(key, 'subject')) stored.subject = row.value;
|
||||
if (row.key === settingKey(key, 'body')) stored.body = row.value;
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
// Returns the definitions alongside whatever is stored, so the admin screen can
|
||||
// show the placeholders a template accepts and which of them it must keep,
|
||||
// rather than the editor having to know.
|
||||
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
|
||||
const { rows } = await pool.query<SettingRow>(
|
||||
`SELECT key, value FROM admin_settings WHERE key LIKE 'email\\_%'`
|
||||
);
|
||||
const stored = new Map<string, string>(rows.map((r) => [r.key, r.value]));
|
||||
|
||||
res.json(
|
||||
KEYS.map((key) => ({
|
||||
key,
|
||||
label: TEMPLATES[key].label,
|
||||
required: TEMPLATES[key].required,
|
||||
available: TEMPLATES[key].available,
|
||||
defaultSubject: TEMPLATES[key].defaultSubject,
|
||||
defaultBody: TEMPLATES[key].defaultBody,
|
||||
// Null rather than the default, so the admin can tell "not customised"
|
||||
// from "customised to exactly the default text".
|
||||
subject: stored.get(settingKey(key, 'subject')) ?? null,
|
||||
body: stored.get(settingKey(key, 'body')) ?? null
|
||||
}))
|
||||
);
|
||||
}));
|
||||
|
||||
// Renders what an email would look like, from the subject and body in the
|
||||
// editor rather than from what is stored — so an admin sees the effect of an
|
||||
// edit before committing to it.
|
||||
//
|
||||
// Rendered here rather than in the browser, deliberately. renderTemplate is the
|
||||
// only thing that turns this markdown into HTML, and markdown-it is configured
|
||||
// with html: false, which is what stops an admin putting script into a
|
||||
// customer's inbox. A second renderer in the frontend would be a second place
|
||||
// for that setting to be wrong, and a preview that differs from the mailer is
|
||||
// worse than no preview.
|
||||
//
|
||||
// Deliberately does not enforce required placeholders. Saving refuses a body
|
||||
// that dropped one; previewing it is how an admin sees what they have done.
|
||||
/**
|
||||
* The sample values, with the three duration placeholders replaced by what the
|
||||
* settings actually hold.
|
||||
*
|
||||
* The preview exists so an admin sees the email that will be sent. A duration
|
||||
* drawn from a static sample would show "one hour" while the setting said two,
|
||||
* which is the precise failure this placeholder was added to remove.
|
||||
*/
|
||||
async function previewValues(key: TemplateKey): Promise<Record<string, string>> {
|
||||
const {
|
||||
cartExpiryHours,
|
||||
verifyTokenHours,
|
||||
passwordResetHours,
|
||||
greetingFormat,
|
||||
greetingFallback
|
||||
} = await getSettings();
|
||||
// `expiresIn` names one placeholder but two different lifetimes, so the value
|
||||
// depends on which template is being previewed. The route knows the key.
|
||||
const expiresIn = key === 'passwordReset' ? passwordResetHours : verifyTokenHours;
|
||||
return {
|
||||
...SAMPLE_VALUES,
|
||||
holdDuration: formatDuration(cartExpiryHours),
|
||||
expiresIn: formatDuration(expiresIn),
|
||||
// Built from the configured format for the same reason as the durations:
|
||||
// the preview is meant to show the email that will be sent.
|
||||
greeting: greeting(SAMPLE_VALUES.firstName, greetingFormat, greetingFallback, SAMPLE_VALUES.lastName)
|
||||
};
|
||||
}
|
||||
|
||||
router.post('/:key/preview', asyncRoute(async (req: Request, res: Response) => {
|
||||
const key = req.params.key;
|
||||
if (!isTemplateKey(key)) {
|
||||
return res.status(404).json({ error: 'unknown template' });
|
||||
}
|
||||
|
||||
const { subject, body } = req.body ?? {};
|
||||
const rendered = renderTemplate(
|
||||
key,
|
||||
{
|
||||
subject: typeof subject === 'string' ? subject : null,
|
||||
body: typeof body === 'string' ? body : null
|
||||
},
|
||||
await previewValues(key)
|
||||
);
|
||||
|
||||
res.json(rendered);
|
||||
}));
|
||||
|
||||
router.put('/:key', asyncRoute(async (req: Request, res: Response) => {
|
||||
const key = req.params.key;
|
||||
if (!isTemplateKey(key)) {
|
||||
return res.status(404).json({ error: 'unknown template' });
|
||||
}
|
||||
|
||||
const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : '';
|
||||
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : '';
|
||||
|
||||
if (!subject) {
|
||||
return res.status(400).json({ error: 'a subject is required' });
|
||||
}
|
||||
if (!body) {
|
||||
return res.status(400).json({ error: 'a body is required' });
|
||||
}
|
||||
|
||||
// The rule that makes this feature safe rather than a way to break password
|
||||
// resets from a settings screen. A body without its link still sends, still
|
||||
// looks correct in the log, and is useless to everyone who receives it — so
|
||||
// the save is refused rather than warned about.
|
||||
const missing = missingPlaceholders(key, body);
|
||||
if (missing.length) {
|
||||
const named = missing.map((name) => '{{' + name + '}}').join(' and ');
|
||||
return res.status(400).json({ error: `the body must keep ${named}` });
|
||||
}
|
||||
|
||||
for (const [part, value] of [
|
||||
['subject', subject],
|
||||
['body', body]
|
||||
] as const) {
|
||||
await pool.query(
|
||||
`INSERT INTO admin_settings (key, value, updated_at) VALUES ($1, $2, now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = now()`,
|
||||
[settingKey(key, part), value]
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ key, subject, body });
|
||||
}));
|
||||
|
||||
// Restores the built-in copy by removing the stored rows, rather than by
|
||||
// writing the default into them — so "not customised" stays distinguishable
|
||||
// from "customised back to the original wording".
|
||||
router.delete('/:key', asyncRoute(async (req: Request, res: Response) => {
|
||||
const key = req.params.key;
|
||||
if (!isTemplateKey(key)) {
|
||||
return res.status(404).json({ error: 'unknown template' });
|
||||
}
|
||||
|
||||
await pool.query(`DELETE FROM admin_settings WHERE key = ANY($1)`, [
|
||||
[settingKey(key, 'subject'), settingKey(key, 'body')]
|
||||
]);
|
||||
|
||||
res.json({ key, subject: null, body: null });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
@@ -1,392 +0,0 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { draftQueued } from '../intake/draftingWorker';
|
||||
import { nextPriceSource, PriceSource } from '../intake/priceSource';
|
||||
import { readId } from '../utils';
|
||||
import {
|
||||
NoOriginalToRestoreError,
|
||||
removeImageBackground,
|
||||
restoreImageOriginal,
|
||||
} from '../intake/backgroundRemoval';
|
||||
import { isRembgConfigured, SidecarRequestError } from '../intake/rembgClient';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* The review queue: everything waiting for a person, with what a person needs
|
||||
* in order to decide.
|
||||
*
|
||||
* Columns are spelled out rather than `d.*, i.*` so that a column added later —
|
||||
* a cost, a token count, an internal error — does not silently start being sent
|
||||
* to the browser. That matters most for the join to upload_links, which carries
|
||||
* the token digest: only the label is taken.
|
||||
*
|
||||
* Images come back as an aggregate rather than a second round trip, matching
|
||||
* how itemSelect.ts builds them.
|
||||
*/
|
||||
const DRAFT_SELECT = `
|
||||
SELECT d.item_id, d.state, d.attempts, d.submitter_note, d.ai_error,
|
||||
d.ai_name, d.ai_description, d.ai_category_id, d.ai_tag_names,
|
||||
d.ai_suggested_price_cents, d.price_source, d.model, d.drafted_at,
|
||||
d.created_at,
|
||||
i.name AS item_name, i.description AS item_description,
|
||||
i.price_cents, i.status,
|
||||
l.label AS upload_link_label,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object(
|
||||
'id', img.id,
|
||||
'image_path', img.image_path,
|
||||
'original_image_path', img.original_image_path)
|
||||
ORDER BY img.sort_order)
|
||||
FROM item_images img WHERE img.item_id = d.item_id
|
||||
), '[]'::json) AS images
|
||||
FROM item_drafts d
|
||||
JOIN items i ON i.id = d.item_id
|
||||
LEFT JOIN upload_links l ON l.id = d.upload_link_id
|
||||
`;
|
||||
|
||||
/**
|
||||
* The two shapes the queue is ever asked for, as whole queries.
|
||||
*
|
||||
* Named rather than assembled at the call, so neither branch of the ternary
|
||||
* interpolates anything — the state is bound as $1 in the first and the second
|
||||
* carries no caller data at all. See #294.
|
||||
*/
|
||||
const DRAFTS_BY_STATE = `${DRAFT_SELECT} WHERE d.state = $1 ORDER BY d.created_at DESC`;
|
||||
const DRAFTS_NOT_DISCARDED = `${DRAFT_SELECT} WHERE d.state <> 'discarded' ORDER BY d.created_at DESC`;
|
||||
|
||||
/**
|
||||
* Discarded rows are excluded by default rather than deleted.
|
||||
*
|
||||
* Discard has to be recoverable, because it is one click away in what amounts
|
||||
* to an inbox — but a discarded row left in the default view would compete for
|
||||
* attention with work that still needs doing.
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const state = typeof req.query.state === 'string' ? req.query.state : null;
|
||||
|
||||
const { rows } = state
|
||||
? await pool.query(DRAFTS_BY_STATE, [state])
|
||||
: await pool.query(DRAFTS_NOT_DISCARDED);
|
||||
|
||||
// Whether the control has anything behind it, alongside the rows. A second
|
||||
// endpoint for one boolean would be a round trip the queue already makes.
|
||||
res.json({ drafts: rows, backgroundRemoval: isRembgConfigured() });
|
||||
})
|
||||
);
|
||||
|
||||
interface DraftPriceRow {
|
||||
price_source: PriceSource;
|
||||
price_cents: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish: the edited copy goes onto the item, and the item goes live.
|
||||
*
|
||||
* The only path from an intake submission to the storefront. It performs what
|
||||
* mark-available performs — the status, and clearing the sale and reservation
|
||||
* fields — rather than calling that route, because both halves have to be one
|
||||
* transaction. An item published carrying the previous draft's name would be a
|
||||
* worse outcome than one not published at all.
|
||||
*/
|
||||
router.post(
|
||||
'/:itemId/publish',
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
|
||||
const description = typeof req.body?.description === 'string' ? req.body.description.trim() : '';
|
||||
const priceCents = Number(req.body?.priceCents);
|
||||
|
||||
if (name === '') {
|
||||
return res.status(400).json({ error: 'a name is required' });
|
||||
}
|
||||
// Integer because the column is cents. A fractional value would round
|
||||
// somewhere nobody is looking and sell the item at a price no one entered.
|
||||
if (!Number.isInteger(priceCents) || priceCents < 0) {
|
||||
return res.status(400).json({ error: 'a price in whole cents is required' });
|
||||
}
|
||||
|
||||
// Guarded before a connection is taken. A malformed id reached Postgres as
|
||||
// text, raised 22P02 on an integer column and surfaced as a 500 — telling
|
||||
// the admin the server had broken when the truth is that no such draft can
|
||||
// exist (#207).
|
||||
const itemId = readId(req.params.itemId);
|
||||
if (itemId === null) return res.status(404).json({ error: 'no draft for this item' });
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Locked for the length of the transaction, so two admins publishing the
|
||||
// same submission cannot interleave one's price decision with another's
|
||||
// name.
|
||||
const { rows } = await client.query<DraftPriceRow>(
|
||||
`SELECT d.price_source, i.price_cents
|
||||
FROM item_drafts d JOIN items i ON i.id = d.item_id
|
||||
WHERE d.item_id = $1
|
||||
FOR UPDATE OF d, i`,
|
||||
[itemId]
|
||||
);
|
||||
const existing = rows[0];
|
||||
if (!existing) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ error: 'no draft for this item' });
|
||||
}
|
||||
|
||||
const priceSource = nextPriceSource(existing.price_source, priceCents, existing.price_cents);
|
||||
|
||||
await client.query(
|
||||
`UPDATE items
|
||||
SET name = $2, description = $3, price_cents = $4,
|
||||
status = 'available', sold_at = NULL, reserved_until = NULL, paypal_order_id = NULL
|
||||
WHERE id = $1`,
|
||||
[itemId, name, description === '' ? null : description, priceCents]
|
||||
);
|
||||
|
||||
await client.query(`UPDATE item_drafts SET price_source = $2 WHERE item_id = $1`, [
|
||||
itemId,
|
||||
priceSource
|
||||
]);
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json({ published: true, priceSource });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Regenerate: hand it back to the worker.
|
||||
*
|
||||
* attempts is reset along with the state. The worker only picks up rows below
|
||||
* the attempt cap, so re-queueing a draft that has already failed three times
|
||||
* without clearing them produces a button that appears to work, does nothing,
|
||||
* and leaves nothing anywhere to say why.
|
||||
*/
|
||||
router.post(
|
||||
'/:itemId/regenerate',
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const itemId = readId(req.params.itemId);
|
||||
if (itemId === null) return res.status(404).json({ error: 'no draft for this item' });
|
||||
|
||||
const { rowCount } = await pool.query(
|
||||
`UPDATE item_drafts SET state = 'queued', attempts = 0, ai_error = NULL WHERE item_id = $1`,
|
||||
[itemId]
|
||||
);
|
||||
if (rowCount === 0) return res.status(404).json({ error: 'no draft for this item' });
|
||||
|
||||
// Wake the worker rather than leaving the row for the five-minute sweeper.
|
||||
// Both this and the submission path put a row into 'queued'; only that one
|
||||
// asked for it to be drafted, which made this button indistinguishable from
|
||||
// a dead one (#272). Fire and forget with a logged catch, exactly as there:
|
||||
// a slow or failing model call must not become a failed request for the
|
||||
// admin, and the sweeper is still the backstop if this misses.
|
||||
void draftQueued(1).catch((err) => console.error('[drafting] after regenerate:', err));
|
||||
|
||||
res.json({ state: 'queued' });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Discard: out of the queue, off the storefront, and entirely recoverable.
|
||||
*
|
||||
* Nothing is deleted — not the item, not the photographs. This is one click
|
||||
* away in what amounts to an inbox, and the photos are often the only copy of
|
||||
* something no longer in the sender's hands, so the destructive reading of
|
||||
* "discard" is deliberately not available here. The item returns to pending
|
||||
* because a discarded submission must not stay on sale.
|
||||
*/
|
||||
router.post(
|
||||
'/:itemId/discard',
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const itemId = readId(req.params.itemId);
|
||||
if (itemId === null) return res.status(404).json({ error: 'no draft for this item' });
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rowCount } = await client.query(
|
||||
`UPDATE item_drafts SET state = 'discarded' WHERE item_id = $1`,
|
||||
[itemId]
|
||||
);
|
||||
if (rowCount === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ error: 'no draft for this item' });
|
||||
}
|
||||
await client.query(`UPDATE items SET status = 'pending' WHERE id = $1`, [itemId]);
|
||||
await client.query('COMMIT');
|
||||
res.json({ state: 'discarded' });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Restore: back into the queue, at the state the draft's own contents justify.
|
||||
*
|
||||
* Not unconditionally 'ready'. A submission discarded before it was ever
|
||||
* drafted has no copy, and returning it as ready would present an empty draft
|
||||
* as a finished one. Judged on whether a name was ever written, because the
|
||||
* state it held before being discarded is not stored anywhere.
|
||||
*/
|
||||
router.post(
|
||||
'/:itemId/restore',
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const itemId = readId(req.params.itemId);
|
||||
if (itemId === null) return res.status(404).json({ error: 'no draft for this item' });
|
||||
|
||||
const { rowCount } = await pool.query(
|
||||
`UPDATE item_drafts
|
||||
SET state = CASE WHEN ai_name IS NULL THEN 'failed' ELSE 'ready' END
|
||||
WHERE item_id = $1`,
|
||||
[itemId]
|
||||
);
|
||||
if (rowCount === 0) return res.status(404).json({ error: 'no draft for this item' });
|
||||
res.json({ restored: true });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* One photo's current paths, if it belongs to this item.
|
||||
*
|
||||
* Scoped by item as well as by image so an image id from a different
|
||||
* submission cannot be acted on through this item's URL — the id is a serial,
|
||||
* so guessing one is not hard.
|
||||
*/
|
||||
async function imageOfItem(
|
||||
itemId: number,
|
||||
imageId: number
|
||||
): Promise<{ image_path: string; original_image_path: string | null } | null> {
|
||||
const { rows } = await pool.query<{ image_path: string; original_image_path: string | null }>(
|
||||
`SELECT image_path, original_image_path
|
||||
FROM item_images WHERE id = $1 AND item_id = $2`,
|
||||
[imageId, itemId]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the background from one photo.
|
||||
*
|
||||
* The other half of the submitter's checkbox: for the photos nobody ticked it
|
||||
* for, and for the ones where the worker could not reach the sidecar. Both go
|
||||
* through the same module, so a cut-out obtained either way is identical and
|
||||
* either can be undone by Restore.
|
||||
*
|
||||
* Synchronous, unlike the worker's path. A warm request measures 1.1–2.3 s and
|
||||
* this is an admin who just clicked a button and is watching for the result.
|
||||
* The reason drafting was moved off the request path — that a stranger can
|
||||
* trigger it and must never wait — does not apply behind the admin gate.
|
||||
*/
|
||||
router.post(
|
||||
'/:itemId/images/:imageId/remove-background',
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
// Both ids, before either reaches Postgres. A route carrying two of them
|
||||
// can guard one and forget the other, and the forgotten one is a 500 rather
|
||||
// than the 404 that "no such photo" actually means (#207).
|
||||
const itemId = readId(req.params.itemId);
|
||||
const imageId = readId(req.params.imageId);
|
||||
if (itemId === null || imageId === null) {
|
||||
return res.status(404).json({ error: 'no such photo on this item' });
|
||||
}
|
||||
if ((await imageOfItem(itemId, imageId)) === null) {
|
||||
return res.status(404).json({ error: 'no such photo on this item' });
|
||||
}
|
||||
|
||||
try {
|
||||
await removeImageBackground(imageId);
|
||||
} catch (err) {
|
||||
console.error(`[drafts] background removal for image ${imageId}:`, err);
|
||||
|
||||
// 502 only for a SidecarRequestError: the request was fine and so is
|
||||
// this app — the service it depends on was actually contacted and did
|
||||
// not answer usably. The message says the photo is unchanged, because
|
||||
// that is the thing the admin actually needs to know.
|
||||
if (err instanceof SidecarRequestError) {
|
||||
return res
|
||||
.status(502)
|
||||
.json({ error: 'the background-removal service did not answer — the photo is unchanged' });
|
||||
}
|
||||
|
||||
// Everything else here never reached the sidecar at all — an
|
||||
// unrecognised file extension (a legacy .jpeg), a file missing from the
|
||||
// uploads volume, or REMBG_URL not being set. Reporting those as "the
|
||||
// service did not answer" would send the admin to retry a service that
|
||||
// was never contacted, and hide the real reason in the server log. The
|
||||
// photo is still unchanged in every one of these cases too:
|
||||
// removeImageBackground only writes the row once the cut-out already
|
||||
// exists on disk.
|
||||
return res.status(500).json({
|
||||
error:
|
||||
err instanceof Error
|
||||
? `this photo could not be processed: ${err.message}`
|
||||
: 'this photo could not be processed'
|
||||
});
|
||||
}
|
||||
|
||||
res.json(await imageOfItem(itemId, imageId));
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Put the original photo back.
|
||||
*
|
||||
* The reason a cut-out is safe to try at all. Background removal produces the
|
||||
* occasional poor result on an unusual object, and this makes that survivable
|
||||
* rather than something to prevent. Nothing is deleted: the cut-out file stays
|
||||
* on disk, because somebody restoring one is quite likely to try again.
|
||||
*
|
||||
* restoreImageOriginal also turns off remove_background for this item, so a
|
||||
* later Regenerate does not silently re-cut a photo the admin just put back —
|
||||
* see the reasoning on that function.
|
||||
*/
|
||||
router.post(
|
||||
'/:itemId/images/:imageId/restore-original',
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const itemId = readId(req.params.itemId);
|
||||
const imageId = readId(req.params.imageId);
|
||||
if (itemId === null || imageId === null) {
|
||||
return res.status(404).json({ error: 'this photo has no original to restore' });
|
||||
}
|
||||
|
||||
const existing = await imageOfItem(itemId, imageId);
|
||||
if (existing === null || existing.original_image_path === null) {
|
||||
return res.status(404).json({ error: 'this photo has no original to restore' });
|
||||
}
|
||||
|
||||
try {
|
||||
await restoreImageOriginal(imageId);
|
||||
} catch (err) {
|
||||
// Narrow on purpose: only NoOriginalToRestoreError means "another
|
||||
// request already did this, the work is done". This precheck and
|
||||
// restoreImageOriginal's own `original_image_path IS NOT NULL` guard can
|
||||
// disagree under a race — two concurrent restores (or a double-click)
|
||||
// can both pass the precheck before either commits, and the loser's
|
||||
// UPDATE then matches zero rows and throws that specific error. Any
|
||||
// other failure (a dropped connection, a transient outage) must not be
|
||||
// reported the same way — it needs to stay loud as a 500, so it is
|
||||
// rethrown here for asyncRoute's app-level handler to catch.
|
||||
if (!(err instanceof NoOriginalToRestoreError)) {
|
||||
throw err;
|
||||
}
|
||||
console.error(`[drafts] restore for image ${imageId}:`, err);
|
||||
return res.status(404).json({ error: 'this photo has no original to restore' });
|
||||
}
|
||||
|
||||
res.json(await imageOfItem(itemId, imageId));
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,124 +1,30 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import {
|
||||
getSettings,
|
||||
updateSettings,
|
||||
HOURS_SETTINGS,
|
||||
TEXT_SETTINGS,
|
||||
CHOICE_SETTINGS,
|
||||
CHOICE_OPTIONS,
|
||||
isValidChoice,
|
||||
mayBeEmpty,
|
||||
SettingName
|
||||
} from '../adminSettings';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
|
||||
res.json(await getSettings());
|
||||
const { rows } = await pool.query(`SELECT key, value FROM admin_settings`);
|
||||
const map: Record<string, string> = {};
|
||||
for (const r of rows) map[r.key] = r.value;
|
||||
res.json({
|
||||
cartExpiryHours: parseFloat(map.cart_expiry_hours || '24')
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* One submitted value, checked.
|
||||
*
|
||||
* A refusal is returned rather than sent, so each reader below is a pure
|
||||
* function of its input and the handler keeps sole responsibility for the
|
||||
* response. That is also what lets the handler be one loop instead of four:
|
||||
* the branching lives in these, one or two conditions each, rather than
|
||||
* accumulating in the route.
|
||||
*/
|
||||
type Reading =
|
||||
| { ok: true; value: number | string }
|
||||
| { ok: false; error: string }
|
||||
| { skip: true };
|
||||
|
||||
const SKIP = { skip: true } as const;
|
||||
|
||||
function readHours(name: SettingName, raw: unknown): Reading {
|
||||
if (raw === undefined) return SKIP;
|
||||
const hours = parseFloat(String(raw));
|
||||
if (Number.isNaN(hours) || hours <= 0) {
|
||||
return { ok: false, error: `${name} must be a positive number` };
|
||||
}
|
||||
return { ok: true, value: hours };
|
||||
}
|
||||
|
||||
function readText(name: SettingName, raw: unknown): Reading {
|
||||
if (raw === undefined) return SKIP;
|
||||
if (typeof raw !== 'string') {
|
||||
return { ok: false, error: `${name} cannot be empty` };
|
||||
}
|
||||
if (raw.trim() === '') {
|
||||
// Whether empty is a mistake is a fact about the setting, not about the
|
||||
// type, so it is asked of the setting (#280). intakeNotifyEmail and
|
||||
// intakeCeilingResetAt both document empty as their default and as a
|
||||
// working configuration — meaning "do not notify" and "no reset recorded" —
|
||||
// and the blanket rule meant an address could be set and never removed
|
||||
// except by a DELETE against the table.
|
||||
if (!mayBeEmpty(name)) {
|
||||
return { ok: false, error: `${name} cannot be empty` };
|
||||
}
|
||||
// Normalised, so whitespace is stored as cleared rather than as spaces.
|
||||
// Someone clearing a field they cannot see the end of leaves whitespace,
|
||||
// and they meant empty.
|
||||
return { ok: true, value: '' };
|
||||
}
|
||||
return { ok: true, value: raw };
|
||||
}
|
||||
|
||||
/**
|
||||
* Membership is checked here rather than left to the dropdown. A value outside
|
||||
* the set would be stored happily and then fail on every submission, surfacing
|
||||
* only as drafts quietly not appearing (#223).
|
||||
*/
|
||||
function readChoice(name: SettingName, raw: unknown): Reading {
|
||||
if (raw === undefined) return SKIP;
|
||||
if (typeof raw !== 'string' || !isValidChoice(name as never, raw)) {
|
||||
const allowed = CHOICE_OPTIONS[name as never] as readonly string[];
|
||||
return { ok: false, error: `${name} must be one of: ${allowed.join(', ')}` };
|
||||
}
|
||||
return { ok: true, value: raw };
|
||||
}
|
||||
|
||||
/**
|
||||
* Each group of settings with the reader that validates it.
|
||||
*
|
||||
* A table rather than four copies of the same loop. The loops were identical
|
||||
* apart from their validation, and having four of them was most of this
|
||||
* handler's cognitive complexity — 18 against a limit of 15, which is what
|
||||
* SonarQube flagged as the only CRITICAL smell in the project (#181). Adding a
|
||||
* type now means adding a row.
|
||||
*
|
||||
* The `count` settings from #227 are deliberately absent, exactly as before
|
||||
* this refactor: nothing sends them, the admin screen has no control for them,
|
||||
* and adding validation for a field no caller submits would be widening the
|
||||
* behaviour under cover of a complexity fix.
|
||||
*/
|
||||
const GROUPS: readonly {
|
||||
names: readonly SettingName[];
|
||||
read: (name: SettingName, raw: unknown) => Reading;
|
||||
}[] = [
|
||||
{ names: HOURS_SETTINGS, read: readHours },
|
||||
{ names: TEXT_SETTINGS, read: readText },
|
||||
{ names: CHOICE_SETTINGS, read: readChoice }
|
||||
];
|
||||
|
||||
router.put('/', asyncRoute(async (req: Request, res: Response) => {
|
||||
const values: Partial<Record<SettingName, number | string>> = {};
|
||||
|
||||
// Only what was sent is validated and written, so a caller updating one field
|
||||
// does not have to echo the others back to avoid clobbering them.
|
||||
for (const group of GROUPS) {
|
||||
for (const name of group.names) {
|
||||
const reading = group.read(name, req.body[name]);
|
||||
if ('skip' in reading) continue;
|
||||
if (!reading.ok) return res.status(400).json({ error: reading.error });
|
||||
values[name] = reading.value;
|
||||
}
|
||||
const { cartExpiryHours } = req.body;
|
||||
const hours = parseFloat(cartExpiryHours);
|
||||
if (Number.isNaN(hours) || hours <= 0) {
|
||||
return res.status(400).json({ error: 'cartExpiryHours must be a positive number' });
|
||||
}
|
||||
|
||||
await updateSettings(values);
|
||||
res.json(await getSettings());
|
||||
await pool.query(
|
||||
`INSERT INTO admin_settings (key, value, updated_at) VALUES ('cart_expiry_hours', $1, now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = now()`,
|
||||
[String(hours)]
|
||||
);
|
||||
res.json({ cartExpiryHours: hours });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,17 +3,6 @@ import { pool } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { TAG_COLORS, tagColorFor } from '../utils';
|
||||
|
||||
interface TagRow {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** The list adds a usage count, cast to int so it arrives as a number. */
|
||||
interface TagListRow extends TagRow {
|
||||
item_count: number;
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
const UNIQUE_VIOLATION = '23505';
|
||||
@@ -33,7 +22,7 @@ function readColor(value: unknown): string | null | undefined {
|
||||
}
|
||||
|
||||
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
|
||||
const { rows } = await pool.query<TagListRow>(
|
||||
const { rows } = await pool.query(
|
||||
`SELECT t.id, t.name, t.color,
|
||||
(SELECT COUNT(*)::int FROM item_tags it WHERE it.tag_id = t.id) AS item_count
|
||||
FROM tags t
|
||||
@@ -55,7 +44,7 @@ router.post('/', asyncRoute(async (req: Request, res: Response) => {
|
||||
const color = requestedColor ?? tagColorFor(name);
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<TagRow>(
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO tags (name, color) VALUES ($1, $2) RETURNING id, name, color`,
|
||||
[name, color]
|
||||
);
|
||||
@@ -94,7 +83,7 @@ router.put('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<TagRow>(
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE tags SET name = $1, color = $2 WHERE id = $3 RETURNING id, name, color`,
|
||||
[name, color, id]
|
||||
);
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool, requireRow } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { generateToken, hashToken } from '../uploadLinks';
|
||||
import { trimTrailingSlashes, isValidEmail } from '../utils';
|
||||
import { sendMail, MailOutcome } from '../mailer';
|
||||
import { renderTemplate } from '../emailTemplates';
|
||||
import { loadStoredTemplate } from './adminEmailTemplates';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Issuing and retiring the links that open the public intake endpoint (#222).
|
||||
*
|
||||
* A link is named because provenance matters more than convenience here. When
|
||||
* one is shared further than intended the question is *which* one, and the
|
||||
* answer has to come from somewhere — so every submission records the link it
|
||||
* arrived through, and revoking kills that link rather than the feature.
|
||||
*
|
||||
* The token is returned by exactly one response in this file and is
|
||||
* unrecoverable afterwards. That is why the admin screen has to present it as
|
||||
* a one-time reveal rather than a field to come back to, and why losing it
|
||||
* means issuing a new link rather than looking the old one up.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shaped so a `SELECT *` can never leak the digest into a response.
|
||||
*
|
||||
* Spelling the columns out is the point: `SELECT *` here would put
|
||||
* `token_hash` into every listing the moment somebody added a convenience.
|
||||
*/
|
||||
const LINK_SELECT = `
|
||||
SELECT id, label, contact_email, revoked_at, submission_count, max_submissions, last_used_at, created_at
|
||||
FROM upload_links
|
||||
`;
|
||||
|
||||
/** Every link, newest first. A whole query, so the call interpolates nothing (#294). */
|
||||
const LINK_LIST = `${LINK_SELECT} ORDER BY created_at DESC`;
|
||||
|
||||
/**
|
||||
* The cap a link gets when nobody chose one.
|
||||
*
|
||||
* Not a tuned number — large enough that an ordinary contributor never meets
|
||||
* it, small enough that a link shared further than intended cannot be used
|
||||
* indefinitely before anyone notices. The point is that the default is finite
|
||||
* at all.
|
||||
*/
|
||||
const DEFAULT_MAX_SUBMISSIONS = 25;
|
||||
|
||||
interface UploadLinkRow {
|
||||
id: number;
|
||||
label: string;
|
||||
contact_email: string | null;
|
||||
revoked_at: string | null;
|
||||
submission_count: number;
|
||||
max_submissions: number | null;
|
||||
last_used_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the mail tells the recipient about how much they may send.
|
||||
*
|
||||
* Words rather than a bare number for an uncapped link, so the sentence reads
|
||||
* as a sentence instead of showing an empty space where a figure should be.
|
||||
* An uncapped link is a deliberate choice the admin already had to make, so it
|
||||
* is emailable like any other.
|
||||
*/
|
||||
function submissionsAllowed(maxSubmissions: number | null): string {
|
||||
if (maxSubmissions === null) return 'as many items as you like';
|
||||
return maxSubmissions === 1 ? '1 item' : `${maxSubmissions} items`;
|
||||
}
|
||||
|
||||
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
|
||||
const { rows } = await pool.query<UploadLinkRow>(LINK_LIST);
|
||||
res.json(rows);
|
||||
}));
|
||||
|
||||
router.post('/', asyncRoute(async (req: Request, res: Response) => {
|
||||
const label = typeof req.body?.label === 'string' ? req.body.label.trim() : '';
|
||||
if (label === '') {
|
||||
return res.status(400).json({ error: 'a label is required' });
|
||||
}
|
||||
|
||||
const email = typeof req.body?.email === 'string' ? req.body.email.trim() : '';
|
||||
if (email === '' || !isValidEmail(email)) {
|
||||
return res.status(400).json({ error: 'a valid email address is required' });
|
||||
}
|
||||
|
||||
// Three cases, deliberately distinct. Absent means nobody decided, which
|
||||
// gets the bounded default. An explicit null means unlimited — a decision
|
||||
// someone made, visible in the request. A number is itself. Reading absent
|
||||
// as unlimited is what would make every link unbounded by default.
|
||||
const rawCap = req.body?.maxSubmissions;
|
||||
let maxSubmissions: number | null = DEFAULT_MAX_SUBMISSIONS;
|
||||
if (rawCap === null) {
|
||||
maxSubmissions = null;
|
||||
} else if (rawCap !== undefined && rawCap !== '') {
|
||||
const parsed = Number(rawCap);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
return res.status(400).json({ error: 'maxSubmissions must be a positive whole number' });
|
||||
}
|
||||
maxSubmissions = parsed;
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const { rows } = await pool.query<UploadLinkRow>(
|
||||
`INSERT INTO upload_links (label, token_hash, max_submissions, contact_email)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, label, contact_email, revoked_at, submission_count, max_submissions, last_used_at, created_at`,
|
||||
[label, hashToken(token), maxSubmissions, email]
|
||||
);
|
||||
const link = requireRow(rows, 'the upload_links INSERT');
|
||||
|
||||
// PUBLIC_URL is already required alongside SMTP and is what every other
|
||||
// outbound link is built from. Absent in local development, which yields a
|
||||
// relative URL the admin screen can still show and copy usefully.
|
||||
const base = trimTrailingSlashes(process.env.PUBLIC_URL ?? '');
|
||||
const url = `${base}/submit/${token}`;
|
||||
|
||||
// Awaited, and its outcome reported rather than swallowed. Every other sender
|
||||
// in this codebase fires and forgets because nobody is waiting on the answer;
|
||||
// here somebody is — the admin is looking at the screen, and whether they
|
||||
// now have to send the link by hand is the thing they need to know.
|
||||
//
|
||||
// A failure does not roll the link back. The token is shown exactly once, so
|
||||
// a rollback would leave the admin retrying and holding a different link,
|
||||
// discarding work that succeeded for the sake of tidiness.
|
||||
let outcome: MailOutcome;
|
||||
try {
|
||||
const template = renderTemplate('uploadLink', await loadStoredTemplate('uploadLink'), {
|
||||
submitUrl: url,
|
||||
label: link.label,
|
||||
submissionsAllowed: submissionsAllowed(link.max_submissions)
|
||||
});
|
||||
outcome = await sendMail(email, template.subject, template.html);
|
||||
} catch (err) {
|
||||
// Reported, not thrown. The link exists and is usable; the admin needs to
|
||||
// be told the mail did not go, not handed a 500 for a link that was made.
|
||||
//
|
||||
// Assigned only here, not also at the declaration. The duplicate initialiser
|
||||
// was flagged as S1854 (#294), and it was worse than redundant: it made the
|
||||
// two ways of reaching this line look like one. A template that will not
|
||||
// render, or a stored template that cannot be loaded, is not an SMTP
|
||||
// problem, and reporting it as "not configured" pointed the admin at the
|
||||
// wrong thing entirely.
|
||||
//
|
||||
// An SMTP rejection landing here and being reported as unconfigured is the
|
||||
// conflation that was actually agreed: a fourth outcome would be a real
|
||||
// distinction, nothing consumes it, and the admin's next action is identical
|
||||
// either way — copy the link and send it by hand.
|
||||
console.error(`[upload-links] could not email ${email}:`, err);
|
||||
outcome = 'skipped-unconfigured';
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
...link,
|
||||
token,
|
||||
url,
|
||||
mail: { sent: outcome === 'sent', outcome }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Forgives the current ceiling window without deleting anything.
|
||||
*
|
||||
* The count is derived from item_drafts rows, which are real submissions with
|
||||
* real items in the review queue — so a reset moves the window's start rather
|
||||
* than removing anything. Recovery is automatic as the window rolls; this is
|
||||
* for the case where the ceiling was hit legitimately and waiting is not
|
||||
* acceptable.
|
||||
*
|
||||
* Declared above `/:id/revoke` deliberately: Express matches in order, and
|
||||
* `reset-ceiling` would otherwise be read as an id.
|
||||
*/
|
||||
router.post('/reset-ceiling', asyncRoute(async (_req: Request, res: Response) => {
|
||||
await pool.query(
|
||||
`INSERT INTO admin_settings (key, value, updated_at)
|
||||
VALUES ('intake_ceiling_reset_at', $1, now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
|
||||
[new Date().toISOString()]
|
||||
);
|
||||
res.json({ reset: true });
|
||||
}));
|
||||
|
||||
router.post('/:id/revoke', asyncRoute(async (req: Request, res: Response) => {
|
||||
// COALESCE so revoking twice keeps the original timestamp. The useful fact
|
||||
// is when access ended, and a second click should neither rewrite that nor
|
||||
// fail — a button that errors on a double-click teaches people to distrust
|
||||
// it, which is the last thing wanted on the control that contains a leak.
|
||||
const { rows } = await pool.query<UploadLinkRow>(
|
||||
`UPDATE upload_links SET revoked_at = COALESCE(revoked_at, now())
|
||||
WHERE id = $1
|
||||
RETURNING id, label, contact_email, revoked_at, submission_count, max_submissions, last_used_at, created_at`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
const link = rows[0];
|
||||
if (!link) {
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
res.json(link);
|
||||
}));
|
||||
|
||||
export default router;
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { readBuildInfo } from '../buildInfo';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* What build this environment is running (#233).
|
||||
*
|
||||
* Behind `requireAdminGate` like every other admin router, and deliberately
|
||||
* not folded into `/api/config`. That endpoint is public — the storefront
|
||||
* fetches it on every load — and a commit hash there would tell anyone exactly
|
||||
* which revision of a public repository is deployed, which is free help to
|
||||
* someone matching known issues against it. Nothing here is needed by a
|
||||
* customer.
|
||||
*
|
||||
* Not wrapped in asyncRoute because the handler is synchronous: the stamp is
|
||||
* read from disk once and cached, so there is no promise to reject.
|
||||
*/
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
res.json(readBuildInfo());
|
||||
});
|
||||
|
||||
export default router;
|
||||
+16
-52
@@ -1,41 +1,13 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool, requireRow } from '../db';
|
||||
import { pool } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { requireCustomer } from '../middleware/customerAuth';
|
||||
import { getSettings } from '../adminSettings';
|
||||
import { ItemStatus, ItemImage } from '../types';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Row shapes for the reads here. As in cartCheckout.ts, only queries whose rows
|
||||
* are read carry a type, and each is kept in step with its SQL by hand.
|
||||
*/
|
||||
interface IdRow {
|
||||
id: number;
|
||||
}
|
||||
|
||||
/** What CART_ITEM_SELECT returns — a held item as the cart page renders it. */
|
||||
interface CartRow {
|
||||
item_id: number;
|
||||
added_at: Date;
|
||||
expires_at: Date;
|
||||
name: string;
|
||||
price_cents: number;
|
||||
status: ItemStatus;
|
||||
// COALESCE'd json_agg, so always an array. Only id and image_path are
|
||||
// selected; the cart does not need sort_order.
|
||||
images: Pick<ItemImage, 'id' | 'image_path'>[];
|
||||
}
|
||||
|
||||
/** The row locked FOR UPDATE before an item is reserved. */
|
||||
interface LockedItemRow {
|
||||
id: number;
|
||||
status: ItemStatus;
|
||||
}
|
||||
|
||||
interface RemovedItemRow {
|
||||
item_id: number;
|
||||
async function getCartExpiryHours(): Promise<number> {
|
||||
const { rows } = await pool.query(`SELECT value FROM admin_settings WHERE key = 'cart_expiry_hours'`);
|
||||
return rows.length ? parseFloat(rows[0].value) : 24;
|
||||
}
|
||||
|
||||
const CART_ITEM_SELECT = `
|
||||
@@ -56,22 +28,18 @@ const CART_ITEM_SELECT = `
|
||||
`;
|
||||
|
||||
router.get('/', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows: cartRows } = await pool.query<IdRow>(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]);
|
||||
const [cart] = cartRows;
|
||||
if (!cart) return res.json({ items: [] });
|
||||
const { rows: items } = await pool.query<CartRow>(CART_ITEM_SELECT, [cart.id]);
|
||||
const { rows: cartRows } = await pool.query(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]);
|
||||
if (!cartRows.length) return res.json({ items: [] });
|
||||
const { rows: items } = await pool.query(CART_ITEM_SELECT, [cartRows[0].id]);
|
||||
res.json({ items });
|
||||
}));
|
||||
|
||||
router.post('/items/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
// Express types route params as an index signature, so this is
|
||||
// `string | undefined` even though the route cannot match without it.
|
||||
const itemId = req.params.itemId;
|
||||
if (!itemId) return res.status(400).json({ error: 'itemId is required' });
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows: itemRows } = await client.query<LockedItemRow>(`SELECT * FROM items WHERE id = $1 FOR UPDATE`, [itemId]);
|
||||
const { rows: itemRows } = await client.query(`SELECT * FROM items WHERE id = $1 FOR UPDATE`, [itemId]);
|
||||
const item = itemRows[0];
|
||||
if (!item) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not found' }); }
|
||||
if (item.status !== 'available') {
|
||||
@@ -79,22 +47,21 @@ router.post('/items/:itemId', requireCustomer, asyncRoute(async (req: Request, r
|
||||
return res.status(409).json({ error: 'item is no longer available' });
|
||||
}
|
||||
|
||||
const { rows: cartRows } = await client.query<IdRow>(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]);
|
||||
const [existingCart] = cartRows;
|
||||
let { rows: cartRows } = await client.query(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]);
|
||||
let cartId: number;
|
||||
if (existingCart) {
|
||||
cartId = existingCart.id;
|
||||
if (cartRows.length) {
|
||||
cartId = cartRows[0].id;
|
||||
await client.query(`UPDATE carts SET updated_at = now() WHERE id = $1`, [cartId]);
|
||||
} else {
|
||||
const { rows: newCart } = await client.query<IdRow>(
|
||||
const { rows: newCart } = await client.query(
|
||||
`INSERT INTO carts (customer_id) VALUES ($1) RETURNING id`,
|
||||
[req.customerId]
|
||||
);
|
||||
cartId = requireRow(newCart, 'the cart INSERT').id;
|
||||
cartId = newCart[0].id;
|
||||
}
|
||||
|
||||
const { cartExpiryHours } = await getSettings();
|
||||
const expiresAt = new Date(Date.now() + cartExpiryHours * 60 * 60 * 1000);
|
||||
const hours = await getCartExpiryHours();
|
||||
const expiresAt = new Date(Date.now() + hours * 60 * 60 * 1000);
|
||||
await client.query(
|
||||
`INSERT INTO cart_items (cart_id, item_id, expires_at) VALUES ($1, $2, $3)`,
|
||||
[cartId, itemId, expiresAt]
|
||||
@@ -112,14 +79,11 @@ router.post('/items/:itemId', requireCustomer, asyncRoute(async (req: Request, r
|
||||
}));
|
||||
|
||||
router.delete('/items/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
// Express types route params as an index signature, so this is
|
||||
// `string | undefined` even though the route cannot match without it.
|
||||
const itemId = req.params.itemId;
|
||||
if (!itemId) return res.status(400).json({ error: 'itemId is required' });
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query<RemovedItemRow>(
|
||||
const { rows } = await client.query(
|
||||
`DELETE FROM cart_items ci
|
||||
USING carts c
|
||||
WHERE ci.cart_id = c.id AND c.customer_id = $1 AND ci.item_id = $2
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import type { PoolClient } from 'pg';
|
||||
import { pool, requireRow } from '../db';
|
||||
import { pool } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { requireCustomer } from '../middleware/customerAuth';
|
||||
import { notifyFavoritersOfSale } from '../favoriteAlerts';
|
||||
@@ -25,35 +24,6 @@ async function getAccessToken(): Promise<string> {
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Row shapes for the reads in this file.
|
||||
*
|
||||
* Only queries whose rows are actually read carry a type. The INSERTs, UPDATEs,
|
||||
* DELETEs and the BEGIN/COMMIT/ROLLBACK calls return nothing anyone looks at,
|
||||
* and annotating them would be ceremony that makes the ones that matter harder
|
||||
* to pick out.
|
||||
*
|
||||
* Kept in step with their SQL by hand: `client.query<T>` asserts a shape rather
|
||||
* than checking it, because TypeScript never reads the query string. The
|
||||
* integration suite is what catches a select and its type disagreeing.
|
||||
*/
|
||||
interface IdRow {
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface CheckoutItemRow {
|
||||
item_id: number;
|
||||
price_cents: number;
|
||||
}
|
||||
|
||||
interface CheckoutOwnerRow {
|
||||
customer_id: number | null;
|
||||
}
|
||||
|
||||
interface CheckoutStatusRow {
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface CartItem {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -68,12 +38,11 @@ interface LockedCart {
|
||||
|
||||
// Locks the customer's cart, verifies every item is still reserved to them,
|
||||
// and returns { cartId, items: [{id, name, price_cents}], totalCents }.
|
||||
async function loadLockedCart(client: PoolClient, customerId: number): Promise<LockedCart | null> {
|
||||
const { rows: cartRows } = await client.query<IdRow>(`SELECT id FROM carts WHERE customer_id = $1`, [customerId]);
|
||||
const [cart] = cartRows;
|
||||
if (!cart) return null;
|
||||
const cartId = cart.id;
|
||||
const { rows: items } = await client.query<CartItem>(
|
||||
async function loadLockedCart(client: any, customerId: number): Promise<LockedCart | null> {
|
||||
const { rows: cartRows } = await client.query(`SELECT id FROM carts WHERE customer_id = $1`, [customerId]);
|
||||
if (!cartRows.length) return null;
|
||||
const cartId = cartRows[0].id;
|
||||
const { rows: items } = await client.query(
|
||||
`SELECT i.id, i.name, i.price_cents
|
||||
FROM cart_items ci
|
||||
JOIN items i ON i.id = ci.item_id
|
||||
@@ -82,7 +51,7 @@ async function loadLockedCart(client: PoolClient, customerId: number): Promise<L
|
||||
[cartId]
|
||||
);
|
||||
if (!items.length) return { cartId, items: [], totalCents: 0 };
|
||||
const totalCents = items.reduce((sum, it) => sum + it.price_cents, 0);
|
||||
const totalCents = items.reduce((sum: number, it: CartItem) => sum + it.price_cents, 0);
|
||||
return { cartId, items, totalCents };
|
||||
}
|
||||
|
||||
@@ -95,13 +64,13 @@ type OpenedCheckout =
|
||||
// items. The caller owns the transaction — on `ok: false` it should roll back
|
||||
// and return the error as a 400.
|
||||
async function openCheckout(
|
||||
client: PoolClient,
|
||||
client: any,
|
||||
customerId: number,
|
||||
shippingAddressId: number,
|
||||
processor: string,
|
||||
processorOrderId: string | null
|
||||
): Promise<OpenedCheckout> {
|
||||
const { rows: addrRows } = await client.query<IdRow>(
|
||||
const { rows: addrRows } = await client.query(
|
||||
`SELECT id FROM shipping_addresses WHERE id = $1 AND customer_id = $2`,
|
||||
[shippingAddressId, customerId]
|
||||
);
|
||||
@@ -110,12 +79,12 @@ async function openCheckout(
|
||||
const cart = await loadLockedCart(client, customerId);
|
||||
if (!cart || !cart.items.length) return { ok: false, error: 'cart is empty' };
|
||||
|
||||
const { rows: checkoutRows } = await client.query<IdRow>(
|
||||
const { rows: checkoutRows } = await client.query(
|
||||
`INSERT INTO checkouts (customer_id, shipping_address_id, processor, processor_order_id, amount_cents, status)
|
||||
VALUES ($1, $2, $3, $4, $5, 'pending') RETURNING id`,
|
||||
[customerId, shippingAddressId, processor, processorOrderId, cart.totalCents]
|
||||
);
|
||||
const checkoutId = requireRow(checkoutRows, 'the checkout INSERT').id;
|
||||
const checkoutId = checkoutRows[0].id;
|
||||
for (const it of cart.items) {
|
||||
await client.query(
|
||||
`INSERT INTO checkout_items (checkout_id, item_id, price_cents) VALUES ($1, $2, $3)`,
|
||||
@@ -176,12 +145,12 @@ router.post('/paypal/create', requireCustomer, asyncRoute(async (req: Request, r
|
||||
// Returns the sold item ids and the buyer, so the caller can notify favoriters
|
||||
// *after* COMMIT. Sending inside the transaction would email people about a
|
||||
// sale that then rolled back, and would hold the transaction open for SMTP.
|
||||
async function completeCheckout(client: PoolClient, checkoutId: number, processor: string, processorOrderId: string | null, rawEvent: unknown): Promise<{ itemIds: number[]; buyerId: number | null }> {
|
||||
const { rows: checkoutItems } = await client.query<CheckoutItemRow>(
|
||||
async function completeCheckout(client: any, checkoutId: number, processor: string, processorOrderId: string | null, rawEvent: unknown): Promise<{ itemIds: number[]; buyerId: number | null }> {
|
||||
const { rows: checkoutItems } = await client.query(
|
||||
`SELECT item_id, price_cents FROM checkout_items WHERE checkout_id = $1`,
|
||||
[checkoutId]
|
||||
);
|
||||
const { rows: checkoutRows } = await client.query<CheckoutOwnerRow>(`SELECT customer_id FROM checkouts WHERE id = $1`, [checkoutId]);
|
||||
const { rows: checkoutRows } = await client.query(`SELECT customer_id FROM checkouts WHERE id = $1`, [checkoutId]);
|
||||
const customerId = checkoutRows[0]?.customer_id;
|
||||
|
||||
for (const ci of checkoutItems) {
|
||||
@@ -196,7 +165,7 @@ async function completeCheckout(client: PoolClient, checkoutId: number, processo
|
||||
await client.query(`UPDATE checkouts SET status = 'completed', raw_event = $1 WHERE id = $2`, [rawEvent, checkoutId]);
|
||||
|
||||
return {
|
||||
itemIds: checkoutItems.map((ci) => ci.item_id),
|
||||
itemIds: checkoutItems.map((ci: { item_id: number }) => ci.item_id),
|
||||
buyerId: customerId ?? null
|
||||
};
|
||||
}
|
||||
@@ -215,14 +184,14 @@ router.post('/paypal/capture', requireCustomer, asyncRoute(async (req: Request,
|
||||
return res.status(502).json({ error: 'capture failed', detail: capture });
|
||||
}
|
||||
|
||||
const { rows } = await pool.query<IdRow>(
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id FROM checkouts WHERE processor_order_id = $1 AND customer_id = $2`,
|
||||
[orderID, req.customerId]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'checkout not found' });
|
||||
|
||||
await client.query('BEGIN');
|
||||
const sold = await completeCheckout(client, requireRow(rows, 'the checkout lookup').id, 'paypal', orderID, capture);
|
||||
const sold = await completeCheckout(client, rows[0].id, 'paypal', orderID, capture);
|
||||
await client.query('COMMIT');
|
||||
await notifyFavoritersOfSale(sold.itemIds, sold.buyerId);
|
||||
res.json({ status: 'completed' });
|
||||
@@ -246,20 +215,9 @@ router.post('/demo/purchase', requireCustomer, asyncRoute(async (req: Request, r
|
||||
const opened = await openCheckout(client, req.customerId as number, shippingAddressId, 'demo', `demo-${Date.now()}`);
|
||||
if (!opened.ok) { await client.query('ROLLBACK'); return res.status(400).json({ error: opened.error }); }
|
||||
|
||||
await completeCheckout(client, opened.checkoutId, 'demo', null, { demo: true });
|
||||
const sold = await completeCheckout(client, opened.checkoutId, 'demo', null, { demo: true });
|
||||
await client.query('COMMIT');
|
||||
|
||||
// Deliberately no notifyFavoritersOfSale here, unlike the PayPal capture and
|
||||
// webhook paths above. A demo purchase is not a sale. The item really is
|
||||
// marked sold, so the storefront stays truthful about availability, but the
|
||||
// `favoriteSold` copy says the item "has been sold to another customer" and
|
||||
// "will not be restocked" — and both are false when nobody bought anything.
|
||||
//
|
||||
// This is the only outbound consequence a demo purchase has. Everything else
|
||||
// it does is visible to the person who clicked, who has been told it is a
|
||||
// demo (#195, #203); these recipients never saw the cart and have no way to
|
||||
// know. While production runs the demo interim (#191) they are real
|
||||
// customers on real SMTP. See #206.
|
||||
await notifyFavoritersOfSale(sold.itemIds, sold.buyerId);
|
||||
res.json({ status: 'completed' });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
@@ -293,9 +251,8 @@ webhookRouter.post('/', asyncRoute(async (req: Request, res: Response) => {
|
||||
if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') {
|
||||
const checkoutId = event.resource?.custom_id;
|
||||
if (checkoutId) {
|
||||
const { rows } = await pool.query<CheckoutStatusRow>(`SELECT status FROM checkouts WHERE id = $1`, [checkoutId]);
|
||||
const [checkout] = rows;
|
||||
if (checkout && checkout.status !== 'completed') {
|
||||
const { rows } = await pool.query(`SELECT status FROM checkouts WHERE id = $1`, [checkoutId]);
|
||||
if (rows.length && rows[0].status !== 'completed') {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { clientErrorLimiter } from '../rateLimit';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// The three error boundaries in the frontend. An unrecognised context means the
|
||||
// client and the server disagree about something, which is worth surfacing
|
||||
// rather than logging under a guessed label — the same reasoning as
|
||||
// parseItemFilters refusing a malformed filter instead of coercing it.
|
||||
const CONTEXTS: readonly string[] = ['page', 'catalogue', 'modal'];
|
||||
|
||||
const MAX_MESSAGE = 500;
|
||||
// Kept well short of the 4000 this endpoint originally used. The endpoint is
|
||||
// unauthenticated and the rate limiter is per-address, so a distributed
|
||||
// writer sending a few hundred cheap requests a day was still tens of
|
||||
// megabytes against Docker's default json-file log driver, which has no size
|
||||
// cap on its own. 1000 characters is roughly fifteen stack frames — enough to
|
||||
// identify a throw — and keeps the worst-case record under 3 KB.
|
||||
const MAX_STACK = 1000;
|
||||
const MAX_COMPONENT_STACK = 1000;
|
||||
const MAX_PATH = 200;
|
||||
|
||||
// True for CR, LF and every other C0 control character, plus DEL (the
|
||||
// C0 range is code points 0 through 31; DEL is 127). Written as a numeric
|
||||
// comparison rather than a control-character regex literal so the source
|
||||
// never has to embed a raw control character or an escape sequence for one.
|
||||
const LAST_C0_CODE = 31;
|
||||
const DEL_CODE = 127;
|
||||
function isControlCharCode(code: number): boolean {
|
||||
return code <= LAST_C0_CODE || code === DEL_CODE;
|
||||
}
|
||||
|
||||
// Strips CR, LF and other control characters from a string, replacing each
|
||||
// with a single space. The endpoint is unauthenticated, so without this a
|
||||
// caller could embed a newline in any field to forge what looks like a
|
||||
// second [client-error] line in the shared server log. The replacement is
|
||||
// 1-for-1 (one control character becomes one space), so it cannot change
|
||||
// the string's length either way.
|
||||
function sanitize(value: string): string {
|
||||
let result = '';
|
||||
for (const char of value) {
|
||||
result += isControlCharCode(char.codePointAt(0) ?? 0) ? ' ' : char;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Anything that is not a string becomes empty rather than 'undefined' or
|
||||
// '[object Object]', so a malformed field cannot dress itself up as content.
|
||||
function clip(value: unknown, max: number): string {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
// Sanitize before truncating, not after. Because the substitution above is
|
||||
// 1-for-1, sanitizing first cannot push the stored length past `max` — an
|
||||
// escaping scheme that expanded a control character into multiple visible
|
||||
// characters would need the opposite order to keep that same guarantee, so
|
||||
// the two are not interchangeable and must not be reordered without
|
||||
// re-checking this.
|
||||
const sanitized = sanitize(value);
|
||||
return sanitized.length > max ? `${sanitized.slice(0, max)}… [truncated]` : sanitized;
|
||||
}
|
||||
|
||||
// No asyncRoute: this handler is synchronous, so there is no promise for the
|
||||
// error middleware to miss.
|
||||
router.post('/', clientErrorLimiter, (req: Request, res: Response) => {
|
||||
const context: unknown = req.body?.context;
|
||||
if (typeof context !== 'string' || !CONTEXTS.includes(context)) {
|
||||
return res.status(400).json({ error: 'invalid context' });
|
||||
}
|
||||
|
||||
console.error(
|
||||
`[client-error] context=${context} path=${clip(req.body?.path, MAX_PATH)}\n` +
|
||||
` message: ${clip(req.body?.message, MAX_MESSAGE)}\n` +
|
||||
` stack: ${clip(req.body?.stack, MAX_STACK)}\n` +
|
||||
` componentStack: ${clip(req.body?.componentStack, MAX_COMPONENT_STACK)}`
|
||||
);
|
||||
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
+84
-478
@@ -1,238 +1,84 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { PASSWORD_HASH_ROUNDS, passwordMatches } from '../passwordHashing';
|
||||
import crypto from 'node:crypto';
|
||||
import { pool, requireRow } from '../db';
|
||||
import { pool } from '../db';
|
||||
import { requireCustomer } from '../middleware/customerAuth';
|
||||
import { sendMail } from '../mailer';
|
||||
import { renderTemplate, greeting, formatDuration } from '../emailTemplates';
|
||||
import { getSettings } from '../adminSettings';
|
||||
import { loadStoredTemplate } from './adminEmailTemplates';
|
||||
import { ANALYTICS_CONSENT_TEXT, MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
|
||||
import { ItemStatus } from '../types';
|
||||
import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
|
||||
import { FAVORITE_ALERTS_CONSENT_TEXT } from '../favoriteAlerts';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { passwordResetRequestLimiter, verificationResendLimiter } from '../rateLimit';
|
||||
// Shared with passkey sign-in, so both paths establish a session identically
|
||||
// rather than in two places that merely agree today (#39).
|
||||
import { setSessionCookie, createSession } from '../customerSession';
|
||||
// Registration, a resend, the customer changing their own address and the shop
|
||||
// changing it for them all need the same three steps, and they now live in one
|
||||
// place for the same reason session creation does (#337).
|
||||
import { issueVerificationEmail } from '../customerVerification';
|
||||
import { passwordResetRequestLimiter } from '../rateLimit';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// setSessionCookie and createSession now live in ../customerSession, shared with
|
||||
// passkey sign-in. #39 requires that path to establish a session identically to
|
||||
// this one, and sharing the code is what makes that true rather than intended.
|
||||
const SESSION_DAYS = 30;
|
||||
|
||||
// The subset of a customers row that is safe to return to the customer it
|
||||
// belongs to. Typed as its own shape rather than `any` so that adding a column
|
||||
// to the table — a password hash, a token, an internal note — cannot silently
|
||||
// start being echoed back by a `...c` somewhere downstream.
|
||||
interface CustomerRow {
|
||||
id: number;
|
||||
email: string;
|
||||
// Nullable despite registration requiring both, because customers who
|
||||
// registered while the field was optional genuinely have no name. The
|
||||
// requirement is enforced at registration, not asserted by the schema.
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
email_verified: boolean;
|
||||
marketing_consent: boolean;
|
||||
favorite_alerts: boolean;
|
||||
created_at: Date;
|
||||
function setSessionCookie(res: Response, token: string) {
|
||||
res.cookie('rd_session', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: SESSION_DAYS * 24 * 60 * 60 * 1000
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole `customers` row, as `SELECT *` returns it.
|
||||
*
|
||||
* Extends CustomerRow rather than restating it, so the relationship is the one
|
||||
* that actually holds: everything safe to return is also on the record, and the
|
||||
* fields below are the ones that are not. Adding a column to the table means
|
||||
* adding it here and deciding, at that moment, whether it belongs in
|
||||
* CustomerRow too — which is the decision the comment above is about.
|
||||
*
|
||||
* Kept in step with the schema by hand; nothing checks this against Postgres.
|
||||
*/
|
||||
interface CustomerRecord extends CustomerRow {
|
||||
password_hash: string | null;
|
||||
disabled_at: Date | null;
|
||||
unsubscribe_token: string;
|
||||
marketing_consent_at: Date | null;
|
||||
marketing_consent_text: string | null;
|
||||
// A separate purpose from marketing, so a separate column, timestamp and
|
||||
// stored wording rather than a second meaning layered onto the pair above.
|
||||
// False for every customer the migration touched: none of them was asked.
|
||||
analytics_consent: boolean;
|
||||
analytics_consent_at: Date | null;
|
||||
analytics_consent_text: string | null;
|
||||
favorite_alerts_at: Date | null;
|
||||
favorite_alerts_text: string | null;
|
||||
async function createSession(customerId: number): Promise<string> {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
|
||||
await pool.query(
|
||||
`INSERT INTO customer_sessions (token, customer_id, expires_at) VALUES ($1, $2, $3)`,
|
||||
[token, customerId, expiresAt]
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Rows that are only ever probed for existence. */
|
||||
interface IdRow {
|
||||
id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single-use link. `kind` distinguishes verification from password reset;
|
||||
* both are read the same way and both are deleted once spent.
|
||||
*/
|
||||
interface CustomerTokenRow {
|
||||
token: string;
|
||||
customer_id: number;
|
||||
kind: string;
|
||||
expires_at: Date;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
/** Just the flag the disabled check reads. */
|
||||
interface DisabledAtRow {
|
||||
disabled_at: Date | null;
|
||||
}
|
||||
|
||||
/** A favorited item as the account page lists it. */
|
||||
interface FavoriteRow {
|
||||
item_id: number;
|
||||
created_at: Date;
|
||||
name: string;
|
||||
status: ItemStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole `orders` row, as the data export returns it.
|
||||
*
|
||||
* Worth reading before changing the export: `raw_event` is the processor's
|
||||
* entire capture payload, and this route sends every column of this row to the
|
||||
* customer verbatim. That is defensible for a GDPR export — it is their
|
||||
* transaction — but it is a decision rather than an accident, and typing it is
|
||||
* what makes it visible. The order-history route above deliberately selects six
|
||||
* named columns instead.
|
||||
*/
|
||||
interface OrderRecord {
|
||||
id: number;
|
||||
item_id: number | null;
|
||||
customer_id: number | null;
|
||||
checkout_id: number | null;
|
||||
processor: string;
|
||||
processor_order_id: string | null;
|
||||
amount_cents: number | null;
|
||||
status: string | null;
|
||||
raw_event: unknown;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
/** One line of a customer's own order history. */
|
||||
interface CustomerOrderRow {
|
||||
id: number;
|
||||
processor: string;
|
||||
amount_cents: number;
|
||||
status: string;
|
||||
created_at: Date;
|
||||
item_name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this customer has agreed to the *current* analytics wording, which is
|
||||
* the only thing that authorises the Brevo tracker (#56).
|
||||
*
|
||||
* Reads the analytics columns and nothing else. It must never consult
|
||||
* `marketing_consent`: those are two purposes with two recipients, and GDPR
|
||||
* requires consent to be granular — a customer who wants the emails and not the
|
||||
* tracking has to be able to have exactly that. Quebec's Law 25 s.8.1 is
|
||||
* stricter again and requires this to be off until the customer switches it on,
|
||||
* which is why the column defaults to false.
|
||||
*
|
||||
* Comparing the stored string is the point rather than an implementation
|
||||
* detail. The flag says a customer agreed to something; the text says what. If
|
||||
* the sentence is ever re-worded, everyone who agreed to the previous one stops
|
||||
* qualifying and is asked again, rather than being silently carried into a
|
||||
* broader agreement they never saw.
|
||||
*
|
||||
* Computed here rather than stored, so it can never drift from the constant.
|
||||
*
|
||||
* Exported for the unit test, and narrowed to the two fields it actually reads
|
||||
* rather than taking a whole CustomerRecord — the rule is about those two and
|
||||
* nothing else, and a test should not have to invent a customer to state it.
|
||||
*/
|
||||
export function analyticsConsent(
|
||||
c: Pick<CustomerRecord, 'analytics_consent' | 'analytics_consent_text'>
|
||||
): boolean {
|
||||
return c.analytics_consent && c.analytics_consent_text === ANALYTICS_CONSENT_TEXT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a CustomerRecord rather than a CustomerRow because `analytics_consent`
|
||||
* is derived from `marketing_consent_text`, which is not on the narrower type.
|
||||
* Every caller already holds a full record — each query is `SELECT *`.
|
||||
*/
|
||||
function publicCustomer(c: CustomerRecord) {
|
||||
function publicCustomer(c: any) {
|
||||
return {
|
||||
id: c.id,
|
||||
email: c.email,
|
||||
first_name: c.first_name,
|
||||
last_name: c.last_name,
|
||||
name: c.name,
|
||||
email_verified: c.email_verified,
|
||||
marketing_consent: c.marketing_consent,
|
||||
// Its own purpose, its own answer. A customer can have either, both, or
|
||||
// neither, and the UI has to be able to show that honestly.
|
||||
analytics_consent: analyticsConsent(c),
|
||||
favorite_alerts: c.favorite_alerts,
|
||||
// Whether, not what (#344). A customer who signed up with Google has none,
|
||||
// and the account page has to be able to say so — offering "change your
|
||||
// password" to somebody who has never had one is a dead end, and saying
|
||||
// nothing leaves them unable to see a credential they are entitled to
|
||||
// manage. A boolean is the whole of what the UI needs, and the hash itself
|
||||
// must never leave this function.
|
||||
has_password: c.password_hash !== null,
|
||||
created_at: c.created_at
|
||||
};
|
||||
}
|
||||
|
||||
router.post('/register', asyncRoute(async (req: Request, res: Response) => {
|
||||
const { email, password, firstName, lastName, marketingConsent, analyticsConsent: analyticsConsentGiven } = req.body;
|
||||
const { email, password, name, marketingConsent } = req.body;
|
||||
if (!email || !isValidEmail(String(email)) || !password || String(password).length < 8) {
|
||||
return res.status(400).json({ error: 'valid email and password (min 8 chars) required' });
|
||||
}
|
||||
// Named individually rather than as one "name is required", so a form that
|
||||
// filled one field and not the other is told which.
|
||||
const first = String(firstName ?? '').trim();
|
||||
const last = String(lastName ?? '').trim();
|
||||
if (!first) {
|
||||
return res.status(400).json({ error: 'first name is required' });
|
||||
}
|
||||
if (!last) {
|
||||
return res.status(400).json({ error: 'last name is required' });
|
||||
}
|
||||
const normalizedEmail = String(email).toLowerCase().trim();
|
||||
const { rows: existing } = await pool.query<IdRow>(`SELECT id FROM customers WHERE email = $1`, [normalizedEmail]);
|
||||
const { rows: existing } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [normalizedEmail]);
|
||||
if (existing.length) return res.status(409).json({ error: 'an account with this email already exists' });
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, PASSWORD_HASH_ROUNDS);
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const unsubscribeToken = crypto.randomBytes(16).toString('hex');
|
||||
const consent = !!marketingConsent;
|
||||
// Read independently of marketingConsent, and absent means false. A client
|
||||
// that sends neither, or only the marketing one, registers a customer who is
|
||||
// not tracked — which is the right answer for a request that never carried an
|
||||
// analytics answer at all.
|
||||
const analytics = !!analyticsConsentGiven;
|
||||
|
||||
const { rows } = await pool.query<CustomerRecord>(
|
||||
`INSERT INTO customers (email, password_hash, first_name, last_name, marketing_consent, marketing_consent_at, marketing_consent_text, analytics_consent, analytics_consent_at, analytics_consent_text, unsubscribe_token)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO customers (email, password_hash, name, marketing_consent, marketing_consent_at, marketing_consent_text, unsubscribe_token)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
[
|
||||
normalizedEmail, passwordHash, first, last,
|
||||
normalizedEmail, passwordHash, name || null,
|
||||
consent, consent ? new Date() : null, consent ? MARKETING_CONSENT_TEXT : null,
|
||||
analytics, analytics ? new Date() : null, analytics ? ANALYTICS_CONSENT_TEXT : null,
|
||||
unsubscribeToken
|
||||
]
|
||||
);
|
||||
const customer = requireRow(rows, 'the registration INSERT');
|
||||
const customer = rows[0];
|
||||
|
||||
await issueVerificationEmail(customer.id, customer.email, customer.first_name, customer.last_name);
|
||||
const verifyToken = crypto.randomBytes(24).toString('hex');
|
||||
await pool.query(
|
||||
`INSERT INTO customer_tokens (token, customer_id, kind, expires_at) VALUES ($1, $2, 'verify_email', $3)`,
|
||||
[verifyToken, customer.id, new Date(Date.now() + 24 * 60 * 60 * 1000)]
|
||||
);
|
||||
const verifyUrl = `${process.env.PUBLIC_URL}/verify-email?token=${verifyToken}`;
|
||||
sendMail(
|
||||
customer.email,
|
||||
'Verify your Redefined Designs account',
|
||||
`<p>Welcome! Please <a href="${verifyUrl}">verify your email</a> to finish setting up your account.</p>`
|
||||
).catch(err => console.error('verify email send failed', err));
|
||||
|
||||
const sessionToken = await createSession(customer.id);
|
||||
setSessionCookie(res, sessionToken);
|
||||
@@ -241,40 +87,17 @@ router.post('/register', asyncRoute(async (req: Request, res: Response) => {
|
||||
|
||||
router.post('/verify-email', asyncRoute(async (req: Request, res: Response) => {
|
||||
const { token } = req.body;
|
||||
const { rows } = await pool.query<CustomerTokenRow>(
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'verify_email' AND expires_at > now()`,
|
||||
[token]
|
||||
);
|
||||
const [verifyToken] = rows;
|
||||
if (!verifyToken) return res.status(400).json({ error: 'invalid or expired token' });
|
||||
await pool.query(`UPDATE customers SET email_verified = true WHERE id = $1`, [verifyToken.customer_id]);
|
||||
if (!rows.length) return res.status(400).json({ error: 'invalid or expired token' });
|
||||
await pool.query(`UPDATE customers SET email_verified = true WHERE id = $1`, [rows[0].customer_id]);
|
||||
await pool.query(`DELETE FROM customer_tokens WHERE token = $1`, [token]);
|
||||
res.json({ status: 'verified' });
|
||||
}));
|
||||
|
||||
// The limiter is mounted after requireCustomer, deliberately: it keys on
|
||||
// req.customerId, which does not exist until requireCustomer has run. Mounted
|
||||
// the other way round every anonymous caller would share one bucket.
|
||||
router.post(
|
||||
'/resend-verification',
|
||||
requireCustomer,
|
||||
verificationResendLimiter,
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
||||
// requireCustomer has already matched this id against a live session.
|
||||
const customer = requireRow(rows, 'the signed-in customer');
|
||||
|
||||
// Refused rather than quietly sending. A pointless email is worse than an
|
||||
// answer, and the account page has no reason to offer the button here.
|
||||
if (customer.email_verified) {
|
||||
return res.status(400).json({ error: 'your email address is already verified' });
|
||||
}
|
||||
|
||||
await issueVerificationEmail(customer.id, customer.email, customer.first_name, customer.last_name);
|
||||
res.status(204).end();
|
||||
})
|
||||
);
|
||||
|
||||
const RESET_TOKEN_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
// Always answers 200, whether or not the address has an account. A response
|
||||
// that differed would let anyone test addresses for membership.
|
||||
@@ -287,7 +110,7 @@ router.post('/request-password-reset', passwordResetRequestLimiter, asyncRoute(a
|
||||
return res.status(400).json({ error: 'a valid email is required' });
|
||||
}
|
||||
|
||||
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE email = $1`, [email]);
|
||||
const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [email]);
|
||||
const customer = rows[0];
|
||||
|
||||
if (customer && !customer.disabled_at) {
|
||||
@@ -295,23 +118,20 @@ router.post('/request-password-reset', passwordResetRequestLimiter, asyncRoute(a
|
||||
// from an older message in the customer's inbox.
|
||||
await pool.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [customer.id]);
|
||||
|
||||
const { passwordResetHours, greetingFormat, greetingFallback } = await getSettings();
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
await pool.query(
|
||||
`INSERT INTO customer_tokens (token, customer_id, kind, expires_at) VALUES ($1, $2, 'password_reset', $3)`,
|
||||
[token, customer.id, new Date(Date.now() + passwordResetHours * 60 * 60 * 1000)]
|
||||
[token, customer.id, new Date(Date.now() + RESET_TOKEN_TTL_MS)]
|
||||
);
|
||||
|
||||
const resetUrl = `${process.env.PUBLIC_URL}/reset-password?token=${token}`;
|
||||
const resetTemplate = renderTemplate('passwordReset', await loadStoredTemplate('passwordReset'), {
|
||||
greeting: greeting(customer.first_name, greetingFormat, greetingFallback, customer.last_name),
|
||||
firstName: customer.first_name ?? '',
|
||||
lastName: customer.last_name ?? '',
|
||||
resetUrl,
|
||||
expiresIn: formatDuration(passwordResetHours)
|
||||
});
|
||||
sendMail(customer.email, resetTemplate.subject, resetTemplate.html)
|
||||
.catch(err => console.error('password reset email send failed', err));
|
||||
sendMail(
|
||||
customer.email,
|
||||
'Reset your Redefined Designs password',
|
||||
`<p>Someone asked to reset the password for this account.</p>
|
||||
<p><a href="${resetUrl}">Choose a new password</a>. This link expires in one hour.</p>
|
||||
<p>If this wasn't you, you can ignore this email — your password has not changed.</p>`
|
||||
).catch(err => console.error('password reset email send failed', err));
|
||||
}
|
||||
|
||||
res.json({ status: 'sent' });
|
||||
@@ -331,27 +151,21 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
|
||||
return res.status(400).json({ error: 'password must be at least 8 characters' });
|
||||
}
|
||||
|
||||
const { rows } = await pool.query<CustomerTokenRow>(
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'password_reset' AND expires_at > now()`,
|
||||
[token]
|
||||
);
|
||||
const [resetToken] = rows;
|
||||
if (!resetToken) return res.status(400).json({ error: 'invalid or expired token' });
|
||||
const customerId = resetToken.customer_id;
|
||||
if (!rows.length) return res.status(400).json({ error: 'invalid or expired token' });
|
||||
const customerId = rows[0].customer_id;
|
||||
|
||||
// A token issued before the account was disabled would otherwise still mint a
|
||||
// fresh session.
|
||||
const { rows: owner } = await pool.query<DisabledAtRow>(`SELECT disabled_at FROM customers WHERE id = $1`, [customerId]);
|
||||
const { rows: owner } = await pool.query(`SELECT disabled_at FROM customers WHERE id = $1`, [customerId]);
|
||||
if (owner[0]?.disabled_at) {
|
||||
return res.status(403).json({ error: 'this account has been disabled' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(String(password), PASSWORD_HASH_ROUNDS);
|
||||
|
||||
// Reported back so the customer is told, rather than finding an empty list
|
||||
// the next time they look. Declared out here because it is decided inside the
|
||||
// transaction and read after it.
|
||||
let passkeysRemoved = 0;
|
||||
const passwordHash = await bcrypt.hash(String(password), 12);
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
@@ -368,37 +182,6 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
|
||||
// up to 30 days.
|
||||
await client.query(`DELETE FROM customer_sessions WHERE customer_id = $1`, [customerId]);
|
||||
await client.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [customerId]);
|
||||
|
||||
// Passkeys go with the sessions, for the same reason and more of it (#42).
|
||||
//
|
||||
// A reset is the recovery path, and recovery has to be complete. The line
|
||||
// above already takes the position that a reset must evict anyone else
|
||||
// holding the account — a session an intruder holds lasts up to 30 days, and
|
||||
// a passkey an intruder registered lasts forever. Leaving those behind would
|
||||
// mean a customer can recover their password and still not have their
|
||||
// account back.
|
||||
//
|
||||
// The obvious objection is that this lets whoever controls the mailbox strip
|
||||
// a customer's passkeys. It does, and it costs nothing: anyone who can
|
||||
// complete a reset already controls the email address, and therefore already
|
||||
// controls the account. The passkeys were not protecting anything at that
|
||||
// point.
|
||||
//
|
||||
// Deliberately NOT the same rule as change-password, which leaves passkeys
|
||||
// alone. That one requires the current password from someone already signed
|
||||
// in — no part of it suggests a lockout or a compromise, and a customer who
|
||||
// suspects one device can revoke that device by name on the account page
|
||||
// (#40). This path has no idea which credential is the problem, so it takes
|
||||
// all of them.
|
||||
const removed = await client.query(`DELETE FROM customer_credentials WHERE customer_id = $1`, [customerId]);
|
||||
passkeysRemoved = removed.rowCount ?? 0;
|
||||
|
||||
// Including anything in flight. A registration challenge issued to an
|
||||
// intruder moments before the reset would otherwise still be completable
|
||||
// afterwards, which would put a passkey back on the account the reset just
|
||||
// cleared.
|
||||
await client.query(`DELETE FROM webauthn_challenges WHERE customer_id = $1`, [customerId]);
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
@@ -407,25 +190,17 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
|
||||
client.release();
|
||||
}
|
||||
|
||||
const { rows: fresh } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [customerId]);
|
||||
const { rows: fresh } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [customerId]);
|
||||
const sessionToken = await createSession(customerId);
|
||||
setSessionCookie(res, sessionToken);
|
||||
// The count rides along with the customer rather than being left for the
|
||||
// account page to imply. A customer who never registered a passkey sees zero
|
||||
// and is told nothing; one who is told two were removed and only remembers
|
||||
// registering one has just learned something they could not otherwise find
|
||||
// out — the row is already gone by the time they could go looking.
|
||||
res.json({
|
||||
...publicCustomer(requireRow(fresh, 'the customer whose password was just reset')),
|
||||
passkeysRemoved
|
||||
});
|
||||
res.json(publicCustomer(fresh[0]));
|
||||
}));
|
||||
|
||||
router.post('/login', asyncRoute(async (req: Request, res: Response) => {
|
||||
const { email, password } = req.body;
|
||||
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE email = $1`, [String(email || '').toLowerCase().trim()]);
|
||||
const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [String(email || '').toLowerCase().trim()]);
|
||||
const customer = rows[0];
|
||||
if (!customer || !(await passwordMatches(password, customer.password_hash))) {
|
||||
if (!customer || !(await bcrypt.compare(password || '', customer.password_hash))) {
|
||||
return res.status(401).json({ error: 'invalid email or password' });
|
||||
}
|
||||
// Only after the password checks out, so a wrong password still looks like a
|
||||
@@ -446,7 +221,7 @@ router.post('/logout', asyncRoute(async (req: Request, res: Response) => {
|
||||
}));
|
||||
|
||||
router.get('/me/favorites', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query<FavoriteRow>(
|
||||
const { rows } = await pool.query(
|
||||
`SELECT f.item_id, f.created_at, i.name, i.status
|
||||
FROM favorites f JOIN items i ON i.id = f.item_id
|
||||
WHERE f.customer_id = $1
|
||||
@@ -457,7 +232,7 @@ router.get('/me/favorites', requireCustomer, asyncRoute(async (req: Request, res
|
||||
}));
|
||||
|
||||
router.post('/me/favorites/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows: item } = await pool.query<IdRow>(`SELECT id FROM items WHERE id = $1`, [req.params.itemId]);
|
||||
const { rows: item } = await pool.query(`SELECT id FROM items WHERE id = $1`, [req.params.itemId]);
|
||||
if (!item.length) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
// Idempotent: a double click, or two tabs, must not be an error.
|
||||
@@ -479,7 +254,7 @@ router.delete('/me/favorites/:itemId', requireCustomer, asyncRoute(async (req: R
|
||||
// so the record says what was actually agreed to.
|
||||
router.put('/me/favorite-alerts', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const enabled = !!req.body?.enabled;
|
||||
const { rows } = await pool.query<CustomerRecord>(
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE customers
|
||||
SET favorite_alerts = $1,
|
||||
favorite_alerts_at = $2,
|
||||
@@ -487,36 +262,22 @@ router.put('/me/favorite-alerts', requireCustomer, asyncRoute(async (req: Reques
|
||||
WHERE id = $4 RETURNING *`,
|
||||
[enabled, enabled ? new Date() : null, enabled ? FAVORITE_ALERTS_CONSENT_TEXT : null, req.customerId]
|
||||
);
|
||||
res.json(publicCustomer(requireRow(rows, 'the favorite-alerts UPDATE')));
|
||||
res.json(publicCustomer(rows[0]));
|
||||
}));
|
||||
|
||||
router.get('/me', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const [customer] = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]).then(r => r.rows);
|
||||
if (!customer) return res.status(404).json({ error: 'not found' });
|
||||
res.json(publicCustomer(customer));
|
||||
const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
res.json(publicCustomer(rows[0]));
|
||||
}));
|
||||
|
||||
// Kept in step with registration for consistency. Note nothing in the frontend
|
||||
// calls this today — the account page has no name editing — so this is API
|
||||
// surface without a caller rather than a path in use.
|
||||
router.put('/me', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { firstName, lastName } = req.body;
|
||||
// Registration demands both and refuses each by name. Accepting empty values
|
||||
// here would let a customer clear fields they could not have skipped when
|
||||
// signing up, which is the same rule disagreeing with itself.
|
||||
const first = String(firstName ?? '').trim();
|
||||
const last = String(lastName ?? '').trim();
|
||||
if (!first) {
|
||||
return res.status(400).json({ error: 'first name is required' });
|
||||
}
|
||||
if (!last) {
|
||||
return res.status(400).json({ error: 'last name is required' });
|
||||
}
|
||||
const { rows } = await pool.query<CustomerRecord>(
|
||||
`UPDATE customers SET first_name = $1, last_name = $2 WHERE id = $3 RETURNING *`,
|
||||
[first, last, req.customerId]
|
||||
const { name } = req.body;
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE customers SET name = $1 WHERE id = $2 RETURNING *`,
|
||||
[name || null, req.customerId]
|
||||
);
|
||||
res.json(publicCustomer(requireRow(rows, 'the name UPDATE')));
|
||||
res.json(publicCustomer(rows[0]));
|
||||
}));
|
||||
|
||||
router.post('/change-password', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
@@ -524,117 +285,14 @@ router.post('/change-password', requireCustomer, asyncRoute(async (req: Request,
|
||||
if (!newPassword || String(newPassword).length < 8) {
|
||||
return res.status(400).json({ error: 'new password must be at least 8 characters' });
|
||||
}
|
||||
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
||||
const customer = requireRow(rows, 'the signed-in customer');
|
||||
|
||||
// Setting the first password and changing an existing one, in one route
|
||||
// rather than two (#344).
|
||||
//
|
||||
// A customer who signed up with Google has no password, so there is nothing
|
||||
// to compare against and asking for one would be a dead end — they cannot
|
||||
// supply a value that was never set. What authorises the change is the
|
||||
// session they are already holding, which is the same thing that authorises
|
||||
// every other setting on the account page.
|
||||
//
|
||||
// One route because two would be two places to get the guard wrong, and the
|
||||
// one that would be forgotten is whichever is not on the path exercised by
|
||||
// hand. The branch is on the stored hash rather than on anything the caller
|
||||
// sends, so a request cannot talk its way into the first-password case.
|
||||
if (customer.password_hash !== null) {
|
||||
if (!(await passwordMatches(currentPassword, customer.password_hash))) {
|
||||
return res.status(401).json({ error: 'current password is incorrect' });
|
||||
}
|
||||
}
|
||||
|
||||
const newHash = await bcrypt.hash(newPassword, PASSWORD_HASH_ROUNDS);
|
||||
await pool.query(`UPDATE customers SET password_hash = $1 WHERE id = $2`, [newHash, req.customerId]);
|
||||
|
||||
// Password reset already ends every session, on the reasoning that a password
|
||||
// is changed precisely when the old one may be known to someone else. A
|
||||
// change left the other sessions alive, which is the same reasoning reaching
|
||||
// the opposite conclusion for no recorded reason. The current session is
|
||||
// spared so the change does not eject the person making it.
|
||||
await pool.query(
|
||||
`DELETE FROM customer_sessions WHERE customer_id = $1 AND token <> $2`,
|
||||
[req.customerId, req.cookies?.rd_session ?? '']
|
||||
);
|
||||
|
||||
res.status(204).end();
|
||||
}));
|
||||
|
||||
// Changing the address a password reset goes to is how an account is taken
|
||||
// over, so this asks for the current password exactly as change-password does.
|
||||
// A live session alone is not enough.
|
||||
router.put('/me/email', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { currentPassword, email } = req.body;
|
||||
|
||||
const normalized = String(email ?? '').toLowerCase().trim();
|
||||
if (!normalized || !isValidEmail(normalized)) {
|
||||
return res.status(400).json({ error: 'a valid email is required' });
|
||||
}
|
||||
|
||||
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
||||
const customer = requireRow(rows, 'the signed-in customer');
|
||||
|
||||
// A customer with no password is refused here rather than waved through, and
|
||||
// the asymmetry with change-password above is deliberate (#344).
|
||||
//
|
||||
// Setting a first password is a change to a credential the customer already
|
||||
// controls. Changing the email address is a change to *where recovery goes* —
|
||||
// whoever holds the new address can reset the password and own the account
|
||||
// outright. That is why this route has always demanded more than a live
|
||||
// session, and dropping the demand for the accounts that cannot meet it would
|
||||
// remove the protection from exactly the ones that need it.
|
||||
//
|
||||
// So the message says the real thing and gives them the route out, rather
|
||||
// than claiming a password was wrong when there is no password at all.
|
||||
if (customer.password_hash === null) {
|
||||
return res.status(409).json({
|
||||
error: 'this account has no password — set one first, then you can change your email address'
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await passwordMatches(currentPassword, customer.password_hash))) {
|
||||
const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
||||
const customer = rows[0];
|
||||
if (!(await bcrypt.compare(currentPassword || '', customer.password_hash))) {
|
||||
return res.status(401).json({ error: 'current password is incorrect' });
|
||||
}
|
||||
|
||||
if (normalized === customer.email) {
|
||||
return res.status(400).json({ error: 'that is already your email address' });
|
||||
}
|
||||
|
||||
const { rows: taken } = await pool.query<IdRow>(`SELECT id FROM customers WHERE email = $1`, [normalized]);
|
||||
if (taken.length) {
|
||||
return res.status(409).json({ error: 'an account with this email already exists' });
|
||||
}
|
||||
|
||||
// Captured before the update, because it is where the notice has to go.
|
||||
const previousEmail = customer.email;
|
||||
|
||||
await pool.query(
|
||||
`UPDATE customers SET email = $1, email_verified = false WHERE id = $2`,
|
||||
[normalized, req.customerId]
|
||||
);
|
||||
|
||||
// Supersedes any outstanding link as part of issuing the new one, so a
|
||||
// message already sitting in the old inbox cannot verify the new address.
|
||||
//
|
||||
// Both sends happen after the row is written, never before — the same rule
|
||||
// favoriteAlerts follows, so a change that failed cannot produce mail saying
|
||||
// it succeeded.
|
||||
await issueVerificationEmail(req.customerId as number, normalized, customer.first_name, customer.last_name);
|
||||
|
||||
const { greetingFormat, greetingFallback } = await getSettings();
|
||||
const notice = renderTemplate('emailChanged', await loadStoredTemplate('emailChanged'), {
|
||||
greeting: greeting(customer.first_name, greetingFormat, greetingFallback, customer.last_name),
|
||||
firstName: customer.first_name ?? '',
|
||||
lastName: customer.last_name ?? '',
|
||||
newEmail: normalized
|
||||
});
|
||||
sendMail(previousEmail, notice.subject, notice.html)
|
||||
.catch(err => console.error('email change notice send failed', err));
|
||||
|
||||
const { rows: updated } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
||||
res.json(publicCustomer(requireRow(updated, 'the customer after the email change')));
|
||||
const newHash = await bcrypt.hash(newPassword, 12);
|
||||
await pool.query(`UPDATE customers SET password_hash = $1 WHERE id = $2`, [newHash, req.customerId]);
|
||||
res.status(204).end();
|
||||
}));
|
||||
|
||||
router.post('/me/consent', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
@@ -646,60 +304,8 @@ router.post('/me/consent', requireCustomer, asyncRoute(async (req: Request, res:
|
||||
res.status(204).end();
|
||||
}));
|
||||
|
||||
/**
|
||||
* Analytics consent, on its own route rather than as a second field on
|
||||
* `/me/consent` (#56).
|
||||
*
|
||||
* Separate because the two are separate purposes and must be separately
|
||||
* refusable. One endpoint taking both would make it possible for a single call
|
||||
* to change an answer the customer did not touch — which is the bundling
|
||||
* problem again, moved from the form into the API.
|
||||
*
|
||||
* Withdrawal writes the reason rather than the consent sentence, so the stored
|
||||
* text never claims agreement to something that was declined. Same convention
|
||||
* as marketing consent above.
|
||||
*/
|
||||
router.post('/me/analytics-consent', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const consent = !!req.body.analyticsConsent;
|
||||
await pool.query(
|
||||
`UPDATE customers SET analytics_consent = $1, analytics_consent_at = now(), analytics_consent_text = $2 WHERE id = $3`,
|
||||
[consent, consent ? ANALYTICS_CONSENT_TEXT : 'Withdrew analytics consent via account settings', req.customerId]
|
||||
);
|
||||
res.status(204).end();
|
||||
}));
|
||||
|
||||
/**
|
||||
* Which identity providers this account is signed in with (#343).
|
||||
*
|
||||
* Linking happens automatically when Google vouches for an address that already
|
||||
* has an account, which is defensible but not obvious. A customer who signed up
|
||||
* with a password and later used Google has had two credentials joined without
|
||||
* being asked, and a silent link is indistinguishable from a bug when they
|
||||
* later wonder why the password is no longer needed.
|
||||
*
|
||||
* So it is shown, beside the passkeys, for the reason the passkey list exists
|
||||
* at all: a customer cannot manage credentials they cannot see.
|
||||
*
|
||||
* No unlinking yet. Removing the only way into an account is the question #344
|
||||
* settles, and offering the button before that check runs would be the fastest
|
||||
* possible way to lock somebody out of their own orders.
|
||||
*/
|
||||
router.get('/me/identities', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query<{ provider: string; created_at: Date; last_used_at: Date | null }>(
|
||||
// No provider_sub. The customer cannot act on it, and it is the one value
|
||||
// that identifies them to the provider — the same reasoning that keeps
|
||||
// credential ids out of the passkey list.
|
||||
`SELECT provider, created_at, last_used_at
|
||||
FROM customer_identities
|
||||
WHERE customer_id = $1
|
||||
ORDER BY created_at`,
|
||||
[req.customerId]
|
||||
);
|
||||
res.json(rows);
|
||||
}));
|
||||
|
||||
router.get('/me/orders', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query<CustomerOrderRow>(
|
||||
const { rows } = await pool.query(
|
||||
`SELECT o.id, o.processor, o.amount_cents, o.status, o.created_at, i.name AS item_name
|
||||
FROM orders o JOIN items i ON i.id = o.item_id
|
||||
WHERE o.customer_id = $1 ORDER BY o.created_at DESC`,
|
||||
@@ -709,11 +315,11 @@ router.get('/me/orders', requireCustomer, asyncRoute(async (req: Request, res: R
|
||||
}));
|
||||
|
||||
router.get('/me/export', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows: customerRows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
||||
const { rows: orderRows } = await pool.query<OrderRecord>(`SELECT * FROM orders WHERE customer_id = $1`, [req.customerId]);
|
||||
const { rows: customerRows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
||||
const { rows: orderRows } = await pool.query(`SELECT * FROM orders WHERE customer_id = $1`, [req.customerId]);
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="my-data.json"');
|
||||
res.json({
|
||||
customer: publicCustomer(requireRow(customerRows, 'the signed-in customer')),
|
||||
customer: publicCustomer(customerRows[0]),
|
||||
orders: orderRows,
|
||||
exported_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
@@ -12,26 +12,18 @@ router.get('/', asyncRoute(async (_req: Request, res: Response) => {
|
||||
pool.query(
|
||||
`SELECT id, name, parent_id, sort_order FROM categories ORDER BY sort_order, lower(name)`
|
||||
),
|
||||
// Pending items are excluded from the count, not just from the catalogue.
|
||||
// Counting them would show a customer a tag reading "Rare (1)", and
|
||||
// filtering by it would then report that nothing matches.
|
||||
pool.query(
|
||||
`SELECT t.id, t.name, t.color, COUNT(i.id)::int AS item_count
|
||||
`SELECT t.id, t.name, t.color, COUNT(it.item_id)::int AS item_count
|
||||
FROM tags t
|
||||
LEFT JOIN item_tags it ON it.tag_id = t.id
|
||||
LEFT JOIN items i ON i.id = it.item_id AND i.status <> 'pending'
|
||||
GROUP BY t.id
|
||||
ORDER BY lower(t.name)`
|
||||
),
|
||||
// An empty catalogue would otherwise hand the slider a null range.
|
||||
//
|
||||
// Pending items are excluded, or a staged item priced far above or below
|
||||
// everything on sale would stretch the slider to a range no visible item
|
||||
// occupies — the customer drags to the end and finds nothing there.
|
||||
pool.query(
|
||||
`SELECT COALESCE(MIN(price_cents), 0)::int AS min_cents,
|
||||
COALESCE(MAX(price_cents), 0)::int AS max_cents
|
||||
FROM items WHERE status <> 'pending'`
|
||||
FROM items`
|
||||
)
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import crypto from 'node:crypto';
|
||||
import { pool } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { signIn } from '../customerSession';
|
||||
import { googleConfig } from '../google/config';
|
||||
import { newAttempt, authorizationUrl, exchangeCode, verifiedIdentity } from '../google/oauth';
|
||||
import type { GoogleIdentity } from '../google/oauth';
|
||||
import { createCustomerFromGoogle } from '../google/newCustomer';
|
||||
import { linkToExistingCustomer } from '../google/linkIdentity';
|
||||
import { issueVerificationEmail } from '../customerVerification';
|
||||
import type { AttemptSecrets } from '../google/oauth';
|
||||
import { googleSignInLimiter } from '../rateLimit';
|
||||
import { safeReturnTo } from '../google/returnTo';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Signing in with Google (#341).
|
||||
*
|
||||
* Unauthenticated by design — this is how a customer becomes authenticated —
|
||||
* and mounted at `/api/auth/google`, away from `/api/customers`, because it is
|
||||
* the first route in this application that a third party redirects into.
|
||||
*
|
||||
* ## What this does and does not do
|
||||
*
|
||||
* It signs in a customer whose Google identity is already linked, and creates
|
||||
* an account for one nobody here has seen (#342).
|
||||
*
|
||||
* It also joins a Google identity to an account that already holds the same
|
||||
* address — but only when Google vouches for that address (#343). The whole of
|
||||
* that policy lives in `google/linkIdentity.ts`, which is the smallest module
|
||||
* in this feature and the one to read most carefully.
|
||||
*
|
||||
* ## The cookie, and why it is the whole security of the callback
|
||||
*
|
||||
* The callback is a plain GET that anyone on the internet can invoke. What
|
||||
* makes it safe is that it can only complete for a browser holding a cookie
|
||||
* this server set moments earlier, carrying three secrets:
|
||||
*
|
||||
* - **state** proves the callback belongs to the request this browser started
|
||||
* - **nonce** proves the id token was minted for this attempt
|
||||
* - **code verifier** proves the code is being spent by whoever asked for it
|
||||
*
|
||||
* The cookie is cleared on every path through the callback, success or failure,
|
||||
* so one attempt cannot be replayed even once.
|
||||
*/
|
||||
|
||||
/** Ten minutes. Long enough to sign in, short enough that a stolen one is stale. */
|
||||
const ATTEMPT_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
const ATTEMPT_COOKIE = 'rd_oauth';
|
||||
|
||||
interface Attempt extends AttemptSecrets {
|
||||
returnTo: string;
|
||||
}
|
||||
|
||||
interface IdentityRow {
|
||||
customer_id: number;
|
||||
disabled_at: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the customer is sent when this ends.
|
||||
*
|
||||
* Always a redirect, never JSON. The browser arrives here by following Google's
|
||||
* redirect, so whatever this responds with is rendered as a page — and a bare
|
||||
* JSON error is a dead end with no way back to the storefront.
|
||||
*/
|
||||
const FAILURE_PATH = '/login?auth=google-failed';
|
||||
|
||||
/**
|
||||
* Where a customer who has just been created lands.
|
||||
*
|
||||
* A route rather than a flag on the storefront, so it is a page with an address
|
||||
* — reachable again, linkable from the account page later, and rendered by the
|
||||
* same modal-route machinery every other auth screen uses.
|
||||
*/
|
||||
const WELCOME_PATH = '/welcome';
|
||||
|
||||
/**
|
||||
* Where a customer goes when they have an account this sign-in cannot reach.
|
||||
*
|
||||
* Its own destination rather than the generic failure, because it is the one
|
||||
* refusal a customer can act on: the login form reads this and says to sign in
|
||||
* with the password they already have.
|
||||
*/
|
||||
const USE_PASSWORD_PATH = '/login?auth=google-use-password';
|
||||
|
||||
function setAttemptCookie(res: Response, attempt: Attempt): void {
|
||||
res.cookie(ATTEMPT_COOKIE, Buffer.from(JSON.stringify(attempt)).toString('base64url'), {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
// Lax, and NOT Strict. The callback arrives as a top-level navigation from
|
||||
// Google, which is cross-site. Strict withholds the cookie, the state check
|
||||
// then fails, and every sign-in is refused with an error that looks exactly
|
||||
// like tampering. This one line is the single most expensive thing to get
|
||||
// wrong in the whole flow.
|
||||
sameSite: 'lax',
|
||||
maxAge: ATTEMPT_TTL_MS,
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
function readAttemptCookie(req: Request): Attempt | null {
|
||||
const raw = req.cookies?.[ATTEMPT_COOKIE];
|
||||
if (typeof raw !== 'string' || raw === '') return null;
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')) as Partial<Attempt>;
|
||||
if (
|
||||
typeof parsed.state !== 'string' ||
|
||||
typeof parsed.nonce !== 'string' ||
|
||||
typeof parsed.codeVerifier !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
state: parsed.state,
|
||||
nonce: parsed.nonce,
|
||||
codeVerifier: parsed.codeVerifier,
|
||||
returnTo: typeof parsed.returnTo === 'string' ? parsed.returnTo : '/'
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two secrets without leaking where they first differ.
|
||||
*
|
||||
* `timingSafeEqual` throws on buffers of unequal length, and a length check
|
||||
* before it would leak the length, so both are hashed to a fixed 32 bytes
|
||||
* first — the same trick `adminGate` uses, for the same reason.
|
||||
*/
|
||||
function secretsMatch(a: string, b: string): boolean {
|
||||
const digest = (value: string) => crypto.createHash('sha256').update(value, 'utf8').digest();
|
||||
return crypto.timingSafeEqual(digest(a), digest(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* What happens when the identity lookup found nothing: create, link, or refuse.
|
||||
*
|
||||
* A named function rather than an inline block for the reason
|
||||
* `routesAreWrapped.test.ts` cares about, and because the callback is already
|
||||
* the longest handler in this file.
|
||||
*
|
||||
* The order below is the policy from #343, and it is an order rather than a set
|
||||
* of independent checks:
|
||||
*
|
||||
* 1. Nobody has this address — create the account, and land on the consent step
|
||||
* 2. Somebody does, and Google vouches for it — link, and sign in
|
||||
* 3. Somebody does, and Google does not vouch — refuse, and say to use the
|
||||
* password
|
||||
*
|
||||
* The return path is deliberately dropped in case 1 only. That customer lands
|
||||
* on the consent step, which is worth interrupting for: it is the only moment
|
||||
* the two consent sentences can honestly be shown, because the redirect to
|
||||
* Google happened before anyone knew this person was new.
|
||||
*
|
||||
* Carrying the path through as a query parameter was the alternative, and it
|
||||
* was rejected. The consent page would then have to redirect somewhere a URL
|
||||
* told it to, which is the open-redirect question `safeReturnTo` already
|
||||
* answers on the server — asked a second time, in a second language, on a page
|
||||
* an attacker can link to directly. One new customer occasionally landing on
|
||||
* the storefront rather than back at their cart is the cheaper of the two.
|
||||
*/
|
||||
async function signUpOrLink(res: Response, identity: GoogleIdentity, returnTo: string): Promise<void> {
|
||||
const outcome = await createCustomerFromGoogle(identity);
|
||||
|
||||
if (outcome.kind === 'created') {
|
||||
// Only when Google did not vouch for the address. When it did, the customer
|
||||
// has already demonstrated they receive mail there — which is precisely
|
||||
// what the confirmation email exists to establish — so sending one would
|
||||
// ask them to do a thing that is done.
|
||||
if (!identity.emailVerified) {
|
||||
await issueVerificationEmail(outcome.customerId, identity.email, identity.firstName, identity.lastName);
|
||||
}
|
||||
await signIn(res, outcome.customerId);
|
||||
res.redirect(WELCOME_PATH);
|
||||
return;
|
||||
}
|
||||
|
||||
// The address belongs to somebody. Whether that is the same person is the
|
||||
// question #343 exists to answer, and `linkToExistingCustomer` holds the
|
||||
// whole of the answer.
|
||||
const link = await linkToExistingCustomer(identity);
|
||||
if (link.kind === 'refused') {
|
||||
// Deliberately its own destination rather than the generic failure. This is
|
||||
// the one refusal a customer can act on: they have an account, they simply
|
||||
// cannot reach it this way, and telling them to use the password they
|
||||
// already have is more useful than "that did not work".
|
||||
//
|
||||
// It reveals nothing they did not already supply. They arrived holding a
|
||||
// Google account for this address, so being told the address has an account
|
||||
// here tells them about themselves.
|
||||
console.warn('[google] refused a link: the address is taken and Google did not verify it');
|
||||
res.redirect(USE_PASSWORD_PATH);
|
||||
return;
|
||||
}
|
||||
|
||||
await signIn(res, link.customerId);
|
||||
res.redirect(returnTo);
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/start',
|
||||
googleSignInLimiter,
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const config = googleConfig();
|
||||
if (!config.enabled) {
|
||||
// Not a 404 and not an error page. Nothing offers this link when Google
|
||||
// sign-in is switched off, so reaching it means a stale bookmark or a
|
||||
// hand-typed URL, and the storefront is the right answer to both.
|
||||
return res.redirect('/');
|
||||
}
|
||||
|
||||
const attempt: Attempt = { ...newAttempt(), returnTo: safeReturnTo(req.query.returnTo) };
|
||||
setAttemptCookie(res, attempt);
|
||||
res.redirect(authorizationUrl(config, attempt));
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/callback',
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const config = googleConfig();
|
||||
const attempt = readAttemptCookie(req);
|
||||
|
||||
// Cleared before anything is decided, on every path. A cookie that survives
|
||||
// a failed attempt is a second try at the same state and nonce.
|
||||
res.clearCookie(ATTEMPT_COOKIE, { path: '/' });
|
||||
|
||||
if (!config.enabled || attempt === null) return res.redirect(FAILURE_PATH);
|
||||
|
||||
// Google sends `error=access_denied` when the customer declines at the
|
||||
// consent screen. That is a cancellation rather than a failure, and it goes
|
||||
// back to the storefront with nothing said — the same distinction #41 draws
|
||||
// for a dismissed passkey prompt.
|
||||
if (typeof req.query.error === 'string') {
|
||||
return res.redirect(attempt.returnTo);
|
||||
}
|
||||
|
||||
const state = typeof req.query.state === 'string' ? req.query.state : '';
|
||||
const code = typeof req.query.code === 'string' ? req.query.code : '';
|
||||
if (state === '' || code === '' || !secretsMatch(state, attempt.state)) {
|
||||
console.warn('[google] callback refused: state did not match the attempt cookie');
|
||||
return res.redirect(FAILURE_PATH);
|
||||
}
|
||||
|
||||
let identity;
|
||||
try {
|
||||
const idToken = await exchangeCode(config, code, attempt.codeVerifier);
|
||||
identity = verifiedIdentity(idToken, { clientId: config.clientId, nonce: attempt.nonce });
|
||||
} catch (err) {
|
||||
// Logged, never returned. These messages name which check failed, which
|
||||
// is exactly what the person reading the logs needs and exactly what an
|
||||
// attacker would like to be told.
|
||||
console.warn(`[google] callback refused: ${(err as Error).message}`);
|
||||
return res.redirect(FAILURE_PATH);
|
||||
}
|
||||
|
||||
const { rows } = await pool.query<IdentityRow>(
|
||||
`SELECT i.customer_id, c.disabled_at
|
||||
FROM customer_identities i
|
||||
JOIN customers c ON c.id = i.customer_id
|
||||
WHERE i.provider = 'google' AND i.provider_sub = $1`,
|
||||
[identity.sub]
|
||||
);
|
||||
const linked = rows[0];
|
||||
|
||||
// Nobody this shop has seen through Google before. Either they are new, or
|
||||
// they already have an account under this address — and joining those two
|
||||
// is linking, which is #343 and is refused here until its policy is
|
||||
// written down rather than falling out of an INSERT.
|
||||
if (!linked) return signUpOrLink(res, identity, attempt.returnTo);
|
||||
|
||||
// Refused here as well as on the password and passkey paths. Enforcing it
|
||||
// on some routes and not others is how a disabled account keeps a way in,
|
||||
// which is the reason #39 called this out for passkeys.
|
||||
if (linked.disabled_at !== null) {
|
||||
console.warn(`[google] refused a disabled account: customer ${linked.customer_id}`);
|
||||
return res.redirect(FAILURE_PATH);
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE customer_identities SET last_used_at = now()
|
||||
WHERE provider = 'google' AND provider_sub = $1`,
|
||||
[identity.sub]
|
||||
);
|
||||
|
||||
// The same call password login and passkey login make. Not a third
|
||||
// implementation that agrees today — the same one, so cookie flags, expiry
|
||||
// and logout behave identically however a customer got here.
|
||||
await signIn(res, linked.customer_id);
|
||||
|
||||
res.redirect(attempt.returnTo);
|
||||
})
|
||||
);
|
||||
|
||||
/** Exported for the tests; nothing else needs the cookie's name. */
|
||||
export { ATTEMPT_COOKIE, ATTEMPT_TTL_MS, FAILURE_PATH, WELCOME_PATH, USE_PASSWORD_PATH };
|
||||
|
||||
export default router;
|
||||
@@ -1,242 +0,0 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { pool, requireRow } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { hashToken } from '../uploadLinks';
|
||||
import { uploadImages, verifyUploadedImages, insertItemImages } from '../imageUpload';
|
||||
import { intakeViewLimiter, intakeSubmitLimiter } from '../rateLimit';
|
||||
import { draftQueued } from '../intake/draftingWorker';
|
||||
import { checkCapacity, countForLinkSince, windowStart } from '../intake/capacity';
|
||||
import { alertCeilingReached, alertLinkThreshold } from '../intake/abuseAlert';
|
||||
import { getSettings } from '../adminSettings';
|
||||
import { isRembgConfigured } from '../intake/rembgClient';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* The public way in: photos of one item, from someone with no account (#222).
|
||||
*
|
||||
* Everything here is reachable by a stranger holding a URL, so the shape of
|
||||
* every refusal matters. Unknown, revoked and exhausted links are all 404 and
|
||||
* indistinguishable from outside — whether a link exists is not something a
|
||||
* stranger needs to be able to learn, which is the same reasoning `uploads.ts`
|
||||
* applies to files.
|
||||
*
|
||||
* The AI is deliberately not called here. A slow or failing model request must
|
||||
* not turn into a failed upload for someone who did nothing wrong, and the
|
||||
* photos may be the only copy — the item is often no longer in the sender's
|
||||
* hands. The row is left at `state='queued'` for the worker in #223.
|
||||
*/
|
||||
|
||||
interface LinkRow {
|
||||
id: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** The link resolved by `requireUsableLink`, carried through to the handler. */
|
||||
interface IntakeRequest extends Request {
|
||||
uploadLink?: LinkRow;
|
||||
}
|
||||
|
||||
/**
|
||||
* The link a token opens, or null.
|
||||
*
|
||||
* The cap is applied in SQL rather than in a later branch, so that "usable" is
|
||||
* one concept with one definition used identically by the GET and the POST.
|
||||
*/
|
||||
async function usableLink(token: string): Promise<LinkRow | null> {
|
||||
const { rows } = await pool.query<LinkRow>(
|
||||
`SELECT id, label FROM upload_links
|
||||
WHERE token_hash = $1
|
||||
AND revoked_at IS NULL
|
||||
AND (max_submissions IS NULL OR submission_count < max_submissions)`,
|
||||
[hashToken(token)]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the link *before* multer runs, so a stranger holding a bad token
|
||||
* cannot cause a single byte to be written to the uploads volume.
|
||||
*
|
||||
* `discardUnlessAccepted` would delete those files afterwards, but "written
|
||||
* then deleted" is a materially worse position than "never written" on an
|
||||
* endpoint the whole internet can reach: it is disk churn an unauthenticated
|
||||
* caller controls, and it leans on a cleanup that a crash between the write
|
||||
* and the unlink would skip. Ordering this ahead of `uploadImages` is the
|
||||
* whole mitigation, and a test asserts it.
|
||||
*/
|
||||
/**
|
||||
* Refuses when the intake surface as a whole is over its ceiling.
|
||||
*
|
||||
* Ordered ahead of `uploadImages` for the same reason `requireUsableLink` is: a
|
||||
* refused submission must write zero bytes to disk. Ordering it after would
|
||||
* accept the upload, store the files and then throw them away, which is the
|
||||
* expensive half of the work this exists to prevent.
|
||||
*
|
||||
* 503 rather than 403. The sender has done nothing wrong, their link is fine,
|
||||
* and the condition clears by itself as the window rolls.
|
||||
*/
|
||||
/**
|
||||
* Alerts when one link crosses its threshold.
|
||||
*
|
||||
* A named function rather than an inline IIFE in the handler. The wrapper guard
|
||||
* flags any `async` inside a route registration that is not directly preceded
|
||||
* by `asyncRoute(`, and it cannot tell an inner IIFE from an unwrapped handler —
|
||||
* nor should it have to.
|
||||
*/
|
||||
async function alertIfLinkIsBusy(link: LinkRow): Promise<void> {
|
||||
const { intakeLinkAlertThreshold, intakeCeilingResetAt } = await getSettings();
|
||||
const used = await countForLinkSince(link.id, windowStart(new Date(), intakeCeilingResetAt));
|
||||
if (used >= intakeLinkAlertThreshold) {
|
||||
await alertLinkThreshold(link.id, link.label, used, intakeLinkAlertThreshold);
|
||||
}
|
||||
}
|
||||
|
||||
const requireCapacity = asyncRoute(
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
const verdict = await checkCapacity();
|
||||
if (verdict.allowed) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Not awaited: an alert that fails must not become a failed request for
|
||||
// somebody who has done nothing wrong, and the refusal is already decided.
|
||||
void alertCeilingReached(verdict.used, verdict.ceiling).catch((err) =>
|
||||
console.error('[intake] ceiling alert failed:', err)
|
||||
);
|
||||
|
||||
res.status(503).json({
|
||||
error: 'we are not able to accept submissions right now — please try again later'
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const requireUsableLink = asyncRoute(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const link = await usableLink(req.params.token as string);
|
||||
if (!link) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
(req as IntakeRequest).uploadLink = link;
|
||||
next();
|
||||
}
|
||||
);
|
||||
|
||||
router.get('/:token', intakeViewLimiter, asyncRoute(async (req: Request, res: Response) => {
|
||||
const link = await usableLink(req.params.token as string);
|
||||
if (!link) {
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
// The label, and whether the background-removal control has anything behind
|
||||
// it. Still nothing about the catalogue, the admin, or other links.
|
||||
res.json({ label: link.label, backgroundRemoval: isRembgConfigured() });
|
||||
}));
|
||||
|
||||
router.post(
|
||||
'/:token',
|
||||
intakeSubmitLimiter,
|
||||
requireUsableLink,
|
||||
requireCapacity,
|
||||
uploadImages,
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
// Set by requireUsableLink above. Re-checked rather than asserted non-null,
|
||||
// so a future reordering of the middleware fails as a 404 rather than as a
|
||||
// crash on undefined.
|
||||
const link = (req as IntakeRequest).uploadLink;
|
||||
if (!link) {
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
|
||||
const files = (req.files as Express.Multer.File[]) || [];
|
||||
if (files.length === 0) {
|
||||
return res.status(400).json({ error: 'at least one photo is required' });
|
||||
}
|
||||
|
||||
const refusal = await verifyUploadedImages(req);
|
||||
if (refusal) {
|
||||
return res.status(400).json({ error: refusal });
|
||||
}
|
||||
|
||||
const note = typeof req.body?.note === 'string' ? req.body.note.trim() : '';
|
||||
|
||||
// Absent means yes: the checkbox on the page is ticked by default, so a
|
||||
// client that does not send the field — an older build, or a script — gets
|
||||
// what every other submission gets rather than silently opting out.
|
||||
//
|
||||
// Only the exact string opts out. Multipart fields arrive as strings, and
|
||||
// reading a stray value as "no" would quietly deny somebody something they
|
||||
// asked for.
|
||||
const removeBackground = req.body?.removeBackground !== 'false';
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// A placeholder name. `items.name` is NOT NULL and nobody has named this
|
||||
// yet — the drafting worker or the admin replaces it. A timestamp rather
|
||||
// than "Untitled" so several waiting submissions stay tellable apart in
|
||||
// the inventory list.
|
||||
const { rows } = await client.query<{ id: number }>(
|
||||
`INSERT INTO items (name, description, status)
|
||||
VALUES ($1, $2, 'pending')
|
||||
RETURNING id`,
|
||||
[`Submission ${new Date().toISOString()}`, null]
|
||||
);
|
||||
const itemId = requireRow(rows, 'the intake item INSERT').id;
|
||||
|
||||
await insertItemImages(client, itemId, files, 0);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO item_drafts (item_id, upload_link_id, submitter_note, remove_background)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[itemId, link.id, note === '' ? null : note, removeBackground]
|
||||
);
|
||||
|
||||
// Counted inside the transaction and guarded on the same conditions as
|
||||
// the lookup, so two submissions racing for the last slot of a capped
|
||||
// link cannot both succeed.
|
||||
const counted = await client.query(
|
||||
`UPDATE upload_links
|
||||
SET submission_count = submission_count + 1, last_used_at = now()
|
||||
WHERE id = $1
|
||||
AND revoked_at IS NULL
|
||||
AND (max_submissions IS NULL OR submission_count < max_submissions)`,
|
||||
[link.id]
|
||||
);
|
||||
if (counted.rowCount === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
// Deliberately not awaited, and catching for itself. A slow or failing
|
||||
// model must not become a failed upload for someone who did nothing
|
||||
// wrong, which is the whole reason drafting does not happen inline. The
|
||||
// sweeper picks up anything this misses, so the cost of it failing here
|
||||
// is a few minutes' delay rather than a lost submission.
|
||||
void draftQueued(1).catch((err) => console.error('[drafting] after submission:', err));
|
||||
|
||||
// The signal that a link has been shared further than intended, which is
|
||||
// the case the revoke mechanism exists for and which otherwise depends on
|
||||
// somebody happening to look. Not awaited, for the same reason as above.
|
||||
void alertIfLinkIsBusy(link).catch((err) =>
|
||||
console.error('[intake] link threshold alert failed:', err)
|
||||
);
|
||||
|
||||
// No item id in the response: the sender has no business knowing about
|
||||
// the catalogue, and nothing they could do with it.
|
||||
res.status(201).json({ ok: true });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,139 +0,0 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { draftQueued } from '../intake/draftingWorker';
|
||||
import { IntakeAction, verifyAction } from '../intake/actionLinks';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const ACTIONS: readonly IntakeAction[] = ['regenerate', 'discard'];
|
||||
|
||||
function isAction(value: string): value is IntakeAction {
|
||||
return (ACTIONS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
interface Checked {
|
||||
itemId: number;
|
||||
action: IntakeAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public, and protected by the signature rather than by the admin gate.
|
||||
*
|
||||
* These are clicked from an inbox by someone who is not signed in, which is the
|
||||
* whole point of them. Neither action can publish: the worst outcome of a
|
||||
* leaked link is a wasted API call or a hide the review queue can undo, and
|
||||
* that is exactly what makes putting them in an email acceptable.
|
||||
*/
|
||||
function check(req: Request, res: Response): Checked | null {
|
||||
const action = req.params.action ?? '';
|
||||
if (!isAction(action)) {
|
||||
res.status(404).json({ error: 'unknown action' });
|
||||
return null;
|
||||
}
|
||||
|
||||
const itemId = Number(req.params.itemId);
|
||||
const expiresAt = Number(req.query.expires);
|
||||
const sig = typeof req.query.sig === 'string' ? req.query.sig : '';
|
||||
|
||||
if (!Number.isInteger(itemId) || !verifyAction(itemId, action, expiresAt, sig)) {
|
||||
// One response for a forged signature, an expired link and an unconfigured
|
||||
// secret alike. Distinguishing them would tell somebody probing which of
|
||||
// those they had achieved.
|
||||
res.status(403).json({ error: 'this link is not valid, or has expired' });
|
||||
return null;
|
||||
}
|
||||
|
||||
return { itemId, action };
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms, and changes nothing.
|
||||
*
|
||||
* Mail scanners and corporate link-rewriting gateways issue a GET against every
|
||||
* URL in a message before a human ever sees it. A GET that discarded a draft
|
||||
* would therefore fire itself on delivery, carrying a valid signature and
|
||||
* looking entirely legitimate in the log — and nobody would know to go and
|
||||
* recover it. So the state change lives on POST, and this exists only to let a
|
||||
* person confirm what they are about to do.
|
||||
*/
|
||||
router.get(
|
||||
'/:itemId/:action',
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const checked = check(req, res);
|
||||
if (!checked) return;
|
||||
|
||||
const { rows } = await pool.query<{ item_name: string; state: string }>(
|
||||
`SELECT i.name AS item_name, d.state
|
||||
FROM item_drafts d JOIN items i ON i.id = d.item_id
|
||||
WHERE d.item_id = $1`,
|
||||
[checked.itemId]
|
||||
);
|
||||
if (!rows[0]) return res.status(404).json({ error: 'no draft for this item' });
|
||||
|
||||
res.json({
|
||||
itemId: checked.itemId,
|
||||
action: checked.action,
|
||||
itemName: rows[0].item_name,
|
||||
state: rows[0].state,
|
||||
confirmWith: 'POST to this same url'
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:itemId/:action',
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const checked = check(req, res);
|
||||
if (!checked) return;
|
||||
|
||||
if (checked.action === 'regenerate') {
|
||||
// attempts cleared with the state, for the same reason the admin route
|
||||
// does it: the worker only picks up rows below the attempt cap, so
|
||||
// re-queueing an exhausted draft without clearing them would do nothing
|
||||
// and say nothing.
|
||||
const { rowCount } = await pool.query(
|
||||
`UPDATE item_drafts SET state = 'queued', attempts = 0, ai_error = NULL WHERE item_id = $1`,
|
||||
[checked.itemId]
|
||||
);
|
||||
if (rowCount === 0) return res.status(404).json({ error: 'no draft for this item' });
|
||||
|
||||
// Wake the worker rather than leaving the row for the five-minute sweeper.
|
||||
// Both this and the submission path put a row into 'queued'; only that one
|
||||
// asked for it to be drafted, which made this button indistinguishable from
|
||||
// a dead one (#272). Fire and forget with a logged catch, exactly as there:
|
||||
// a slow or failing model call must not become a failed request for the
|
||||
// admin, and the sweeper is still the backstop if this misses.
|
||||
void draftQueued(1).catch((err) => console.error('[drafting] after regenerate:', err));
|
||||
|
||||
return res.json({ state: 'queued' });
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rowCount } = await client.query(
|
||||
`UPDATE item_drafts SET state = 'discarded' WHERE item_id = $1`,
|
||||
[checked.itemId]
|
||||
);
|
||||
if (rowCount === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ error: 'no draft for this item' });
|
||||
}
|
||||
// Nothing is deleted, here or in the admin route. Discard is reachable in
|
||||
// one click from an inbox, and the photographs are often the only copy of
|
||||
// something no longer in the sender's hands.
|
||||
await client.query(`UPDATE items SET status = 'pending' WHERE id = $1`, [checked.itemId]);
|
||||
await client.query('COMMIT');
|
||||
res.json({ state: 'discarded' });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,29 +1,8 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { publicItemQuery, PublicItemRow, ItemContext } from '../itemSelect';
|
||||
import { readId } from '../utils';
|
||||
import {
|
||||
parseItemFilters,
|
||||
itemFilterExpressions,
|
||||
FilterError,
|
||||
NON_PUBLIC_STATUSES,
|
||||
STOREFRONT_DEFAULT_STATUSES,
|
||||
STOREFRONT_ALL_STATUSES
|
||||
} from '../itemFilters';
|
||||
|
||||
/**
|
||||
* Pending items are excluded everywhere, not only from the list. A pending item
|
||||
* that stayed fetchable by id would be hidden from the catalogue and still
|
||||
* reachable by anyone who guessed or kept a link.
|
||||
*
|
||||
* An expression rather than the SQL literal this was until #308, so it composes
|
||||
* with the filter clauses through `eb.and` instead of being joined into a
|
||||
* string. That join used to need its own argument about why AND could not
|
||||
* weaken it; `and` cannot re-associate anything.
|
||||
*/
|
||||
function notPending(eb: ItemContext) {
|
||||
return eb('i.status', '!=', 'pending');
|
||||
}
|
||||
import { PUBLIC_ITEM_SELECT } from '../itemSelect';
|
||||
import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -49,67 +28,14 @@ router.get('/', asyncRoute(async (req: Request, res: Response) => {
|
||||
return res.status(401).json({ error: 'sign in to filter by favorites' });
|
||||
}
|
||||
|
||||
// Refused rather than quietly answered. The filter parser is shared with the
|
||||
// admin routes, where 'pending' is valid, so it parses here too — and with
|
||||
// the exclusion below it would return an empty list, which reads as "no items
|
||||
// match" rather than "you may not ask that".
|
||||
//
|
||||
// Checked across every requested status, not just a single one: `?status=
|
||||
// available,pending` must be refused for naming pending at all, rather than
|
||||
// quietly answered because the first name in the list happened to be allowed.
|
||||
if (filters.status?.some((status) => NON_PUBLIC_STATUSES.includes(status))) {
|
||||
return res.status(400).json({ error: 'invalid status' });
|
||||
}
|
||||
|
||||
// No preference means Not Sold rather than everything. Applied here rather
|
||||
// than in the parser, which is shared with the admin, where the same absence
|
||||
// has to go on meaning "every status including pending".
|
||||
//
|
||||
// Except when the customer asked for their own favorites, where the default
|
||||
// stays everything. A favorite that has just sold is often exactly what the
|
||||
// customer came to look at — they were emailed to say so — and hiding it
|
||||
// would make an item they curated vanish without explanation. That was a
|
||||
// deliberate decision before this filter existed, and defaulting favorites to
|
||||
// Not Sold would have quietly reversed it. An explicit ?status= still wins,
|
||||
// so the choice remains theirs.
|
||||
const defaultStatuses = filters.favoritesOnly
|
||||
? STOREFRONT_ALL_STATUSES
|
||||
: STOREFRONT_DEFAULT_STATUSES;
|
||||
const effectiveFilters = {
|
||||
...filters,
|
||||
status: filters.status ?? [...defaultStatuses]
|
||||
};
|
||||
|
||||
const rows: PublicItemRow[] = await publicItemQuery()
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
notPending(eb),
|
||||
...itemFilterExpressions(eb, effectiveFilters, req.customerId ?? null)
|
||||
])
|
||||
)
|
||||
.orderBy('i.created_at', 'desc')
|
||||
.execute();
|
||||
|
||||
const { clauses, params } = buildItemFilterSql(filters, 1, req.customerId ?? null);
|
||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params);
|
||||
res.json(rows);
|
||||
}));
|
||||
|
||||
router.get('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||
// readId, like every other id-taking route since #207. This was the last one
|
||||
// reading its id with a bare Number(), which meant an unreadable id reached
|
||||
// Postgres and came back to the caller as a 500 for an item that cannot
|
||||
// exist. 404 is what "/items/abc" actually means.
|
||||
//
|
||||
// It was left on Number() because errorHandling.integration.test.ts used this
|
||||
// route's looseness as its way of making a handler reject. That test now
|
||||
// fails a database call directly instead, so it no longer depends on a route
|
||||
// declining to validate — which is what allowed this to be fixed (#307).
|
||||
const id = readId(req.params.id);
|
||||
if (id === null) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const rows = await publicItemQuery()
|
||||
.where('i.id', '=', id)
|
||||
.where((eb) => notPending(eb))
|
||||
.execute();
|
||||
const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} WHERE i.id = $1`, [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
res.json(rows[0]);
|
||||
}));
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse
|
||||
} from '@simplewebauthn/server';
|
||||
import type { AuthenticationResponseJSON } from '@simplewebauthn/server';
|
||||
import { pool } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { relyingParty } from '../passkeys/relyingParty';
|
||||
import { checkSignatureCounter } from '../passkeys/signatureCounter';
|
||||
import { signIn } from '../customerSession';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Signing in with a passkey (#39).
|
||||
*
|
||||
* Unauthenticated by design — this is how a customer becomes authenticated —
|
||||
* which is why it is a separate router from the registration one at
|
||||
* `/api/customers/me/passkeys`, where every route requires a session.
|
||||
*
|
||||
* ## Usernameless, and what that buys
|
||||
*
|
||||
* The customer is never asked who they are. `begin` takes no email and returns
|
||||
* no `allowCredentials`, so the browser offers whichever accounts it holds for
|
||||
* this Relying Party and the assertion says which credential answered. #38 asked
|
||||
* for discoverable credentials precisely so this would work.
|
||||
*
|
||||
* That is the better experience, and it also makes one of this issue's
|
||||
* requirements structural rather than something to be careful about: "failures
|
||||
* must not reveal whether an email has an account or has passkeys registered."
|
||||
* **No email is ever sent to this endpoint**, so there is nothing to reveal.
|
||||
* An email-first flow would have had to be careful to answer identically for a
|
||||
* known and an unknown address, forever, in every branch.
|
||||
*/
|
||||
|
||||
/** Matches the registration ceremony, so neither can be the odd one out. */
|
||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
interface CredentialRow {
|
||||
customer_id: number;
|
||||
credential_id: string;
|
||||
public_key: string;
|
||||
signature_counter: string;
|
||||
transports: string | null;
|
||||
disabled_at: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer given whenever a sign-in does not succeed.
|
||||
*
|
||||
* One message for every reason: no such credential, a disabled account, a bad
|
||||
* assertion, a stalled counter. They are all "that did not work" to the caller,
|
||||
* and saying which would turn this endpoint into an oracle for whether a
|
||||
* credential exists and whether its account is in good standing.
|
||||
*/
|
||||
const REFUSED = 'that passkey could not be used to sign in';
|
||||
|
||||
/**
|
||||
* Spends an authentication challenge, reporting whether it was spendable.
|
||||
*
|
||||
* Passed to `verifyAuthenticationResponse` as its `expectedChallenge`, which
|
||||
* accepts a predicate precisely for this flow: in a usernameless sign-in the
|
||||
* challenge is not known until the assertion names it, so it cannot be looked
|
||||
* up in advance.
|
||||
*
|
||||
* Deleting it is the check. A replay finds nothing to delete and fails, and the
|
||||
* expiry sits in the same statement so a stale challenge fails the same way and
|
||||
* for the same reason.
|
||||
*
|
||||
* A named function rather than an inline callback because
|
||||
* `routesAreWrapped.test.ts` reads the text of each `router.post(...)` looking
|
||||
* for an `async` that no `asyncRoute` covers — and an async callback nested
|
||||
* inside a wrapped handler looks exactly like an unwrapped one to it. Hoisting
|
||||
* it out keeps that guard sharp instead of teaching it another exception.
|
||||
*/
|
||||
async function spendAuthenticationChallenge(challenge: string): Promise<boolean> {
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM webauthn_challenges
|
||||
WHERE challenge = $1 AND kind = 'authentication' AND expires_at > now()`,
|
||||
[challenge]
|
||||
);
|
||||
return rowCount === 1;
|
||||
}
|
||||
|
||||
router.post(
|
||||
'/login/begin',
|
||||
asyncRoute(async (_req: Request, res: Response) => {
|
||||
const rp = relyingParty();
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rp.id,
|
||||
// Empty by design: the browser offers what it holds. Naming credentials
|
||||
// here would require knowing who is signing in, which is the thing this
|
||||
// flow exists to avoid asking.
|
||||
allowCredentials: [],
|
||||
userVerification: 'preferred',
|
||||
timeout: CHALLENGE_TTL_MS
|
||||
});
|
||||
|
||||
// customer_id is null — nobody is identified yet, which is exactly why #37
|
||||
// made that column nullable rather than reusing customer_tokens.
|
||||
await pool.query(
|
||||
`INSERT INTO webauthn_challenges (challenge, customer_id, kind, expires_at)
|
||||
VALUES ($1, NULL, 'authentication', now() + ($2 || ' milliseconds')::interval)`,
|
||||
[options.challenge, String(CHALLENGE_TTL_MS)]
|
||||
);
|
||||
|
||||
res.json(options);
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/login/finish',
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const rp = relyingParty();
|
||||
const body = req.body as AuthenticationResponseJSON;
|
||||
|
||||
if (typeof body?.id !== 'string' || body.id === '') {
|
||||
return res.status(400).json({ error: REFUSED });
|
||||
}
|
||||
|
||||
// The assertion says which credential answered, and that is what identifies
|
||||
// the customer. Joined so the disabled check reads the same row rather than
|
||||
// a second one that could have changed in between.
|
||||
const { rows } = await pool.query<CredentialRow>(
|
||||
`SELECT c.customer_id, c.credential_id, c.public_key, c.signature_counter,
|
||||
c.transports, cu.disabled_at
|
||||
FROM customer_credentials c
|
||||
JOIN customers cu ON cu.id = c.customer_id
|
||||
WHERE c.credential_id = $1`,
|
||||
[body.id]
|
||||
);
|
||||
const stored = rows[0];
|
||||
|
||||
// A disabled account is refused here as well as on the password path.
|
||||
// Enforcing it on one and not the other would leave passkeys as a way
|
||||
// around it, which is the whole reason #39 calls this out (#33).
|
||||
if (!stored || stored.disabled_at !== null) {
|
||||
// The challenge is still consumed below by verification never running, so
|
||||
// sweep it here: a refused attempt must not leave one usable.
|
||||
await pool.query(`DELETE FROM webauthn_challenges WHERE kind = 'authentication' AND expires_at <= now()`);
|
||||
return res.status(401).json({ error: REFUSED });
|
||||
}
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response: body,
|
||||
// A predicate rather than a value, which is what lets a usernameless
|
||||
// flow work at all: the challenge is not known until the assertion
|
||||
// names it. See the function for why single use falls out of this.
|
||||
expectedChallenge: spendAuthenticationChallenge,
|
||||
expectedOrigin: rp.origins,
|
||||
expectedRPID: rp.id,
|
||||
credential: {
|
||||
id: stored.credential_id,
|
||||
publicKey: new Uint8Array(Buffer.from(stored.public_key, 'base64url')),
|
||||
// Stored as BIGINT, which pg returns as a string.
|
||||
counter: Number(stored.signature_counter),
|
||||
transports: stored.transports ? (JSON.parse(stored.transports) as string[]) : undefined
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return res.status(401).json({ error: REFUSED });
|
||||
}
|
||||
|
||||
if (!verification.verified) {
|
||||
return res.status(401).json({ error: REFUSED });
|
||||
}
|
||||
|
||||
const verdict = checkSignatureCounter(
|
||||
Number(stored.signature_counter),
|
||||
verification.authenticationInfo.newCounter
|
||||
);
|
||||
if (!verdict.ok) {
|
||||
// Logged rather than returned. The customer cannot act on it, and the
|
||||
// person who can is reading the logs.
|
||||
console.warn(`[passkeys] refused credential ${stored.credential_id}: ${verdict.reason}`);
|
||||
return res.status(401).json({ error: REFUSED });
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE customer_credentials
|
||||
SET signature_counter = $1, last_used_at = now()
|
||||
WHERE credential_id = $2`,
|
||||
[verification.authenticationInfo.newCounter, stored.credential_id]
|
||||
);
|
||||
|
||||
// The same call password login makes. Not a second implementation that
|
||||
// agrees today — the same one.
|
||||
await signIn(res, stored.customer_id);
|
||||
|
||||
const { rows: customers } = await pool.query<{ id: number; email: string }>(
|
||||
`SELECT id, email FROM customers WHERE id = $1`,
|
||||
[stored.customer_id]
|
||||
);
|
||||
res.json(customers[0]);
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,292 +0,0 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse
|
||||
} from '@simplewebauthn/server';
|
||||
import type { RegistrationResponseJSON } from '@simplewebauthn/server';
|
||||
import { pool } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { requireCustomer } from '../middleware/customerAuth';
|
||||
import { relyingParty } from '../passkeys/relyingParty';
|
||||
import { defaultCredentialName, readCredentialName } from '../passkeys/credentialName';
|
||||
import { readId } from '../utils';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Registering a passkey (#38).
|
||||
*
|
||||
* Every route here is behind `requireCustomer`. Registration is not a sign-up
|
||||
* path — it adds a credential to an account that already exists and is already
|
||||
* signed in — so an unauthenticated caller has nothing to register against.
|
||||
*
|
||||
* Signing in with a passkey is #39, and the management screen is #40. Neither
|
||||
* exists yet, so nothing reads these credentials.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How long a customer has to complete the ceremony.
|
||||
*
|
||||
* Long enough to find a phone and use it; short enough that an intercepted
|
||||
* challenge is not useful for long. The browser's own timeout is set to match,
|
||||
* so the two cannot disagree about when the attempt has expired.
|
||||
*/
|
||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
interface CredentialIdRow {
|
||||
credential_id: string;
|
||||
transports: string | null;
|
||||
}
|
||||
|
||||
interface ChallengeRow {
|
||||
challenge: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a challenge and reports whether it was there.
|
||||
*
|
||||
* Single use is the whole point, and deleting it *is* the check: a replayed
|
||||
* response finds nothing to delete and is refused. Doing it as one statement
|
||||
* rather than a read followed by a delete means two requests racing cannot both
|
||||
* see the row and both proceed.
|
||||
*
|
||||
* Expiry is part of the same condition, so an expired challenge is refused for
|
||||
* the same reason and by the same statement.
|
||||
*/
|
||||
async function consumeChallenge(customerId: number, kind: string): Promise<string | null> {
|
||||
const { rows } = await pool.query<ChallengeRow>(
|
||||
`DELETE FROM webauthn_challenges
|
||||
WHERE customer_id = $1 AND kind = $2 AND expires_at > now()
|
||||
RETURNING challenge`,
|
||||
[customerId, kind]
|
||||
);
|
||||
return rows[0]?.challenge ?? null;
|
||||
}
|
||||
|
||||
router.post(
|
||||
'/register/begin',
|
||||
requireCustomer,
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const customerId = req.customerId as number;
|
||||
const rp = relyingParty();
|
||||
|
||||
const { rows: existing } = await pool.query<CredentialIdRow>(
|
||||
`SELECT credential_id, transports FROM customer_credentials WHERE customer_id = $1`,
|
||||
[customerId]
|
||||
);
|
||||
|
||||
const { rows: customers } = await pool.query<{ email: string; first_name: string | null }>(
|
||||
`SELECT email, first_name FROM customers WHERE id = $1`,
|
||||
[customerId]
|
||||
);
|
||||
const customer = customers[0];
|
||||
if (!customer) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: rp.name,
|
||||
rpID: rp.id,
|
||||
userName: customer.email,
|
||||
userDisplayName: customer.first_name ?? customer.email,
|
||||
// The customer id, not the email. A userID is meant to be stable and
|
||||
// opaque; the email is neither, and a customer changing theirs would
|
||||
// otherwise look like a different person to their own authenticator.
|
||||
userID: new TextEncoder().encode(String(customerId)),
|
||||
// Stops the same authenticator being enrolled twice. Without it a
|
||||
// customer pressing register again on a device they already registered
|
||||
// gets a second row that behaves identically to the first, and a
|
||||
// management screen showing two entries they cannot tell apart.
|
||||
excludeCredentials: existing.map((row) => ({ id: row.credential_id })),
|
||||
attestationType: 'none',
|
||||
authenticatorSelection: {
|
||||
// Discoverable, because #39 wants sign-in without the customer first
|
||||
// saying who they are. 'preferred' rather than 'required' so an
|
||||
// authenticator that cannot store one is still usable here.
|
||||
residentKey: 'preferred',
|
||||
userVerification: 'preferred'
|
||||
},
|
||||
timeout: CHALLENGE_TTL_MS
|
||||
});
|
||||
|
||||
// One in-flight registration per customer. Pressing the button twice must
|
||||
// not leave the first challenge usable — the second replaces it, and the
|
||||
// first response is then refused by consumeChallenge finding nothing.
|
||||
await pool.query(`DELETE FROM webauthn_challenges WHERE customer_id = $1 AND kind = 'registration'`, [
|
||||
customerId
|
||||
]);
|
||||
await pool.query(
|
||||
`INSERT INTO webauthn_challenges (challenge, customer_id, kind, expires_at)
|
||||
VALUES ($1, $2, 'registration', now() + ($3 || ' milliseconds')::interval)`,
|
||||
[options.challenge, customerId, String(CHALLENGE_TTL_MS)]
|
||||
);
|
||||
|
||||
res.json(options);
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/register/finish',
|
||||
requireCustomer,
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const customerId = req.customerId as number;
|
||||
const rp = relyingParty();
|
||||
|
||||
const expectedChallenge = await consumeChallenge(customerId, 'registration');
|
||||
if (expectedChallenge === null) {
|
||||
// Deliberately the same answer for "never started", "already used" and
|
||||
// "expired". They are the same thing from here — no challenge this
|
||||
// customer may still complete — and distinguishing them would tell an
|
||||
// attacker which of their guesses was closest.
|
||||
return res.status(400).json({ error: 'start again — that registration is no longer valid' });
|
||||
}
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: req.body as RegistrationResponseJSON,
|
||||
expectedChallenge,
|
||||
expectedOrigin: rp.origins,
|
||||
expectedRPID: rp.id
|
||||
});
|
||||
} catch {
|
||||
// The library throws on a malformed or unverifiable response. The
|
||||
// challenge is already consumed by this point, deliberately: a failed
|
||||
// attempt must not leave one usable for a second try.
|
||||
return res.status(400).json({ error: 'that passkey could not be registered' });
|
||||
}
|
||||
|
||||
if (!verification.verified) {
|
||||
return res.status(400).json({ error: 'that passkey could not be registered' });
|
||||
}
|
||||
|
||||
const { credential } = verification.registrationInfo;
|
||||
const transports = credential.transports ?? [];
|
||||
const name = readCredentialName((req.body as { name?: unknown }).name)
|
||||
?? defaultCredentialName(transports);
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO customer_credentials
|
||||
(customer_id, credential_id, public_key, signature_counter, transports, name)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[
|
||||
customerId,
|
||||
credential.id,
|
||||
Buffer.from(credential.publicKey).toString('base64url'),
|
||||
credential.counter,
|
||||
JSON.stringify(transports),
|
||||
name
|
||||
]
|
||||
);
|
||||
} catch (err) {
|
||||
// credential_id is unique across the table. excludeCredentials should
|
||||
// have stopped the browser offering an already-registered authenticator,
|
||||
// but that is a hint the browser may ignore, so the constraint is what
|
||||
// actually holds — and hitting it means the credential is already
|
||||
// registered rather than that anything is broken.
|
||||
if ((err as { code?: string }).code === '23505') {
|
||||
return res.status(409).json({ error: 'that passkey is already registered' });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
res.status(201).json({ name });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* The customer's registered passkeys (#40).
|
||||
*
|
||||
* Registering one with no way to see or remove it is worse than not offering
|
||||
* passkeys at all, which is what makes this the smallest issue in the project
|
||||
* and the one that makes the rest usable.
|
||||
*
|
||||
* `last_used_at` is here because it is the only thing that tells two entries
|
||||
* apart when the names are similar — a customer about to revoke one needs to
|
||||
* know which device they are cutting off, and "used an hour ago" answers that
|
||||
* where a creation date does not.
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
requireCustomer,
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query<{
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: Date;
|
||||
last_used_at: Date | null;
|
||||
}>(
|
||||
`SELECT id, name, created_at, last_used_at
|
||||
FROM customer_credentials
|
||||
WHERE customer_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
[req.customerId]
|
||||
);
|
||||
|
||||
// No public key, no credential id, no counter. The customer cannot act on
|
||||
// any of them, and a credential id is the one value that identifies this
|
||||
// authenticator to anyone who has it.
|
||||
res.json(rows);
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireCustomer,
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const id = readId(req.params.id);
|
||||
if (id === null) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
// Removing the last way in must not lock the customer out.
|
||||
//
|
||||
// This cannot fire today: password_hash is NOT NULL, so every customer has
|
||||
// a password and removing every passkey still leaves them a way to sign in.
|
||||
// The issue asks for the check anyway, and that is the right call — it is
|
||||
// written against the condition rather than against today's schema, so it
|
||||
// starts holding on its own the moment the condition changes.
|
||||
//
|
||||
// #332 is what changes it. Social sign-in makes password_hash nullable and
|
||||
// creates the first customers with no password, at which point a customer
|
||||
// whose only credential is a passkey can genuinely lock themselves out with
|
||||
// this button. When that lands, `has_password` stops being always true and
|
||||
// this branch starts running.
|
||||
const { rows: waysIn } = await pool.query<{ has_password: boolean; credentials: string }>(
|
||||
`SELECT (c.password_hash IS NOT NULL) AS has_password,
|
||||
(SELECT count(*) FROM customer_credentials WHERE customer_id = c.id) AS credentials
|
||||
FROM customers c
|
||||
WHERE c.id = $1`,
|
||||
[req.customerId]
|
||||
);
|
||||
const waysInRow = waysIn[0];
|
||||
if (waysInRow && !waysInRow.has_password && Number(waysInRow.credentials) <= 1) {
|
||||
return res.status(409).json({
|
||||
error:
|
||||
'that is the only way you can sign in — set a password first, or add another passkey'
|
||||
});
|
||||
}
|
||||
|
||||
// Scoped to the signed-in customer in the same statement that deletes.
|
||||
// Reading first and deleting after would leave a window, and a credential
|
||||
// id is not a secret — the only thing making this safe is that the WHERE
|
||||
// names whose it must be.
|
||||
//
|
||||
// Revocation is the row going away: #39 looks the credential up by id on
|
||||
// every sign-in, so a deleted one is refused immediately and by
|
||||
// construction rather than by a flag something has to remember to check.
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM customer_credentials WHERE id = $1 AND customer_id = $2`,
|
||||
[id, req.customerId]
|
||||
);
|
||||
|
||||
// 404 for both "no such credential" and "not yours", deliberately. The
|
||||
// second is the interesting case and saying so would confirm that some
|
||||
// other customer holds that id.
|
||||
if (rowCount === 0) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
res.status(204).end();
|
||||
})
|
||||
);
|
||||
|
||||
/** Exported for the tests; nothing else constructs a challenge. */
|
||||
export { CHALLENGE_TTL_MS };
|
||||
|
||||
export default router;
|
||||
@@ -1,16 +1,12 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool, requireRow } from '../db';
|
||||
import { pool } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
|
||||
interface IdRow {
|
||||
id: number;
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/unsubscribe', asyncRoute(async (req: Request, res: Response) => {
|
||||
const token = req.query.token as string;
|
||||
const { rows } = await pool.query<IdRow>(`SELECT id FROM customers WHERE unsubscribe_token = $1`, [token]);
|
||||
const { rows } = await pool.query(`SELECT id FROM customers WHERE unsubscribe_token = $1`, [token]);
|
||||
if (!rows.length) {
|
||||
res.status(400).send('<html><body><h2>Invalid or expired unsubscribe link.</h2></body></html>');
|
||||
return;
|
||||
@@ -18,7 +14,7 @@ router.get('/unsubscribe', asyncRoute(async (req: Request, res: Response) => {
|
||||
await pool.query(
|
||||
`UPDATE customers SET marketing_consent = false, marketing_consent_at = now(),
|
||||
marketing_consent_text = 'Unsubscribed via email link' WHERE id = $1`,
|
||||
[requireRow(rows, 'the unsubscribe-token lookup').id]
|
||||
[rows[0].id]
|
||||
);
|
||||
res.send('<html><body><h2>You\'ve been unsubscribed.</h2><p>You will no longer receive marketing emails from Redefined Designs.</p></body></html>');
|
||||
}));
|
||||
|
||||
@@ -4,32 +4,10 @@ import { asyncRoute } from '../asyncRoute';
|
||||
import { requireCustomer } from '../middleware/customerAuth';
|
||||
import { validateAddress, uspsConfigured, UspsValidationResult } from '../usps';
|
||||
|
||||
/**
|
||||
* A whole `shipping_addresses` row. Every query here uses `SELECT *` or
|
||||
* `RETURNING *`, so one shape covers the file. Kept in step with the schema by
|
||||
* hand.
|
||||
*/
|
||||
interface ShippingAddressRow {
|
||||
id: number;
|
||||
customer_id: number;
|
||||
full_name: string;
|
||||
address_line1: string;
|
||||
address_line2: string | null;
|
||||
city: string;
|
||||
state: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
is_default: boolean;
|
||||
usps_validated: boolean;
|
||||
// jsonb, and only ever handed back to the client — never read here.
|
||||
usps_standardized: unknown;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query<ShippingAddressRow>(
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM shipping_addresses WHERE customer_id = $1 ORDER BY is_default DESC, created_at DESC`,
|
||||
[req.customerId]
|
||||
);
|
||||
@@ -53,7 +31,7 @@ if ((country || 'US') === 'US') {
|
||||
if (isDefault) {
|
||||
await client.query(`UPDATE shipping_addresses SET is_default = false WHERE customer_id = $1`, [req.customerId]);
|
||||
}
|
||||
const { rows } = await client.query<ShippingAddressRow>(
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO shipping_addresses
|
||||
(customer_id, full_name, address_line1, address_line2, city, state, postal_code, country, is_default, usps_validated, usps_standardized)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
|
||||
@@ -81,7 +59,7 @@ router.put('/:id', requireCustomer, asyncRoute(async (req: Request, res: Respons
|
||||
if (isDefault) {
|
||||
await client.query(`UPDATE shipping_addresses SET is_default = false WHERE customer_id = $1`, [req.customerId]);
|
||||
}
|
||||
const { rows } = await client.query<ShippingAddressRow>(
|
||||
const { rows } = await client.query(
|
||||
`UPDATE shipping_addresses
|
||||
SET full_name=$1, address_line1=$2, address_line2=$3, city=$4, state=$5, postal_code=$6, country=$7, is_default=$8,
|
||||
usps_validated = false, usps_standardized = NULL
|
||||
@@ -110,7 +88,7 @@ router.post('/:id/set-default', requireCustomer, asyncRoute(async (req: Request,
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(`UPDATE shipping_addresses SET is_default = false WHERE customer_id = $1`, [req.customerId]);
|
||||
const { rows } = await client.query<ShippingAddressRow>(
|
||||
const { rows } = await client.query(
|
||||
`UPDATE shipping_addresses SET is_default = true WHERE id = $1 AND customer_id = $2 RETURNING *`,
|
||||
[req.params.id, req.customerId]
|
||||
);
|
||||
@@ -118,10 +96,6 @@ router.post('/:id/set-default', requireCustomer, asyncRoute(async (req: Request,
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
// Logged like the two catch blocks above it in this file, which this one
|
||||
// was simply missing. Without it a failed default-address change rolls
|
||||
// back and returns 500 leaving nothing behind to say why.
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
} finally {
|
||||
client.release();
|
||||
|
||||
+14
-79
@@ -2,16 +2,11 @@ import cron from 'node-cron';
|
||||
import app from './app';
|
||||
import { pool } from './db';
|
||||
import { sendMail } from './mailer';
|
||||
import { renderTemplate, greeting, formatDuration } from './emailTemplates';
|
||||
import { getSettings } from './adminSettings';
|
||||
import { loadStoredTemplate } from './routes/adminEmailTemplates';
|
||||
import { validateEnv } from './envValidation';
|
||||
import { draftQueued } from './intake/draftingWorker';
|
||||
|
||||
// Release cart holds whose expiry has passed.
|
||||
async function sweepExpiredCarts(): Promise<void> {
|
||||
try {
|
||||
const { rows } = await pool.query<ExpiredCartItemRow>(
|
||||
const { rows } = await pool.query(
|
||||
`DELETE FROM cart_items WHERE expires_at < now() RETURNING item_id`
|
||||
);
|
||||
for (const row of rows) {
|
||||
@@ -26,8 +21,8 @@ async function sweepExpiredCarts(): Promise<void> {
|
||||
// their cart.
|
||||
async function sendCartReminders(): Promise<void> {
|
||||
try {
|
||||
const { rows } = await pool.query<ReminderRow>(`
|
||||
SELECT c.email, c.first_name, c.last_name, i.name AS item_name, ci.expires_at, ci.id AS cart_item_id
|
||||
const { rows } = await pool.query(`
|
||||
SELECT c.email, c.name, i.name AS item_name, ci.expires_at, ci.id AS cart_item_id
|
||||
FROM cart_items ci
|
||||
JOIN carts ca ON ca.id = ci.cart_id
|
||||
JOIN customers c ON c.id = ca.customer_id
|
||||
@@ -37,35 +32,22 @@ async function sendCartReminders(): Promise<void> {
|
||||
AND ci.expires_at > now()
|
||||
`);
|
||||
|
||||
const byEmail = new Map<string, { firstName: string | null; lastName: string | null; items: { name: string; expiresAt: Date; cartItemId: number }[] }>();
|
||||
const byEmail = new Map<string, { name: string | null; items: { name: string; expiresAt: Date; cartItemId: number }[] }>();
|
||||
for (const row of rows) {
|
||||
if (!byEmail.has(row.email)) byEmail.set(row.email, { firstName: row.first_name, lastName: row.last_name, items: [] });
|
||||
if (!byEmail.has(row.email)) byEmail.set(row.email, { name: row.name, items: [] });
|
||||
byEmail.get(row.email)!.items.push({ name: row.item_name, expiresAt: row.expires_at, cartItemId: row.cart_item_id });
|
||||
}
|
||||
|
||||
// Loaded once rather than per recipient: the copy is shared, only the
|
||||
// greeting and the item list differ.
|
||||
const stored = await loadStoredTemplate('cartReminder');
|
||||
const { cartExpiryHours, greetingFormat, greetingFallback } = await getSettings();
|
||||
const holdDuration = formatDuration(cartExpiryHours);
|
||||
|
||||
for (const [email, data] of byEmail) {
|
||||
// Markdown, not HTML. Values are substituted into the template source
|
||||
// before it is rendered, and the renderer escapes raw HTML — so an <li>
|
||||
// here would reach the customer as literal angle brackets.
|
||||
const itemList = data.items
|
||||
.map(i => `- ${i.name} — reserved until ${i.expiresAt.toLocaleString()}`)
|
||||
.join('\n');
|
||||
|
||||
const { subject, html } = renderTemplate('cartReminder', stored, {
|
||||
greeting: greeting(data.firstName, greetingFormat, greetingFallback, data.lastName),
|
||||
firstName: data.firstName ?? '',
|
||||
lastName: data.lastName ?? '',
|
||||
itemList,
|
||||
cartUrl: `${process.env.PUBLIC_URL}/cart`,
|
||||
holdDuration
|
||||
});
|
||||
await sendMail(email, subject, html);
|
||||
const itemList = data.items.map(i => `<li>${i.name} — reserved until ${i.expiresAt.toLocaleString()}</li>`).join('');
|
||||
await sendMail(
|
||||
email,
|
||||
'Items waiting in your cart',
|
||||
`<p>Hi${data.name ? ' ' + data.name : ''},</p>
|
||||
<p>You still have items in your cart at Redefined Designs:</p>
|
||||
<ul>${itemList}</ul>
|
||||
<p><a href="${process.env.PUBLIC_URL}/cart">View your cart</a> before your reservation expires.</p>`
|
||||
);
|
||||
const ids = data.items.map(i => i.cartItemId);
|
||||
await pool.query(`UPDATE cart_items SET last_reminder_sent_at = now() WHERE id = ANY($1::int[])`, [ids]);
|
||||
}
|
||||
@@ -74,21 +56,6 @@ async function sendCartReminders(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** What the expiry sweep releases, so the items can be returned to the shop. */
|
||||
interface ExpiredCartItemRow {
|
||||
item_id: number;
|
||||
}
|
||||
|
||||
/** One held item and who to remind about it. */
|
||||
interface ReminderRow {
|
||||
email: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
item_name: string;
|
||||
expires_at: Date;
|
||||
cart_item_id: number;
|
||||
}
|
||||
|
||||
// Neither scheduler has anything to await these with, so `void` states that the
|
||||
// promise is deliberately dropped. That is only safe because both functions
|
||||
// catch their own errors above — an escaping rejection would be unhandled, and
|
||||
@@ -97,37 +64,5 @@ interface ReminderRow {
|
||||
setInterval(() => void sweepExpiredCarts(), 5 * 60 * 1000);
|
||||
cron.schedule('0 9 * * *', () => void sendCartReminders());
|
||||
|
||||
// Every five minutes, in the same shape as the cart sweep. This is what makes a
|
||||
// restart mid-draft recoverable rather than a permanently stalled row, and what
|
||||
// picks up anything the post-submission call missed. Unlike the two above,
|
||||
// draftQueued does not catch at its own top level — the initial query can
|
||||
// reject — so it catches here instead, for the reason the comment above gives.
|
||||
setInterval(
|
||||
() => void draftQueued().catch((err) => console.error('[drafting] sweep:', err)),
|
||||
5 * 60 * 1000
|
||||
);
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
|
||||
// Checked at boot rather than left to be discovered by the first request that
|
||||
// happens to need a missing value. Every problem is reported at once — fixing a
|
||||
// fresh environment one restart at a time is miserable — and anything fatal
|
||||
// stops the process, the same way a failed migration does rather than serving
|
||||
// against a schema it does not match. The admin-gate warning lives here too now
|
||||
// (#63), so there is one place that says what this container is and is not
|
||||
// configured to do. See envValidation.ts and #64.
|
||||
const { errors, warnings } = validateEnv(process.env);
|
||||
|
||||
for (const warning of warnings) {
|
||||
console.warn(`[config] ${warning}`);
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
console.error(`[config] refusing to start — ${errors.length} problem(s) with the environment:`);
|
||||
for (const error of errors) {
|
||||
console.error(`[config] - ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
app.listen(PORT, () => console.log(`redefined-designs listening on ${PORT}`));
|
||||
|
||||
+1
-19
@@ -1,16 +1,4 @@
|
||||
/**
|
||||
* Every value `items.status` can hold.
|
||||
*
|
||||
* 'pending' was missing here from the moment items started arriving pending,
|
||||
* while itemFilters.ts declared its own copy that had it. Two declarations of
|
||||
* one union is how that happens: nothing connects them, so one goes stale and
|
||||
* nothing says so. The stale one was harmless only because query rows were
|
||||
* `any` — typing them turned `status === 'pending'` in admin.ts into a compile
|
||||
* error about a comparison with no overlap, which is how it was found.
|
||||
*
|
||||
* This is now the single declaration. itemFilters.ts imports it.
|
||||
*/
|
||||
export type ItemStatus = 'pending' | 'available' | 'reserved' | 'sold';
|
||||
export type ItemStatus = 'available' | 'reserved' | 'sold';
|
||||
|
||||
export interface ItemImage {
|
||||
id: number;
|
||||
@@ -18,12 +6,6 @@ export interface ItemImage {
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface ItemTag {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Issuing and recognising the tokens that open the public intake endpoint.
|
||||
*
|
||||
* Kept apart from the routes so the rules are pure and testable directly — the
|
||||
* same reasoning as `uploadTypes.ts` and `keyByCallerAndEmail`, both of which
|
||||
* are exported for their tests because they are where the real decisions live.
|
||||
*/
|
||||
|
||||
// 32 bytes — 256 bits. base64url so the value survives being pasted into a URL,
|
||||
// a chat message and a QR code without escaping, which is the whole point of a
|
||||
// link somebody is handed.
|
||||
const TOKEN_BYTES = 32;
|
||||
|
||||
export function generateToken(): string {
|
||||
return crypto.randomBytes(TOKEN_BYTES).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* The digest stored against a link.
|
||||
*
|
||||
* SHA-256 rather than bcrypt, deliberately, and the reasoning is the opposite
|
||||
* of the one that governs passwords. A password hash is slow on purpose,
|
||||
* because a human password carries little entropy and has to survive an
|
||||
* offline dictionary attack. This is 256 bits from a CSPRNG: there is no
|
||||
* dictionary to try, and guessing is not a threat that slowing the hash
|
||||
* addresses.
|
||||
*
|
||||
* Meanwhile the digest is computed on every submission request, and the intake
|
||||
* endpoint is unauthenticated. A deliberately slow hash there would be a
|
||||
* denial-of-service surface rather than a protection — see #242, where cost-12
|
||||
* bcrypt in the test suite was enough to push a request past its timeout under
|
||||
* load.
|
||||
*
|
||||
* No timing-safe comparison is needed. The lookup is an indexed equality match
|
||||
* on the digest rather than a byte-by-byte compare of the secret, and an
|
||||
* attacker able to mount a timing attack against a 256-bit random value would
|
||||
* still need the value.
|
||||
*/
|
||||
export function hashToken(token: string): string {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* What the inventory upload will accept, and how to tell whether a file is
|
||||
* actually what it says it is.
|
||||
*
|
||||
* Kept apart from the route so the rules are pure and can be tested directly.
|
||||
* A mistake here is not a cosmetic one: uploads are served by express.static
|
||||
* from the application's own origin, so a file that gets through and is later
|
||||
* navigated to runs as same-origin content. See #95 and #103.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Deliberately three types, not `image/*`.
|
||||
*
|
||||
* SVG is excluded even though it is an image: it can carry script that executes
|
||||
* when the file is navigated to directly, which is precisely the exposure #103
|
||||
* describes. A photograph of a one-of-a-kind item is never a vector drawing, so
|
||||
* nothing real is lost.
|
||||
*
|
||||
* GIF is excluded as simply not wanted for product stills rather than for any
|
||||
* security reason. Adding it later means adding its signature below too.
|
||||
*
|
||||
* The frontend's `accept` attribute lists these same three so the file picker
|
||||
* offers exactly what the server will take. The list unavoidably exists in two
|
||||
* runtimes; if it changes here, change it there.
|
||||
*/
|
||||
export const ALLOWED_IMAGE_TYPES: readonly string[] = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
/**
|
||||
* How many bytes of a file are needed to check any signature below. WebP is the
|
||||
* longest reach: it needs byte 8 onwards.
|
||||
*/
|
||||
export const SIGNATURE_BYTES = 12;
|
||||
|
||||
const EXTENSION_FOR_TYPE: Readonly<Record<string, string>> = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp'
|
||||
};
|
||||
|
||||
/**
|
||||
* The content type a stored file should be served as, from its extension.
|
||||
*
|
||||
* The inverse of `extensionFor`, and derived from the same record so the two
|
||||
* cannot drift. Returns null for anything else, which is what lets the uploads
|
||||
* route refuse to serve a file it does not recognise — the case that matters is
|
||||
* a file written before this validation existed, or one that arrived through a
|
||||
* gap, since nothing the current upload path accepts can produce another
|
||||
* extension.
|
||||
*/
|
||||
export function typeForExtension(extension: string): string | null {
|
||||
const lowered = extension.toLowerCase();
|
||||
const found = Object.entries(EXTENSION_FOR_TYPE).find(([, ext]) => ext === lowered);
|
||||
return found?.[0] ?? null;
|
||||
}
|
||||
|
||||
export function isAllowedImageType(mimetype: string): boolean {
|
||||
return ALLOWED_IMAGE_TYPES.includes(mimetype);
|
||||
}
|
||||
|
||||
/**
|
||||
* The extension a stored file should carry, derived from its validated type.
|
||||
*
|
||||
* Returns null for anything unrecognised so a caller has to handle it, rather
|
||||
* than defaulting to an empty string and writing a file with no extension at
|
||||
* all. The stored name comes from this instead of from the submitted filename,
|
||||
* so the name on disk cannot disagree with what the file is.
|
||||
*/
|
||||
export function extensionFor(mimetype: string): string | null {
|
||||
return EXTENSION_FOR_TYPE[mimetype] ?? null;
|
||||
}
|
||||
|
||||
function startsWithBytes(head: Buffer, offset: number, expected: readonly number[]): boolean {
|
||||
if (head.length < offset + expected.length) {
|
||||
return false;
|
||||
}
|
||||
return expected.every((byte, index) => head[offset + index] === byte);
|
||||
}
|
||||
|
||||
const ASCII_RIFF = [0x52, 0x49, 0x46, 0x46];
|
||||
const ASCII_WEBP = [0x57, 0x45, 0x42, 0x50];
|
||||
|
||||
/**
|
||||
* Whether a file's leading bytes agree with the content type it was declared as.
|
||||
*
|
||||
* `file.mimetype` comes from the client's multipart headers and is whatever the
|
||||
* caller chose to write there, so the allowlist alone stops honest mistakes and
|
||||
* nothing else. This is what stops `evil.html` renamed to `photo.jpg` and sent
|
||||
* as `image/jpeg`.
|
||||
*
|
||||
* Fails closed on a short read and on any type not in the allowlist, so a
|
||||
* truncated file or an unexpected type is refused rather than assumed fine.
|
||||
*/
|
||||
export function signatureMatches(mimetype: string, head: Buffer): boolean {
|
||||
switch (mimetype) {
|
||||
case 'image/jpeg':
|
||||
return startsWithBytes(head, 0, [0xff, 0xd8, 0xff]);
|
||||
case 'image/png':
|
||||
return startsWithBytes(head, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
// A RIFF container is not necessarily a WebP — a .wav opens the same way —
|
||||
// so both the container marker and the format marker are checked.
|
||||
case 'image/webp':
|
||||
return startsWithBytes(head, 0, ASCII_RIFF) && startsWithBytes(head, 8, ASCII_WEBP);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* Serving user-uploaded files, defensively.
|
||||
*
|
||||
* Everything under the uploads directory was put there by someone other than
|
||||
* the people who wrote this application, and it is served over HTTP. #95 stops
|
||||
* a dangerous file being *stored*; this stops a stored file *doing damage* if
|
||||
* one ever gets there anyway — through a gap, a path added later, or a file
|
||||
* written before that validation existed.
|
||||
*
|
||||
* The real fix for that class is a separate origin, because the origin is the
|
||||
* whole unit of trust in a browser (#103). That needs a hostname and a
|
||||
* certificate, which live outside this repository, so what is here is the half
|
||||
* that works either way: the app's own origin stops serving anything it does
|
||||
* not recognise, and serves what it does recognise in a form that cannot be
|
||||
* talked into executing.
|
||||
*
|
||||
* These two are complementary rather than alternatives. The separate origin
|
||||
* still points at these same files, so the rules below apply there too.
|
||||
*/
|
||||
|
||||
import express, { Request, Response, NextFunction, Router } from 'express';
|
||||
import path from 'path';
|
||||
import { typeForExtension } from './uploadTypes';
|
||||
|
||||
/**
|
||||
* A directly-navigated upload gets no capabilities at all.
|
||||
*
|
||||
* `default-src 'none'` leaves a document unable to load or run anything, and
|
||||
* `sandbox` with no allowances drops it into an opaque origin, so even a file
|
||||
* that somehow renders as markup cannot reach the site's cookies or DOM.
|
||||
*
|
||||
* This does nothing to an `<img>` embed, which is the only way these files are
|
||||
* legitimately used — a policy on an image response constrains the image's own
|
||||
* (nonexistent) subresource loads, not the page displaying it.
|
||||
*/
|
||||
const UPLOAD_CSP = "default-src 'none'; sandbox";
|
||||
|
||||
/**
|
||||
* Whether a request should reach the files at all.
|
||||
*
|
||||
* Only GET and HEAD: express.static ignores the rest anyway, but answering 405
|
||||
* says so rather than falling through to a 404 that suggests the path is wrong.
|
||||
*/
|
||||
function methodAllowed(method: string): boolean {
|
||||
return method === 'GET' || method === 'HEAD';
|
||||
}
|
||||
|
||||
export function uploadsRouter(directory: string): Router {
|
||||
const router = express.Router();
|
||||
|
||||
router.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (!methodAllowed(req.method)) {
|
||||
res.set('Allow', 'GET, HEAD');
|
||||
res.status(405).json({ error: 'method not allowed' });
|
||||
return;
|
||||
}
|
||||
|
||||
// An allowlist rather than a denylist of dangerous extensions. A denylist
|
||||
// has to anticipate every type a browser might execute, which is a moving
|
||||
// target across browsers and years; this only has to know the three types
|
||||
// the upload path can produce, and everything else — including a `.html` or
|
||||
// a `.svg` sitting on disk from before there was any validation — is simply
|
||||
// not a file this application will hand out.
|
||||
const contentType = typeForExtension(path.extname(req.path));
|
||||
if (contentType === null) {
|
||||
// 404 rather than 403: whether a file exists at that path is not
|
||||
// something a stranger needs to be able to distinguish.
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Set here rather than only in setHeaders below, so a request that never
|
||||
// reaches a file still carries them.
|
||||
res.set('X-Content-Type-Options', 'nosniff');
|
||||
res.set('Content-Security-Policy', UPLOAD_CSP);
|
||||
next();
|
||||
});
|
||||
|
||||
router.use(
|
||||
express.static(directory, {
|
||||
// No directory listings and no index.html, both of which would be content
|
||||
// this application did not write being served as if it had.
|
||||
index: false,
|
||||
// A dotfile in an upload directory is never something to hand out.
|
||||
dotfiles: 'ignore',
|
||||
setHeaders: (res: Response, filePath: string) => {
|
||||
const contentType = typeForExtension(path.extname(filePath));
|
||||
if (contentType !== null) {
|
||||
// Stated explicitly rather than left to express.static's extension
|
||||
// lookup. Paired with nosniff, the type a browser sees is then the
|
||||
// one this application chose, from a list of three, and never a guess
|
||||
// made from the bytes.
|
||||
res.set('Content-Type', contentType);
|
||||
}
|
||||
// Required once these are served from a hostname of their own: without
|
||||
// it a resource-policy-conscious browser refuses the cross-origin
|
||||
// `<img>` load. Harmless while the origin is shared.
|
||||
res.set('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
+2
-92
@@ -43,7 +43,7 @@ export function isValidEmail(email: string): boolean {
|
||||
// antd's preset Tag colours. Kept as the single source of truth for tag
|
||||
// colours so the admin palette picker and the auto-assignment below can never
|
||||
// drift apart — the frontend renders whatever string lands in tags.color.
|
||||
export const TAG_COLORS: [string, ...string[]] = [
|
||||
export const TAG_COLORS = [
|
||||
'magenta', 'red', 'volcano', 'orange', 'gold', 'lime',
|
||||
'green', 'cyan', 'blue', 'geekblue', 'purple'
|
||||
];
|
||||
@@ -61,98 +61,8 @@ export function tagColorFor(name: string): string {
|
||||
for (let i = 0; i < normalized.length; i++) {
|
||||
hash = ((hash << 5) + hash + normalized.charCodeAt(i)) | 0;
|
||||
}
|
||||
// The modulo keeps this in range, but an index signature cannot say so. The
|
||||
// fallback is the first colour rather than a throw: a tag with an unexpected
|
||||
// colour is not worth failing a request over.
|
||||
// TAG_COLORS is typed as a non-empty tuple, so index 0 is known to exist —
|
||||
// the annotation, rather than `as const`, because the elements must stay
|
||||
// `string` for the callers that assign them. The modulo keeps the computed
|
||||
// index in range; the fallback only exists because indexing cannot say so.
|
||||
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length] ?? TAG_COLORS[0];
|
||||
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Email marketing only. Deliberately says nothing about tracking.
|
||||
*
|
||||
* This was briefly widened during #56 to cover analytics as well, and that was
|
||||
* wrong: GDPR requires consent to be granular, and current EDPB guidance treats
|
||||
* bundling tracking consent with subscription consent as invalid because the
|
||||
* customer cannot accept one purpose and refuse the other. Quebec's Law 25 is
|
||||
* stricter still. Analytics has its own sentence and its own column below.
|
||||
*
|
||||
* Left exactly as it was so that every existing consent record stays valid and
|
||||
* untouched — nobody has to be re-asked for something they already agreed to.
|
||||
*/
|
||||
export const MARKETING_CONSENT_TEXT =
|
||||
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
||||
|
||||
/**
|
||||
* Consent to the Brevo tracker (#56). Separate from marketing consent, and
|
||||
* separately refusable, because they are two purposes with two recipients.
|
||||
*
|
||||
* Names Brevo rather than saying "our email provider": informed consent means
|
||||
* the customer can tell who receives their data, and a description they cannot
|
||||
* act on is not disclosure. Says what is shared and why, states that it is
|
||||
* optional and independent of the emails, and states that it can be turned off
|
||||
* — withdrawal has to be as easy as giving it.
|
||||
*
|
||||
* Stored verbatim in `analytics_consent_text` for the same reason the marketing
|
||||
* sentence is: a record of consent that does not say what was consented to
|
||||
* cannot be audited, and re-wording this later must not silently broaden
|
||||
* anybody's agreement.
|
||||
*/
|
||||
export const ANALYTICS_CONSENT_TEXT =
|
||||
'I agree that what I browse and buy on this site may be shared with Brevo, the service that sends our emails, so that what they contain is relevant to me. This is optional, separate from receiving the emails themselves, and I can turn it off at any time.';
|
||||
|
||||
/**
|
||||
* Strips trailing slashes so a base URL can be joined with a stored path.
|
||||
*
|
||||
* A loop rather than `/\/+$/`, which backtracks: sonarjs flags that pattern as
|
||||
* super-linear, and the input here is an environment variable rather than
|
||||
* anything hostile, but the cheap version is no harder to read.
|
||||
*
|
||||
* Shared because two callers now need it — `/api/config` sends
|
||||
* `uploadsBaseUrl` this way, and the upload-link routes build a submission URL
|
||||
* from PUBLIC_URL. Stored paths always begin with a slash, so trimming the
|
||||
* base is what stops the join producing a double.
|
||||
*/
|
||||
export function trimTrailingSlashes(value: string): string {
|
||||
let trimmed = value;
|
||||
while (trimmed.endsWith('/')) trimmed = trimmed.slice(0, -1);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* A route's `:id` as a positive integer, or null when it is not one.
|
||||
*
|
||||
* Guarding this is not cosmetic. `Number('abc')` is NaN, which the driver sends
|
||||
* to Postgres as the text "NaN"; Postgres raises 22P02 for an integer column,
|
||||
* the route's catch turns that into a 500, and a caller asking for an item that
|
||||
* cannot exist is told the server broke. Returning null lets the route answer
|
||||
* 404, which is what "/items/abc" actually means. See #207.
|
||||
*
|
||||
* Rejects 0 and negatives as well as fractions: every id in this schema is a
|
||||
* positive serial, so anything else identifies nothing.
|
||||
*
|
||||
* Matched against decimal digits before parsing, because `Number` on its own is
|
||||
* far more permissive than "is this an id" wants. It reads `5.0`, `1e2`, `0x10`
|
||||
* and `+5` as 5, 100, 16 and 5 — each a positive integer, each passing the
|
||||
* checks below, and each therefore fetching a real row for a URL nobody wrote.
|
||||
* That is not a crash and so it never announced itself; #307 noticed it only
|
||||
* because #308 converted the comparison to a real integer. An id is a string of
|
||||
* digits, and anything else is a different request.
|
||||
*
|
||||
* Bounded at the top for the reason the whole function exists: the column is a
|
||||
* 32-bit serial, so an id above that limit reaches Postgres as an out-of-range
|
||||
* integer and raises 22003 — the same shape of failure as the 22P02 above, and
|
||||
* the same wrong answer to the caller. Below the limit it is a 404.
|
||||
*/
|
||||
const MAX_SERIAL_ID = 2147483647;
|
||||
|
||||
export function readId(value: string | undefined): number | null {
|
||||
if (value === undefined) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!/^\d+$/.test(trimmed)) return null;
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isInteger(parsed) && parsed > 0 && parsed <= MAX_SERIAL_ID ? parsed : null;
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* Writes the build stamp that the admin reads back (#233).
|
||||
*
|
||||
* Runs once, at the end of the Docker build, against the compiled output:
|
||||
*
|
||||
* npm run build && node dist/writeBuildInfo.js
|
||||
*
|
||||
* It has to run after `tsc` because `tsc` writes into `dist/` and would not
|
||||
* remove a JSON file placed there first — but ordering it explicitly means the
|
||||
* stamp is never left over from a previous build.
|
||||
*
|
||||
* Never fails the build. A missing or unreadable `.git` produces a stamp
|
||||
* saying `unknown`, which is a worse answer than a commit and a much better
|
||||
* one than a deploy that stopped. `.git` is absent from the final image by
|
||||
* design; only this build stage sees it.
|
||||
*/
|
||||
|
||||
import { writeFileSync, existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { resolveCommit, gitSourceAt, BUILD_INFO_PATH, UNKNOWN_COMMIT, BuildInfo } from './buildInfo';
|
||||
|
||||
/**
|
||||
* Where `.git` is, relative to wherever this was run from.
|
||||
*
|
||||
* Two layouts, both real. In the container the repository's `.git` is copied
|
||||
* beside the backend, so it sits in the working directory. Locally the backend
|
||||
* is a subdirectory of the repository, so it is one level up. An explicit
|
||||
* argument wins over both, which is what makes this testable by hand.
|
||||
*/
|
||||
function findGitDir(explicit?: string): string | null {
|
||||
const candidates = [
|
||||
explicit,
|
||||
path.join(process.cwd(), '.git'),
|
||||
path.join(process.cwd(), '..', '.git')
|
||||
].filter((candidate): candidate is string => typeof candidate === 'string');
|
||||
|
||||
return candidates.find((candidate) => existsSync(candidate)) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A commit passed in by whoever is building, or null.
|
||||
*
|
||||
* This is the half that actually works in the environments that matter. The
|
||||
* Dockerfile deliberately does not copy `.git` — doing so broke every Portainer
|
||||
* deploy (#235) — and #237 established that building in CI changes nothing,
|
||||
* because the copy is what was missing rather than the history. So the builder
|
||||
* has to hand the commit over rather than the build going to look for it (#248).
|
||||
*
|
||||
* Empty is treated as absent. A `--build-arg GIT_COMMIT=` with nothing after it
|
||||
* is what an unset shell variable expands to, and stamping the image with an
|
||||
* empty string would be worse than saying "unknown" — it reads as a commit that
|
||||
* happens to be blank rather than as one nobody supplied.
|
||||
*/
|
||||
function passedCommit(value: string | undefined): string | null {
|
||||
return value !== undefined && value.trim() !== '' ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function buildStamp(gitDir: string | null, passed?: string): BuildInfo {
|
||||
const supplied = passedCommit(passed);
|
||||
|
||||
return {
|
||||
// The passed value wins. It is the only one available where this matters,
|
||||
// and reading .git remains the fallback so a local build still stamps
|
||||
// itself without anyone having to remember the argument.
|
||||
commit: supplied ?? (gitDir ? resolveCommit(gitSourceAt(gitDir)) : UNKNOWN_COMMIT),
|
||||
// Whole seconds: this is read by a person comparing it to when they
|
||||
// pressed a button, not by anything that needs precision.
|
||||
builtAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
|
||||
};
|
||||
}
|
||||
|
||||
// Guarded so that importing this module cannot rewrite the stamp of a running
|
||||
// deployment — the same reasoning as backfillImageReencode.ts (#231).
|
||||
if (require.main === module) {
|
||||
const gitDir = findGitDir(process.argv[2]);
|
||||
const stamp = buildStamp(gitDir, process.env.GIT_COMMIT);
|
||||
|
||||
if (stamp.commit === UNKNOWN_COMMIT) {
|
||||
// Loud, because a deploy that cannot say what it is defeats the point of
|
||||
// the stamp — but a warning, not a failure.
|
||||
const where = gitDir ? ` at ${gitDir}` : '';
|
||||
console.warn(
|
||||
`[build-info] no GIT_COMMIT passed and no readable .git found${where} — ` +
|
||||
`the admin will report the commit as "${UNKNOWN_COMMIT}". ` +
|
||||
`Pass --build-arg GIT_COMMIT="$(git rev-parse --short HEAD)" to stamp it.`
|
||||
);
|
||||
}
|
||||
|
||||
writeFileSync(BUILD_INFO_PATH, `${JSON.stringify(stamp, null, 2)}\n`, 'utf8');
|
||||
console.info(`[build-info] ${stamp.commit} built ${stamp.builtAt}`);
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import request from 'supertest';
|
||||
import app from '../../src/app';
|
||||
import { pool } from '../../src/db';
|
||||
import { resetDb, closeDb } from './setup/testDb';
|
||||
|
||||
jest.mock('../../src/mailer', () => ({
|
||||
sendMail: jest.fn().mockResolvedValue(undefined)
|
||||
}));
|
||||
import { sendMail } from '../../src/mailer';
|
||||
const sentMail = sendMail as jest.MockedFunction<typeof sendMail>;
|
||||
|
||||
const PASSWORD = 'supersecret123';
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
sentMail.mockClear();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
await closeDb();
|
||||
});
|
||||
|
||||
async function register(email: string) {
|
||||
const agent = request.agent(app);
|
||||
const res = await agent
|
||||
.post('/api/customers/register')
|
||||
.send({ email, password: PASSWORD, firstName: 'Thom', lastName: 'Lamb' });
|
||||
expect(res.status).toBe(200);
|
||||
sentMail.mockClear();
|
||||
return agent;
|
||||
}
|
||||
|
||||
const recipients = () => sentMail.mock.calls.map(call => String(call[0]));
|
||||
|
||||
describe('editing your own name', () => {
|
||||
it('stores both parts', async () => {
|
||||
const agent = await register('namer@example.com');
|
||||
|
||||
const res = await agent.put('/api/customers/me').send({ firstName: ' Ada ', lastName: ' Lovelace ' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.first_name).toBe('Ada');
|
||||
expect(res.body.last_name).toBe('Lovelace');
|
||||
});
|
||||
|
||||
// Registration refuses these individually. Accepting them here would let a
|
||||
// customer clear fields they could not have skipped when signing up.
|
||||
it.each([
|
||||
[{ firstName: '', lastName: 'Lamb' }, 'first name is required'],
|
||||
[{ firstName: 'Thom', lastName: ' ' }, 'last name is required']
|
||||
])('refuses %p', async (body, expected) => {
|
||||
const agent = await register(`blank${Math.random().toString(36).slice(2, 8)}@example.com`);
|
||||
|
||||
const res = await agent.put('/api/customers/me').send(body);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe(expected);
|
||||
});
|
||||
|
||||
it('refuses an unauthenticated caller', async () => {
|
||||
const res = await request(app).put('/api/customers/me').send({ firstName: 'A', lastName: 'B' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changing your own password', () => {
|
||||
it('refuses without the current password', async () => {
|
||||
const agent = await register('pw1@example.com');
|
||||
|
||||
const res = await agent
|
||||
.post('/api/customers/change-password')
|
||||
.send({ currentPassword: 'wrong-password', newPassword: 'brandnewpass1' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
// The reason for ending sessions at all: a password is changed precisely when
|
||||
// the old one may be known to someone else, and a session opened with it
|
||||
// would otherwise outlive the change.
|
||||
it('ends other sessions but keeps the one making the change', async () => {
|
||||
const email = 'pw2@example.com';
|
||||
const here = await register(email);
|
||||
|
||||
// A second signed-in device.
|
||||
const elsewhere = request.agent(app);
|
||||
expect((await elsewhere.post('/api/customers/login').send({ email, password: PASSWORD })).status).toBe(200);
|
||||
expect((await elsewhere.get('/api/customers/me')).status).toBe(200);
|
||||
|
||||
const res = await here
|
||||
.post('/api/customers/change-password')
|
||||
.send({ currentPassword: PASSWORD, newPassword: 'brandnewpass1' });
|
||||
expect(res.status).toBe(204);
|
||||
|
||||
// The other device is signed out; this one is not. Asserted on the status,
|
||||
// because /me answers an unauthenticated caller with 401 and an error body
|
||||
// rather than an empty one — the frontend is what turns that into null.
|
||||
expect((await elsewhere.get('/api/customers/me')).status).toBe(401);
|
||||
const stillHere = await here.get('/api/customers/me');
|
||||
expect(stillHere.status).toBe(200);
|
||||
expect(stillHere.body.email).toBe(email);
|
||||
});
|
||||
|
||||
it('leaves the new password working and the old one not', async () => {
|
||||
const email = 'pw3@example.com';
|
||||
const agent = await register(email);
|
||||
await agent.post('/api/customers/change-password').send({
|
||||
currentPassword: PASSWORD,
|
||||
newPassword: 'brandnewpass1'
|
||||
});
|
||||
|
||||
const stale = request.agent(app);
|
||||
expect((await stale.post('/api/customers/login').send({ email, password: PASSWORD })).status).toBe(401);
|
||||
expect((await stale.post('/api/customers/login').send({ email, password: 'brandnewpass1' })).status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changing your own email address', () => {
|
||||
it('requires the current password, because this is how an account is taken over', async () => {
|
||||
const agent = await register('mail1@example.com');
|
||||
|
||||
const res = await agent
|
||||
.put('/api/customers/me/email')
|
||||
.send({ currentPassword: 'not-it', email: 'attacker@example.com' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
const { rows } = await pool.query(`SELECT email FROM customers WHERE email = $1`, ['mail1@example.com']);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(sentMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses an address that is not valid', async () => {
|
||||
const agent = await register('mail2@example.com');
|
||||
|
||||
const res = await agent
|
||||
.put('/api/customers/me/email')
|
||||
.send({ currentPassword: PASSWORD, email: 'not-an-address' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('refuses an address another account already holds', async () => {
|
||||
await register('taken@example.com');
|
||||
const agent = await register('mail3@example.com');
|
||||
|
||||
const res = await agent
|
||||
.put('/api/customers/me/email')
|
||||
.send({ currentPassword: PASSWORD, email: 'taken@example.com' });
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('changes the address, marks it unverified, and mints a fresh token', async () => {
|
||||
const agent = await register('mail4@example.com');
|
||||
|
||||
const res = await agent
|
||||
.put('/api/customers/me/email')
|
||||
.send({ currentPassword: PASSWORD, email: ' NewAddress@Example.com ' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.email).toBe('newaddress@example.com');
|
||||
expect(res.body.email_verified).toBe(false);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT COUNT(*)::int AS n FROM customer_tokens t
|
||||
JOIN customers c ON c.id = t.customer_id
|
||||
WHERE c.email = $1 AND t.kind = 'verify_email'`,
|
||||
['newaddress@example.com']
|
||||
);
|
||||
expect(rows[0].n).toBe(1);
|
||||
});
|
||||
|
||||
// Both messages matter, and they go to different places: verification to the
|
||||
// new address, and the warning to the one being replaced — which is the only
|
||||
// thing that tells a real owner their account was taken.
|
||||
it('mails the new address to verify it and the old one to warn it', async () => {
|
||||
const agent = await register('old@example.com');
|
||||
|
||||
await agent.put('/api/customers/me/email').send({
|
||||
currentPassword: PASSWORD,
|
||||
email: 'new@example.com'
|
||||
});
|
||||
|
||||
expect(recipients().sort()).toEqual(['new@example.com', 'old@example.com']);
|
||||
|
||||
const notice = sentMail.mock.calls.find(call => String(call[0]) === 'old@example.com');
|
||||
expect(String(notice?.[2])).toContain('new@example.com');
|
||||
});
|
||||
|
||||
it('refuses changing to the address already held', async () => {
|
||||
const agent = await register('same@example.com');
|
||||
|
||||
const res = await agent
|
||||
.put('/api/customers/me/email')
|
||||
.send({ currentPassword: PASSWORD, email: 'same@example.com' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user