Compare commits

..
Author SHA1 Message Date
synAdminandClaude Opus 5 1838bb38d1 docs(plans): record the Google sign-in plan where the other plans live
Linting / lint (pull_request) Successful in 3m11s
SonarQube Analysis / sonarqube (pull_request) Failing after 28m42s
The plan for this feature existed as a published page and nowhere in the repository, which made it the only sizeable piece of work here without a record beside its siblings in docs/superpowers/plans.

Written to that folder's conventions — dated filename, goal and architecture up front, global constraints, the file structure, then the phases as tasks. The boxes are checked rather than open, because all six phases merged before this was written and an implementation plan full of unticked work that is already done would read as a to-do list nobody had started.

It is a record rather than a reconstruction. The file list is taken from the commits themselves rather than from memory, and a check confirms every path it names exists.

The corrections section is the part worth keeping. Two things the plan asserted turned out to be false, and both are written down rather than quietly fixed: that QA could never run this feature, which cost a deploy and put the wrong constraint into a compose file and two issues; and that local development should register the port 3000 callback, when the dev server it actually browses is on 5173. A plan that silently stops saying something teaches nobody why it said it.

Also records the two smaller corrections made during the work — that account deletion never asked for a password, and that the button was initially missing from the sign-up tab — and the three follow-ups that were deliberately not built.

Verified: every file path named in the document exists in the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 07:13:03 -05:00
bermudalamb 0361ef35d0 Merge pull request 'docs(auth): correct the claim that QA could never run Google sign-in' (#356) from docs/correct-qa-google-claim into main
Linting / lint (push) Successful in 3m13s
SonarQube Analysis / sonarqube (push) Failing after 31m6s
Reviewed-on: #356
2026-09-11 15:16:28 -05:00
synAdminandClaude Opus 5 f5a29127fb docs(auth): correct the claim that QA could never run Google sign-in
Linting / lint (pull_request) Successful in 3m44s
SonarQube Analysis / sonarqube (pull_request) Failing after 28m29s
It can, and it does. Registering the QA callback under Authorized redirect URIs was all it took.

The claim was that qa-redefined-designs.bermudalamb.synology.me could never be registered, because Google requires a redirect URI's host to sit under a domain whose ownership has been proved by DNS, and Synology owns the domain above that one. It was inferred from #285, where Cloudflare's free tier genuinely cannot be applied to that hostname, and asserted with far more confidence than the inference supported. What was actually established is narrower: localhost is exempt from the authorized-domain rules, and a domain listed as an authorized domain has to be verified in Search Console. Whether either applied here was never checked.

It was not a harmless error. On the strength of it, QA testing of this feature was documented as blocked behind #313, the QA compose file hardcoded its credentials to empty rather than reading the stack, #345 recorded it as a constraint, and #332 closed with it written into the summary. A QA deploy was spent on it.

So the correction is left in place rather than the wrong sentences quietly deleted. A document that silently stops saying something teaches nobody why it said it, and this is the second time in this feature that a confident inference about somebody else's platform has cost a day — the first being the assumption that a passing local build said anything about another machine.

What replaces it is the thing that was always true and never written down plainly: every environment sends a redirect URI derived from its own PUBLIC_URL, and each one has to exist verbatim in the console. There is now one table listing all four, including the localhost:5173 entry that local development needs and that Phase 0 originally omitted — the omission that cost an hour of redirect_uri_mismatch before any of this.

The QA compose comment now says which URL to register rather than why it cannot be. The ops document gains the steps QA actually took, in order, with a note on why registering before setting the variables is the order that matters: a button that appears before its callback exists fails at Google, where nothing in the storefront logs explains it.

Verified: backend tsc clean, the QA compose file still parses, and no file in the tree still claims the hostname is unusable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 14:48:46 -05:00
bermudalamb c9dccfe4a2 Merge pull request 'chore(qa): read the Google credentials from the stack, like every other secret' (#355) from chore/qa-google-credentials-from-stack into main
Linting / lint (push) Successful in 3m36s
SonarQube Analysis / sonarqube (push) Failing after 30m5s
Reviewed-on: #355
2026-09-11 14:19:20 -05:00
synAdminandClaude Opus 5 903a1d8b76 chore(qa): read the Google credentials from the stack, like every other secret
Linting / lint (pull_request) Successful in 3m28s
SonarQube Analysis / sonarqube (pull_request) Failing after 28m14s
QA_GOOGLE_CLIENT_ID and QA_GOOGLE_CLIENT_SECRET were set on the QA stack and went nowhere, because #340 hardcoded the container's values empty rather than reading anything. The button stayed missing, correctly, but for a reason the file gave no way to discover: every other secret in it is read from a QA_-prefixed stack variable, and these two were the odd ones out.

So they are wired the way the rest of the file works. The deploy that prompted this cost nothing except time, and the next one would have cost the same again.

Wiring them is not the same as enabling them, and the comment now leads with that. **Leave both stack variables unset until QA moves off *.bermudalamb.synology.me.** Google refuses a redirect URI whose host is not under a domain whose ownership has been proved by DNS, and nobody can prove ownership of that one, because Synology owns the registrable domain above it — the same wall #285 hit with Cloudflare. Setting them today produces a button that fails at Google with redirect_uri_mismatch, and there is no console entry that could satisfy it.

Once #313 moves QA to qa.redefined-designs.com it is three steps and no code: set the two variables, point PUBLIC_URL at the new host, and add the matching callback under Clients in the Google Auth Platform.

Production already read its pair from the stack and is unchanged. The QA variable documentation at the top of the file gains an entry, matching the style of the others.

Verified: both files still parse as YAML, both substitutions resolve to the intended stack variables, and the compose environment guard passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 14:17:51 -05:00
bermudalamb 838749df64 Merge pull request 'fix(auth): offer Google on the sign-up tab, not only on Log In (#345)' (#354) from fix/google-button-missing-on-signup into main
Linting / lint (push) Successful in 3m19s
SonarQube Analysis / sonarqube (push) Failing after 32m51s
Reviewed-on: #354
2026-09-11 10:06:54 -05:00
synAdminandClaude Opus 5 3958bda489 fix(auth): offer Google on the sign-up tab, not only on Log In (#345)
The button was rendered inside the Log In tab's form only, so a visitor on Create Account saw no social option at all. Reported from a local run.

The mistake came from following the passkey button too closely. A passkey belongs only on Log In, and correctly so: you cannot register an account with one, since registration requires an account to register it against. Google is the opposite case. Creating an account is precisely what a new customer reaches for it to do, so leaving it off the sign-up tab hid the feature from the people it helps most — and hid it on the tab the modal opens on by default.

The label differs by tab and nothing else does. One endpoint serves both: it signs in a known identity, links a verified address, or creates an account, and the customer neither knows nor cares which will happen. So the wording matches what they came to that tab to do rather than what the server ends up doing. Both spellings are given in Google's identity guidelines alongside the mark.

The two consent checkboxes above it are deliberately not carried across. Google takes the customer off this site entirely, and a tick that survived that round trip would be a consent recorded from a form nobody submitted. They are asked again, with the same wording and through the same endpoints, on the step they land on afterwards — which is what #342 built that step for.

The end-to-end test asserts absence, like the one beside it, because local and QA have no credentials and absence is the behaviour that actually runs there.

Verified: frontend tsc, lint and build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:06:54 -05:00
bermudalamb b82b989cc8 Merge pull request 'fix(e2e): match an email template tab on its whole name, not a prefix' (#353) from fix/email-template-tab-name-collision into main
Linting / lint (push) Successful in 3m2s
SonarQube Analysis / sonarqube (push) Failing after 31m8s
Reviewed-on: #353
2026-09-11 10:04:42 -05:00
synAdminandClaude Opus 5 c2ee0b0c5d fix(e2e): match an email template tab on its whole name, not a prefix
SonarQube Analysis / sonarqube (pull_request) Canceled after 0s
Linting / lint (pull_request) Successful in 3m47s
CI failed on a test nobody had touched:

    strict mode violation: getByRole('tab', { name: /Email address changed/ })
    resolved to 2 elements

The locators in AdminEmails built an unanchored regex from the label, so a template whose name merely began with another's matched both. #337 added "Email address changed by the shop" alongside "Email address changed" and broke the assertion above it.

The failure named the assertion rather than the new template, which is what made it worth more than a rename. It is the same shape as the switch locator that silently retargeted in #317: a loose locator that keeps passing until something new is added nearby, and then fails somewhere that says nothing about the cause.

So the fix is the locator rather than the label. One helper now builds a matcher anchored at both ends, allowing only the optional "Customised" suffix a tab carries once its template has been edited, and escaping the label because these are copy and copy acquires brackets and full stops eventually. Both railTab and customisedTab go through it, and callers pass plain strings instead of assembling regexes at each call site.

The new template is added to the list the test walks, which is what it should have had in #337.

Verified the matcher against every real label plus the pairs that collide, including that a customised-only match still rejects an unedited tab. Frontend tsc and lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 09:59:05 -05:00
bermudalamb 843c51dd91 Merge pull request 'feat(auth): offer Google sign-in on the login form (#345)' (#352) from feature/345-google-button into main
Linting / lint (push) Successful in 2m49s
SonarQube Analysis / sonarqube (push) Failing after 30m25s
Reviewed-on: #352
2026-09-10 16:47:29 -05:00
synAdminandClaude Opus 5 2c6ac4d2be feat(auth): offer Google sign-in on the login form (#345)
Linting / lint (pull_request) Successful in 3m49s
SonarQube Analysis / sonarqube (pull_request) Failing after 30m35s
The last of the six, and the first a customer can see. The auth form is the single sign-in implementation rendered by both the route modal and the cart prompt, so the button goes in one place and appears in both.

Below the passkey button, which is below the password form. The order is deliberate and it is not about preference: a passkey is already on the device in front of the customer, while Google is a round trip to somebody else's site, and passwords are how every existing customer signs in. Each step down that list asks more of the person using it.

Absent rather than disabled where it is not configured, which is the same call #41 made for a browser without WebAuthn. It matters more here, because being unconfigured is the normal state rather than the exception: local development has no credentials, and QA cannot have any until #313. The storefront advertises a boolean through the existing public config, never the client id — the browser has no use for one, since the whole flow is a redirect the server builds.

Google's mark is inlined as SVG with their published colours and geometry. A hand-drawn approximation of somebody else's trademark is a compliance problem rather than a style choice, and a second origin on the sign-in path is a second thing that can be down.

The button is a navigation rather than a fetch, which makes it unlike every other control on that form. The flow leaves the application entirely, so there is no promise to await and no error to catch — the callback decides and redirects.

Where to return to is supplied by the caller, because only the caller knows. The route modal renders over a backdrop location and its own path is /login, so reading the current URL there would send the customer back to the form they just left; the router builds it from the backdrop instead. The cart prompt uses the page it interrupted. It cannot resume the interrupted action the way onSuccess does — the redirect leaves the app — so the customer lands back on the page and presses the button again.

That value is validated on the server and not in the browser. It has to be, since anyone can type the URL, and doing it in one place beats doing it twice in two languages.

The end-to-end test asserts the button is ABSENT, which is the behaviour local and QA actually have, and then signs in with the password form to show that its absence changes nothing. That is the point of putting the alternatives below rather than above.

docs/ops/google-sign-in.md records what has to be true outside the repository: the seven sections of the Google Auth Platform, the three scopes that keep publishing out of a verification review, the cutover checklist for #313, and the production smoke test. It states plainly that QA on the Synology hostname is impossible rather than merely unconfigured, because Google will not accept a redirect URI whose domain nobody can prove they own — the same wall #285 hit with Cloudflare.

The failure that document warns about hardest is leaving the consent screen in Testing. Only listed test users can then sign in, the refusal happens on Google's own page, and nothing reaches the storefront at all — so a customer reports a broken button and the logs are silent.

Verified: backend tsc clean for src and tests, 590 unit tests pass, lint at the seven warnings that predate this branch, frontend tsc, lint and build clean. The integration and end-to-end suites need a database this machine has no Docker for.

Closes #345

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 15:13:18 -05:00
bermudalamb 0014a8db8a Merge pull request 'feat(auth): the routes that assumed every customer has a password (#344)' (#351) from feature/344-life-without-a-password into main
Linting / lint (push) Successful in 3m34s
SonarQube Analysis / sonarqube (push) Failing after 32m46s
Reviewed-on: #351
2026-09-10 15:05:47 -05:00
synAdminandClaude Opus 5 dcb3c7c91b feat(auth): the routes that assumed every customer has a password (#344)
Linting / lint (pull_request) Successful in 2m55s
SonarQube Analysis / sonarqube (pull_request) Failing after 27m31s
The first accounts in this project's history have no password. Several things that were true stop being true, and one check written a month ago finally becomes reachable.

Setting a first password and changing an existing one stay one route. A customer who signed up with Google cannot supply a value that was never set, so asking for one is a dead end; what authorises the change is the session they are already holding, which is what authorises every other setting on the account page. Two routes 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 reads the stored hash rather than anything the caller sends, so a request cannot talk its way into the first-password case by omitting a field — there is a test for exactly that.

Changing the email address is refused instead, and the asymmetry is the point. Setting a first password changes a credential the customer already controls. Changing the address changes where recovery goes, and whoever holds the new one can reset the password and own the account outright. That is why the 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. The message says the real thing and names the way out, rather than claiming a password was wrong when there is none.

Login is left exactly as it was. Answering "this account has no password" to a submitted address would turn the form into an oracle for which customers use Google, so it keeps the single refusal and the account page is where a signed-in customer learns what they have. Two tests pin that, including the one where both the supplied password and the stored hash are empty — the combination most tempting to call a match, and the one that would let anyone sign in as any Google-only customer.

Deletion needed nothing, because it never asked for a password. That corrects what #332 recorded, and there is now a test so it stays true.

The passkey lockout guard runs for the first time. It was written in #40 against the condition rather than the schema and has been unreachable ever since, because password_hash was NOT NULL. Three tests exercise it now: refused when it is the only way in, allowed when a second passkey remains, allowed once a password has been set.

Two things about password reset were worth checking rather than assuming, and both turn out to be right as they stand. A customer who never had a password can still reset one, which is what somebody reaching for "forgot password" was asking for. And a reset still removes every passkey, per #42, because nothing about that path identifies who asked. What it does not do is sever the Google identity, and that asymmetry is deliberate: a passkey is a credential this shop issued and can revoke, while a Google identity is one Google holds, and cutting it would leave the customer unable to use the button they signed up with for no gain — whoever completed the reset controls the mailbox either way.

The account page is told whether a password exists, and nothing more. Offering to change a password to somebody who has never had one is a dead end; saying nothing leaves them unable to see a credential they are entitled to manage. So the panel is titled for what it does for this customer, the current-password field is absent rather than disabled, and the confirmation says they can now sign in with it as well as with Google.

Verified: backend tsc clean for src and tests, 590 unit tests pass, lint at the seven warnings that predate this branch, frontend tsc, lint and build clean. The integration suite needs a database this machine has no Docker for.

Closes #344

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 14:06:49 -05:00
bermudalamb c5014d5342 Merge pull request 'feat(auth): link a Google identity to an account that already exists (#343)' (#350) from feature/343-google-linking into main
Linting / lint (push) Successful in 3m3s
SonarQube Analysis / sonarqube (push) Failing after 29m34s
Reviewed-on: #350
2026-09-10 14:00:43 -05:00
synAdminandClaude Opus 5 44df0bd2d9 feat(auth): link a Google identity to an account that already exists (#343)
Linting / lint (pull_request) Successful in 3m52s
SonarQube Analysis / sonarqube (pull_request) Failing after 29m7s
The smallest change 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 belonging to somebody who did.

The rule is one line at the top of linkIdentity.ts: link only when Google asserts the address is verified, and refuse otherwise. Everything below it is bookkeeping.

That is defensible for Google specifically, and the reasoning is worth stating rather than assuming. Google asserting the address means whoever completed the sign-in demonstrably controls the mailbox, and 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 takes 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 their password.

Never on an unverified address. That is not a weaker version of the same thing; it is an account takeover with extra steps, because the assertion would be one nobody checked. There is a test for the specific trap: the string "false" is truthy, and if that check ever becomes a truthiness test then every unverified Google account links to whatever account holds its address.

The order matters and is an order rather than a set of independent checks. The identity lookup runs first and nothing else is consulted when it matches, which is why an identity that has signed in before keeps working after the address changes on either side. There is a test where a second customer has since taken the address the Google account reports, and the sign-in correctly reaches the first.

Linking to a disabled account is refused, and the reason is not obvious. Linking 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.

The refusal gets its own destination rather than the generic failure. It is the one refusal in this flow a customer can act on: they have an account and simply cannot reach it this way, so the login form now says to use the password they already have. That reveals nothing, because they arrived holding a Google account for that address — being told the address has an account here tells them only about themselves.

Deciding this in newCustomer.ts, where the unique constraint already fires, was the shape to avoid. An account must never be handed over as a side effect of an INSERT failing, so that module reports the address is taken and stops, and the policy lives somewhere it can be read on its own.

Automatic linking is defensible but it is not obvious, so the account page now shows it. 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. It sits beside the passkeys for the reason that list exists at all: a customer cannot manage credentials they cannot see. The endpoint never returns the provider subject, which is the same reasoning that keeps credential ids out of the passkey list.

No unlinking. Removing the only way into an account is the question #344 settles, and offering that button before the check runs would be the fastest possible way to lock somebody out of their own orders.

Verified: backend tsc clean for src and tests, 590 unit tests pass, lint at the seven warnings that predate this branch, frontend tsc, lint and build clean. The integration suite needs a database this machine has no Docker for.

Closes #343

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 10:18:05 -05:00
bermudalamb 8a5f6eb08c Merge pull request 'feat(auth): create an account from a Google identity, then ask about consent (#342)' (#349) from feature/342-google-new-accounts into main
Linting / lint (push) Successful in 3m4s
SonarQube Analysis / sonarqube (push) Failing after 39m7s
Reviewed-on: #349
2026-09-10 10:11:16 -05:00
synAdminandClaude Opus 5 25078417e5 feat(auth): create an account from a Google identity, then ask about consent (#342)
A Google account nobody here has seen now becomes a customer. The OAuth part of this was the easy half; the problem worth the issue is consent.

Registration asks for two consents and stores their wording verbatim, and marketing consent must start unticked. Somebody arriving through Google has never seen those checkboxes and could not have, because the redirect happened before anyone knew whether they were new.

Creating the account with both false is legally correct: nobody agreed to anything, and nothing is recorded as though they had. There is no stored wording either, because a wording saved against a false consent is a record of a conversation that never happened. But stopping there would mean a Google sign-up is never asked at all, and a silent no is still a decision made on somebody else's behalf.

So the account is created, the customer is signed in, and they land on a step that shows the same two sentences with the same two unticked boxes. It saves through the endpoints registration already uses, which is what keeps the stored text byte-identical rather than merely similar. Not now is offered as an equal option, because consent has to be as easy to withhold as to give, and both can be changed later from the account page.

The wording on that screen is imported from the shared constants rather than retyped. Three different wordings were already in circulation once before that was shared, and the record is meant to say what the customer actually saw.

The return path is deliberately dropped for a new customer, who lands on the consent step instead. Carrying it through as a query parameter was the alternative and was rejected: the consent page would then redirect somewhere a URL told it to, which is the open-redirect question already answered 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 much the cheaper of the two.

The customer and the identity are inserted in one transaction. A customer row with no identity is an account nobody can sign in to and nobody can recover, because it has no password either.

Signing up is refused when the address already belongs to a customer. Joining those two accounts is linking, it is the most security-sensitive decision in this project, and it belongs to the next issue rather than falling out of an INSERT here. Refusing is the safe half of that decision and the only half available until the policy is written down. The unique index rather than the preceding SELECT is what actually holds when two sign-ins race, so losing that race is treated as the address being taken rather than as an error.

Google's assertion about the address is taken only when it is the boolean true. When it holds, the account is marked verified and no confirmation email is sent, because that email exists to prove the customer receives mail at the address and Google has just proved exactly that. When it does not, the account is unverified and goes through the ordinary confirmation, because an unverified assertion is worth nothing.

Names from the profile are hints. Registration demands both because every email greets by first name, but Google may return neither and refusing a sign-in over it would be absurd — the greeting already has a fallback for exactly this case.

The tests worth reading are the two about a returning customer. One signs in again and reaches the same account; the other changes their Google address first and still reaches it. That second one is the whole reason the identity is keyed on the subject claim: an email match would have created a second account there, and an address that had since been reassigned would have handed the first one to a stranger.

Verified: backend tsc clean for src and tests, 590 unit tests pass, lint at the seven warnings that predate this branch, frontend tsc, lint and build clean. The integration suite needs a database this machine has no Docker for.

Closes #342

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 10:11:16 -05:00
bermudalamb 79a2b606ea Merge pull request 'fix(local): install dependencies when the lockfile changes, not only when node_modules is absent' (#348) from fix/local-install-skips-new-dependencies into main
Linting / lint (push) Successful in 3m10s
SonarQube Analysis / sonarqube (push) Failing after 33m49s
Reviewed-on: #348
2026-09-10 10:06:58 -05:00
synAdminandClaude Opus 5 022ab8edcf fix(local): install dependencies when the lockfile changes, not only when node_modules is absent
Starting the stack locally failed to build with four copies of

    error TS2307: Cannot find module '@simplewebauthn/server'

naming a package that is right there in package.json. That reads as a broken checkout rather than a missing install, which is why it costs more than it should.

The cause is one line in Install-IfMissing. It asked whether node_modules existed and returned early if it did, which is true for anyone who has ever run the script. So a branch that ADDS a dependency never installs it: the pull brings a new package.json and a new lockfile, the script says dependencies already installed, and the build then fails on an import the source is entirely right to make.

The passkeys work is what surfaced it, adding @simplewebauthn/server to the backend and @simplewebauthn/browser to the frontend, but nothing about it is specific to those. Any dependency added on any branch would have done the same, and the failure would have looked equally unrelated to its cause each time.

It now compares timestamps instead. npm writes node_modules/.package-lock.json describing exactly what it put there, so holding that against package-lock.json answers the question actually being asked: is what is installed what is currently asked for. A pull that changes dependencies makes the lockfile newer and this notices; a pull that does not leaves the check skipping the install exactly as before, which is the whole reason the check exists.

Both branches were exercised against the real working tree: stale before installing, up to date after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 10:06:58 -05:00
bermudalamb 9177b54dea Merge pull request 'feat(auth): the Google sign-in round trip (#341)' (#347) from feature/341-google-round-trip into main
Linting / lint (push) Successful in 3m31s
SonarQube Analysis / sonarqube (push) Failing after 34m6s
Reviewed-on: #347
2026-09-10 08:53:00 -05:00
synAdminandClaude Opus 5 87f07baaff feat(auth): the Google sign-in round trip (#341)
Linting / lint (pull_request) Successful in 3m39s
SonarQube Analysis / sonarqube (pull_request) Failing after 28m35s
Two routes, and a customer whose Google identity is already linked can sign in. Creating accounts and linking them are deliberately held back to the next two issues, so this change is about the protocol alone and can be reviewed as such.

The authorization code flow, with PKCE. The browser is sent to Google, comes back carrying a code, and this server exchanges it over its own TLS connection, so nothing that can read the page ever holds a token. PKCE goes in even though this is a confidential client with a secret: it costs one hash and closes code interception outright rather than resting the whole flow on the secret staying secret.

No JWKS fetch, and the reasoning is written into the module rather than left to be rediscovered. The id token arrives in the response to a request this server made, over TLS, directly to Google's token endpoint, which is exactly the case OpenID Connect permits skipping signature verification for. That removes a key fetch, a cache and a rotation path from the part of the codebase least worth having moving parts in. It removes none of the claim checks, and the comment says plainly that the moment an id token reaches this code from anywhere else, the reasoning stops holding.

So the claim checks are load-bearing rather than belt and braces, and each has a test naming what accepting it blindly would allow. A wrong audience is a token minted for another application being replayed here. A wrong nonce is a token from an earlier attempt. A missing subject is an identity row keyed on nothing. Both spellings of the issuer are accepted because Google really does send both, and taking only one produces sign-ins that fail for some customers and not others.

email_verified is compared to the boolean and never merely tested for truthiness. The string "false" is truthy, and the linking policy turns entirely on this flag, so that one line is the difference between a policy and an account-takeover path.

The attempt cookie is the whole security of the callback, which is a plain GET anyone on the internet can invoke. It carries three secrets, minted separately because they are checked by different parties at different moments: state proves the callback belongs to the request this browser started, nonce proves the token was minted for this attempt, and the verifier proves the code is being spent by whoever asked for it. It is cleared on every path through the callback, so one attempt cannot be replayed even once.

SameSite is Lax and not Strict, and that line has the longest comment in the file because it is the most expensive thing here to get wrong. The callback arrives as a cross-site top-level navigation; Strict withholds the cookie, the state check then fails, and every sign-in is refused with an error that looks exactly like tampering.

Where the customer returns to survives the round trip in that cookie, and it is a value an attacker can propose. Unchecked, the start route is an open redirect wearing a sign-in flow as a disguise: a link on our own domain, with our own certificate, that lands somewhere else. Its own module, so it can be tested without a database and so the next path needing the same question has an obvious place to ask it. Its first draft used a regex that inverted its own character class and rejected every path, which passed every other test and would have broken every real sign-in — there is now a test for exactly that.

Declining at Google's consent screen is a cancellation rather than a failure. The customer goes back where they were with nothing said, the same distinction #41 drew for a dismissed passkey prompt.

A disabled account is refused here as well, because enforcing it on some sign-in routes and not others is how a disabled account keeps a way in.

Signing in calls the shared function, not a third implementation that agrees today. There is a test that the resulting session is accepted by an unrelated route, which is what makes that sharing worth something rather than merely tidy.

Verified: backend tsc clean for src and tests, 590 unit tests pass, lint back to the seven warnings that predate this branch. The integration suite covers the routes end to end with only the token exchange stubbed, and needs a database this machine has no Docker for.

Closes #341

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 08:49:42 -05:00
bermudalamb 607881b711 Merge pull request 'feat(auth): groundwork for signing in with Google (#340)' (#346) from feature/340-google-groundwork into main
Linting / lint (push) Successful in 3m5s
SonarQube Analysis / sonarqube (push) Failing after 33m55s
Reviewed-on: #346
2026-09-10 08:39:21 -05:00
synAdminandClaude Opus 5 9cc82002b8 feat(auth): groundwork for signing in with Google (#340)
Linting / lint (pull_request) Successful in 3m19s
SonarQube Analysis / sonarqube (pull_request) Failing after 27m40s
Nothing a customer can see. The schema change and the configuration land on their own so the widest-reaching edit in the project can be reviewed for what it is rather than buried inside a feature.

The password hash becomes nullable. That is one line and it is not the work; the work is that every read of the column is now a question rather than a fact. Three places compared against it with bcrypt, and all three now ask first through one shared function.

That function exists because the alternative is worse than a wrong answer. bcrypt.compare throws on a null hash rather than returning false, so any call site that forgot the check would answer a sign-in attempt with a 500 instead of a refusal. On the login route that is also an oracle, because it would happen for exactly the accounts that have no password. One function rather than a null check repeated three times means the question is asked identically everywhere and a fourth site cannot forget to ask it.

Nothing writes a null yet. The first accounts without a password arrive with the sign-up path, which is why this is landed ahead of them.

The identities table is a table rather than columns on customers, because one customer may eventually hold more than one. Columns would make a second provider a migration and a third an embarrassment.

Its important column is the provider subject, and the comment on it is the whole security posture of the feature in one place: never the email. An email is a display value its owner can change and a provider may reassign; a subject is opaque and stable for the life of the account. Matching on the email would strand a customer who changed theirs and, far worse, hand their account to whoever inherited the old address. Unique across the provider and subject together, not the subject alone.

The down migration drops the table and deliberately does not restore the NOT NULL. Re-adding it fails outright once a passwordless customer exists, and a down migration that destroys accounts to satisfy a constraint is far worse than a column that is merely more permissive than it needs to be.

The redirect URI is derived from PUBLIC_URL, the same single source the WebAuthn Relying Party ID uses and for the same reason: Google compares it as an exact string and answers a mismatch with a message that says nothing about which half is wrong. Deriving it means the value is correct by construction anywhere the email links already are. The tests are mostly about what must not end up in it, since a trailing slash on PUBLIC_URL is an easy way to produce a URI that is one character from the registered one.

The config also reports whether it is enabled at all, so a developer without credentials gets a storefront that works and simply does not offer the button, rather than one that offers it and fails. Absent rather than disabled, the same choice made for a browser without WebAuthn.

Environment validation refuses to boot on one credential without the other, matching how the SMTP pair is handled. Half-configured is the case worth catching because the failure otherwise arrives at the moment a customer presses the button.

The QA compose file sets both to empty, and the comment there says why at length rather than leaving it to look like an oversight. Google refuses a redirect URI whose host is not under a domain whose ownership has been proved by DNS, and nobody can prove ownership of anything under bermudalamb.synology.me because Synology owns the registrable domain above it. That is the same wall #285 hit with Cloudflare. So QA cannot run this at all until #313 moves it to a subdomain of the real domain, at which point it is two stack variables and one console entry, with no code change either way.

Also corrects the record in #332, which lists account deletion as confirming with a password. It does not; the route takes none and the confirmation is a modal in the account page. Deletion needed no change here.

Verified: backend tsc clean for src and tests, 550 unit tests pass including new coverage of the config derivation, the null-hash comparison and the environment rules; lint clean apart from warnings that predate this branch. The integration suite needs a database this machine has no Docker for.

Closes #340

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 08:18:51 -05:00
bermudalamb d69091d5ad Merge pull request 'fix(ci): the cleanup script forced TLS onto a plaintext endpoint (#324)' (#339) from fix/324-cleanup-honours-scheme into main
Linting / lint (push) Successful in 2m50s
SonarQube Analysis / sonarqube (push) Failing after 31m9s
Reviewed-on: #339
2026-09-10 07:19:47 -05:00
synAdminandClaude Opus 5 f9c40146d4 fix(ci): the cleanup script forced TLS onto a plaintext endpoint (#324)
The workflow failed on its first real run with:

    write EPROTO ... ssl3_get_record:wrong version number

which reads like a TLS misconfiguration and sends you looking at certificates and protocol versions. It is neither. The server answered in cleartext and OpenSSL tried to parse that as a TLS record.

The script required node's https module and always used it, defaulting to port 443. That was fine while the host was typed by hand, and it stopped being fine the moment the workflow started supplying it from github.server_url. Inside the runner that is the address act_runner reaches Gitea on, not the public one, and here it is plain HTTP on a container port.

So the scheme in GITEA_HOST is honoured rather than assumed, and the default port follows from it. A URL naming neither http nor https is refused up front, because this script speaks nothing else and reporting that as a bad input beats failing later inside a request.

The endpoint is now printed before the first request rather than after one succeeds. That is the part that made this cost more than it should have: a transport failure said nothing about where it had been pointed, so the message named a symptom in OpenSSL and nothing about the run at all.

Verified against a plaintext HTTP stub end to end: the listing, the age selection and the dry-run report all work over http, and a bad scheme exits 1 with a message naming the value it was given.

Refs #324

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 07:19:47 -05:00
bermudalamb 401cacaac0 Merge pull request 'feat(admin): move a customer's account to an address they can reach (#337)' (#338) from feature/337-admin-change-customer-email into main
Linting / lint (push) Successful in 2m52s
SonarQube Analysis / sonarqube (push) Failing after 29m19s
Reviewed-on: #338
2026-09-09 16:38:34 -05:00
synAdminandClaude Opus 5 90e372d6bd feat(admin): move a customer's account to an address they can reach (#337)
Linting / lint (pull_request) Successful in 3m37s
SonarQube Analysis / sonarqube (pull_request) Failing after 35m39s
The third step of the only recovery route a customer who has lost their mailbox has. The first two are contacting the shop and being verified against order history. The third had no implementation, so the answer was a hand-written database edit that left no record of who did it or why.

The thing to say plainly, because everything here follows from it: this operation and an account takeover are the same operation. They differ only in whether the verification was sound, and nothing in the software can check that. What the software can do is make the change recorded, announced, and complete in its effects.

Recorded. The endpoint refuses without a written reason, and the reason is stored against the account. That row is the only thing that tells a genuine recovery from a takeover afterwards, which is why a hand edit was never acceptable and why the field is required by the server rather than merely collected by the form. It is never shown to the customer: it is a note about how somebody was verified and can name things the customer should not be handed back.

There is no column for who did it. Admin access is one shared gate secret in front of a single operator, so such a column could only ever hold a constant, and a constant dressed up as an identity is worse than an honest absence.

Announced, to the address being replaced. If the recovery was sound that reaches nobody and costs nothing. If it was not, it reaches the real owner, who is the only person in the world who can say so, and that is the only reason this endpoint is safe to have at all. Its own template rather than the self-service one, because that copy says to contact us if you did not make this change, and here somebody already did — the sentence would be addressed to the customer who just did the thing it asks for, while the person who needs to act on it did nothing.

The new address is marked unverified and sent a confirmation link. Somebody reading an address out over the phone has not demonstrated they can receive mail at it, and that is the commonest way this goes wrong harmlessly.

Complete in its effects. The move signs the customer out everywhere, removes every passkey, and cancels reset links already sent. That is the conclusion #42 reached for password reset, and it applies here with more force: somebody the system cannot identify asked for this change, so a session or a credential surviving it is one the new owner cannot see and cannot revoke, and a reset link sitting in the mailbox being taken away would let whoever still reads it take the account straight back.

The password is left alone. What the customer lost was the mailbox, so demanding a new one adds a step for no gain.

The verification-email helper moved out of the customers route into its own module, for the reason session creation moved out for passkeys: two implementations that agree today are two that can be changed one at a time, and the one that gets forgotten is whichever the manual testing does not exercise. This path runs perhaps once a year, so it is exactly the one that would rot.

The admin drawer gains the action next to the address rather than among the account controls, because it is a thing done to that field by someone already looking at it. It leads with the warning instead of burying it. The history of moves sits on the same drawer and renders nothing at all for the overwhelming majority of customers, who have never been moved.

Verified: backend tsc clean for src and tests, 526 unit tests pass, lint clean apart from warnings that predate this branch; frontend tsc, lint and build clean. The integration suite needs a database this machine has no Docker for. It also cannot be proven by CI right now — run 917 has been hung since it started and 24 runs are queued behind it, which is the same hang #154 identifies as the source of the leftover Postgres containers.

Closes #337

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:36:34 -05:00
bermudalamb 20554a4f1d Merge pull request 'feat(passkeys): a password reset takes the passkeys with it (#42)' (#336) from feature/42-passkey-account-recovery into main
Linting / lint (push) Successful in 3m2s
SonarQube Analysis / sonarqube (push) Failing after 32m47s
Reviewed-on: #336
2026-09-09 16:22:12 -05:00
synAdminandClaude Opus 5 36dbf18916 feat(passkeys): a password reset takes the passkeys with it (#42)
Linting / lint (pull_request) Canceled after 0s
SonarQube Analysis / sonarqube (pull_request) Canceled after 0s
The last issue in the passkeys project, and the only one that adds no capability. It is the safety net, and it exists because this is the piece most likely to be skipped and most expensive to discover missing.

A reset is the recovery path, and recovery has to be complete. The reset already deletes every session on the account, on the reasoning that a reset prompted by a compromise must not leave an intruder signed in for the remaining thirty days of their cookie. A passkey an intruder registered has no expiry at all. Leaving those behind would mean a customer can recover their password and still not have their account back.

The obvious objection is that this hands whoever controls the mailbox a way to 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 by that point.

Anything in flight goes too. An intruder who pressed add a passkey moments before the reset could otherwise finish that ceremony afterwards and put a credential straight back onto the account the reset had just cleared.

Changing a password deliberately does not do this, and the asymmetry is the point. A change requires the current password from someone already signed in, so nothing about it suggests a lockout or a compromise, and it already spares the current session for the same reason. A customer who suspects one particular device revokes that device by name from the account page, which is a better tool than deleting everything. A reset has no idea which credential is the problem, so it takes all of them.

The customer is told twice. Before, in the reset email and on the reset form, unconditionally — that form has no session and is never told whether the account has passkeys, because answering that would make the reset page an oracle for it, so the wording has to read the same to someone who has none. After, with a count, and only when the count is not zero. That moment is the only one where the count can be reported: the rows are gone by the time anyone could go and look. A customer told two were removed who only remembers registering one has just learned something they could not otherwise find out.

That notice is a panel that waits to be dismissed rather than a toast, because a toast dismisses itself and this is the message a customer needs to still be looking at while they decide what to do about it.

The other question this issue asks — whether a reset ends a session established by a passkey — turns out to need no code, because #39 made both paths call one createSession. But "it falls out for free" is a claim, so there is now a test that establishes a session through that exact function and watches the reset end it.

docs/ops/account-recovery.md records the whole policy, including the two answers that are not code. Losing an authenticator is not a lockout: the customer signs in with their password and revokes the lost credential themselves, which is why this change implements nothing for it. Losing the email address is a lockout, and there is deliberately no self-service route out — 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. The manual route runs through the shop owner verifying against order history, and its third step, changing the address from the admin screen, does not exist yet. That is written down as a gap with its own notification and audit questions rather than smuggled in here.

Verified: backend tsc clean for src and tests, 521 unit tests pass, lint clean apart from warnings that predate this branch; frontend tsc, lint and build clean. The integration and end-to-end suites need a database this machine has no Docker for, so CI is what proves those.

Closes #42

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:03:39 -05:00
bermudalamb a0238ea4ed Merge pull request 'feat(passkeys): offer passkey sign-in on the login form (#41)' (#335) from feature/41-passkey-login-page into main
Linting / lint (push) Canceled after 0s
SonarQube Analysis / sonarqube (push) Canceled after 0s
Reviewed-on: #335
2026-09-09 15:53:19 -05:00
synAdminandClaude Opus 5 72e8090fd1 feat(passkeys): offer passkey sign-in on the login form (#41)
Linting / lint (pull_request) Canceled after 0s
SonarQube Analysis / sonarqube (pull_request) Canceled after 0s
The point at which passkeys become visible to customers. Everything before this was reachable only by knowing the endpoints existed.

Below the password form rather than above it. Passwords are how every existing customer signs in and a passkey is the alternative, so putting it first would demote the path that works for everyone.

Absent entirely where WebAuthn is unavailable, rather than shown disabled. A greyed-out control invites a customer to wonder what they are missing and offers nothing they can act on, and password login is the fallback in every case regardless. The check is read once at render because it decides whether the control exists, not whether pressing it works.

The passkey button has its own loading flag rather than sharing the form's. The requirement is that a dismissed prompt leaves a usable password form behind it, and a shared flag would leave that form disabled and spinning while the browser's prompt is open.

Dismissing the prompt is a cancellation and shows nothing. NotAllowedError and AbortError are the two the browser raises for it, and reporting either as a failure would tell a customer something went wrong when they changed their mind — leaving a red alert sitting above a form that is working perfectly. Everything else shows a message that says what to do next rather than only that something failed.

That message says nothing about whether an account exists, which costs nothing to hold to here because the server already answers every refusal identically. There is also no email on this path at all, so there is nothing to be asked about.

The end-to-end test covers the half of this issue that can be proven without an authenticator. The failure is injected at the first request, before the browser prompt, so it needs no credential and cannot hang waiting for a gesture nobody will make — and then the password form behind the error is used to sign in for real. That is the requirement: not a dead end.

The other half cannot be tested anywhere but production, and this issue says so itself. Credentials bind to the Relying Party ID, so a passkey registered against QA will not work against production. QA proves the flow, the fallbacks and the copy; production needs its own smoke test with a real registration afterwards, and that is a standing property of the feature rather than a gap in this change.

Verified: tsc clean for src and tests, lint clean with no warnings, frontend build green.

Closes #41

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 15:50:59 -05:00
bermudalamb 1da4dc7acc Merge pull request 'feat(passkeys): list, add and revoke from the account page (#40)' (#334) from feature/40-manage-passkeys into main
Linting / lint (push) Canceled after 0s
SonarQube Analysis / sonarqube (push) Canceled after 0s
Reviewed-on: #334
2026-09-09 15:44:49 -05:00
synAdminandClaude Opus 5 1c8763f30f feat(passkeys): list, add and revoke from the account page (#40)
Linting / lint (pull_request) Canceled after 0s
SonarQube Analysis / sonarqube (pull_request) Canceled after 0s
The issue calls this the smallest one in the project and the one that makes the rest usable, and that is right: registering a passkey with no way to see or remove it is worse than not offering passkeys at all.

Revocation is the row going away. #39 looks a 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. The delete is scoped to the signed-in customer in the same statement that removes the row, because a credential id is not a secret and the WHERE clause is the only thing making this safe. Reading first and deleting after would leave a window.

Both "no such credential" and "not yours" answer 404. The second is the interesting case, and saying so would confirm that some other customer holds that id.

The lockout check is written even though it cannot fire. password_hash is NOT NULL, so every customer has a password and removing every passkey still leaves a way in. The issue asks for the check anyway and that is the right call, because it is written against the condition rather than against today's schema — 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, and at that point this branch starts running for real.

The list returns name, added and last used, and nothing else. No public key, no credential id, no counter — the customer cannot act on any of them, and the credential id is the one value that identifies an authenticator to anyone holding it. Last used is what actually tells two entries apart when the names are similar: someone about to revoke one needs to know which device they are cutting off, and a creation date does not answer that.

The whole ceremony lives in customerApi rather than the component, because it is one operation: options from the server, an attestation from the browser, verification back at the server. A component holding that intermediate state could leave a challenge issued and never answered.

Dismissing the browser's prompt rejects, and that is a cancellation rather than a failure. Reporting it as an error would tell a customer something went wrong when they simply changed their mind, so NotAllowedError and AbortError are swallowed and everything else is shown. The server's message on a refused revoke is shown as it arrives too — it says what to do about the last way in, and a generic message would strand the customer on a button that just does not work.

The section renders nothing where WebAuthn is unavailable, rather than offering a button that cannot work. Same rule #41 applies to the login form.

Verified: tsc clean for src and tests in both workspaces, backend lint at the seven pre-existing warnings with none added, frontend lint clean, 521 unit tests across 36 suites, frontend build green.

Not verified: the registration ceremony needs a browser and a real authenticator, which per #41 is a standing limitation of this feature. What CI can prove is the list and revoke endpoints, which are ordinary routes.

Closes #40

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 14:41:37 -05:00
bermudalamb 36bbaf99e4 Merge pull request 'feat(passkeys): authentication ceremony (#39)' (#333) from feature/39-passkey-authentication into main
Linting / lint (push) Canceled after 0s
SonarQube Analysis / sonarqube (push) Canceled after 0s
Reviewed-on: #333
2026-09-09 14:32:25 -05:00
synAdminandClaude Opus 5 f95013850b feat(passkeys): authentication ceremony (#39)
Linting / lint (pull_request) Canceled after 0s
SonarQube Analysis / sonarqube (pull_request) Canceled after 0s
A customer signs in with a registered passkey. Usernameless: they are never asked who they are, the browser offers whichever accounts it holds for this Relying Party, and the assertion says which credential answered. #38 requested discoverable credentials so this would work.

That choice does more than improve the experience. This issue requires that failures not reveal whether an email has an account or has passkeys registered, and with no email ever sent to the endpoint there is nothing to reveal. The email-first alternative would have had to answer identically for a known and an unknown address, in every branch, forever.

Session creation is shared rather than reimplemented, which is the requirement stated most sharply here: a second, subtly different session path is how auth bugs get in. setSessionCookie and createSession move to customerSession.ts and both paths import them. Two implementations that agree today are two 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. Social sign-in will use the same module when #332 lands.

The signature counter policy #37 deferred is decided here, and both halves matter. Requiring an increase from every authenticator refuses synced passkeys, which report zero forever by design and are what most customers actually use. Requiring it from none discards the only signal that a hardware credential has been cloned. So zero against zero is accepted and anything else must strictly increase — and the asymmetry is deliberate, because an authenticator that has ever reported a real counter is held to the strict rule from then on and cannot downgrade itself to zero to escape it.

A disabled account is refused, read from the same row as the credential rather than a second query that could disagree. Enforcing that only on the password path would have left passkeys as a way around it.

Every refusal answers the same way. No such credential, a disabled account, a bad assertion and a stalled counter are all that did not work to the caller; saying which would turn the endpoint into an oracle for whether a credential exists and whether its account is in good standing. The stalled counter is logged, because the customer cannot act on it and the person who can is reading the logs.

The challenge is spent by deleting it, with the expiry in the same statement, so a replay finds nothing to delete and a stale challenge fails the same way. It is passed to the library as a predicate rather than a value, which is what makes a usernameless flow possible at all — the challenge is not known until the assertion names it.

That predicate is a named function rather than an inline callback, and it was inline first. 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. The guard caught it, and hoisting the function out was the better fix: the code reads more clearly and the guard keeps its teeth rather than learning another exception.

Verified: tsc clean for src and tests, lint back to the seven pre-existing warnings with none added, 521 unit tests across 36 suites — seven new, covering the counter policy in both directions.

Not verified: the ceremony cannot be exercised without a browser and a real authenticator, which per #41 is a standing limitation of this feature rather than a gap here. CI can prove the routes exist, are wrapped, and refuse a caller with no credential.

Closes #39

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 14:30:17 -05:00
bermudalamb fc674561e9 Merge pull request 'feat(passkeys): registration ceremony (#38)' (#331) from feature/38-passkey-registration into main
Linting / lint (push) Canceled after 0s
SonarQube Analysis / sonarqube (push) Canceled after 0s
Reviewed-on: #331
2026-09-09 14:21:47 -05:00
synAdminandClaude Opus 5 88f76926d5 feat(passkeys): registration ceremony (#38)
Linting / lint (pull_request) Canceled after 0s
SonarQube Analysis / sonarqube (pull_request) Canceled after 0s
A signed-in customer can register a passkey. Signing in with one is #39 and the management screen is #40, so nothing reads these credentials yet.

The name column arrives here rather than in #37. That issue listed the columns the ceremony needs and this one is for the person: #40 shows a list and offers to revoke from it, and "phone" against "laptop" is the only thing that makes two rows tellable apart. Without it a customer revoking a credential is choosing between identical entries. The customer may name it, and otherwise it is derived from the authenticator's transports — a hint rather than a fact, so the defaults are deliberately vague. "This device" is honest about a platform authenticator in a way that guessing at a model name would not be.

Single use is enforced by deleting the challenge and treating the delete as the check, in one statement with the expiry condition. A replayed response finds nothing to delete and is refused, and two requests racing cannot both see the row and both proceed. Beginning a second registration replaces any in-flight challenge for that customer, so pressing the button twice cannot leave the first one usable.

The challenge is consumed before the response is verified, deliberately. A failed attempt must not leave one available for a second try, so an invalid response costs the ceremony rather than merely failing it.

Never started, already used and expired all answer the same way. From the server they are one condition — no challenge this customer may still complete — and distinguishing them would tell someone guessing which guess was closest.

excludeCredentials stops the same authenticator being enrolled twice, but it is a hint the browser may ignore, so the unique constraint on credential_id is what actually holds. Hitting it answers 409: the credential is already registered, which is not a failure of anything.

userID is the customer id rather than the email. A userID is meant to be stable and opaque, and an email is neither — a customer changing theirs would otherwise look like a different person to their own authenticator.

Discoverable credentials are requested as preferred rather than required, because #39 wants sign-in without the customer first saying who they are, and an authenticator that cannot store one should still be usable here.

Mounted before /api/customers, like the addresses router: Express matches mounts in order and the broader prefix would otherwise swallow these.

Verified: tsc clean for src and tests, lint 0 errors with no new warnings, 514 unit tests across 35 suites — twenty new, covering the naming rules. The schema mirror gains the name column, placed where kysely-codegen would put it.

Not verified: neither migration has been run against a database, and the ceremony itself cannot be exercised without a browser and an authenticator. What CI can prove is that the routes exist, are wrapped, and refuse an unauthenticated caller; what it cannot prove is a real attestation, which needs a device.

Closes #38

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 14:15:56 -05:00
bermudalamb c6f6ddc6dd Merge pull request 'feat(passkeys): schema, dependency and per-environment Relying Party (#37)' (#330) from feature/37-passkeys-groundwork into main
Linting / lint (push) Canceled after 0s
SonarQube Analysis / sonarqube (push) Canceled after 0s
Reviewed-on: #330
2026-09-09 14:08:29 -05:00
synAdminandClaude Opus 5 6d320fd867 feat(passkeys): schema, dependency and per-environment Relying Party (#37)
Groundwork only. Nothing reads any of this yet, and no behaviour changes.

The Relying Party ID is derived from PUBLIC_URL rather than written down, because it is the one value in this feature that cannot be corrected afterwards: a credential is bound to it permanently, and a wrong one surfaces only as a customer unable to sign in with a passkey that no longer matches anything. PUBLIC_URL is what every customer-facing link is already built from, so the ID is correct wherever those links are, and wrong only where they were already wrong. hostname rather than host, so a port cannot reach an ID that must not contain one.

Local development is the exception the issue's table did not cover. envValidation requires PUBLIC_URL only when SMTP is configured, so a local setup that cannot send mail legitimately has none and falls back to localhost, which browsers treat as a secure context. Two origins there rather than one: the app is served by Vite on 5173 during development and by Express on 3000 once built, and those differ only by port, which is not part of the RP ID.

The challenge table is separate from customer_tokens, and the reason is structural rather than preference. 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 exists. Storing it there would mean making that column nullable for every other kind of token.

Two of the issue's open decisions are deliberately not made here, because they belong to the ceremony that enforces them rather than to the schema. What to do when the signature counter fails to increase is #39's: many synced passkeys report zero forever, so treating a non-increase as cloning is wrong for them and right for a hardware key, and this only has to hold the value. Whether a disabled account can authenticate is also #39's, and the schema takes the position that it should not cost the customer their devices: credentials survive disabling and are refused at the ceremony, so re-enabling does not mean re-registering everything. Deletion is different and is settled here — credentials cascade with the customer, since one outliving its owner could authenticate as an account that no longer exists.

signature_counter is BIGINT because the spec allows a 32-bit unsigned value, which overflows a signed INTEGER at half its range. That is the first bigint column in this schema, so the generated mirror gains the Int8 alias with it.

Both tables are added to resetDb's TRUNCATE list and to REQUIRED_TABLES, and the schema mirror is updated by hand to match what kysely-codegen emits — placement and all, so a real regenerate produces no diff. Skipping either is how #56 turned a green local run into a red main; the mirror drift guard exists precisely to catch it, and schemaLoss's count moves from 18 to 20 with them.

Verified: tsc clean for src and tests, lint 0 errors with no new warnings, 494 unit tests across 34 suites including nine new ones for the RP derivation, frontend build green, and the migration parses. Not verified: the migration has not been run against a database, and the integration suite needs one this machine cannot provide.

Worth knowing before this goes further: #313 changes the domain, and every passkey registered before that cutover stops working at it. This code needs no change — it follows PUBLIC_URL — but the credentials do not survive. That is free while production is not live and nobody holds one, and it stops being free the day the shop opens.

Closes #37

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 14:08:29 -05:00
bermudalamb 6a2143696a Merge pull request 'docs(ops): feature flags are not worth a manager here (#318)' (#329) from spike/318-feature-flags into main
Linting / lint (push) Successful in 2m49s
SonarQube Analysis / sonarqube (push) Failing after 35m25s
Reviewed-on: #329
2026-09-09 13:39:21 -05:00
synAdminandClaude Opus 5 0c297f8465 docs(ops): feature flags are not worth a manager here (#318)
Linting / lint (pull_request) Successful in 3m3s
SonarQube Analysis / sonarqube (pull_request) Failing after 31m7s
The spike asked whether this project should adopt feature flag management and whether a free option fits. The answer is that it already has two flag mechanisms, uses both idiomatically, and is not short of a third.

Environment variables do per-environment gating, with a consistent and documented convention: unset means the feature does not exist and the application is working rather than broken. QA runs with several deliberately empty, which is how it cannot reach PayPal, cannot email anyone and cannot report browsing into the live Brevo account. That is a feature flag system; it is simply not called one.

admin_settings does runtime tuning by whoever runs the shop, and its own header already states the principle a flag manager would sell: a setting is changed by the person running the shop rather than the person deploying it. drafting_model and intake_notify_email live there for exactly that reason.

What is missing is one value type. admin_settings supports hours, count, text and choice, and has no boolean — so the single thing a manager would add that this project lacks is an admin-flippable on/off, in a store that already does everything else. That is a definition entry, a parse branch and a control. It is not a reason to run another service on a NAS that already hosts Gitea, the runner, QA and production.

No tool was compared until that was established, because a spike that starts by comparing platforms will always find one. The comparison is included for whenever the answer changes, with the constraint that matters most stated plainly: fail-safe behaviour beats features, and a flag service that takes the shop down when unreachable is worse than no flags. admin_settings reads from the database the application already cannot run without, so it has no failure mode of its own.

The issue said a spike that cannot name a concrete change needing a flag should conclude not yet, and it cannot. Seven of the nine open issues are the Passkeys epic, which is naturally incremental and gates itself by ordering, and #313, whose trust proxy change cannot be flagged at all — it is only correct once Cloudflare is in front, so a runtime toggle would let it be wrong on purpose. The strongest hypothetical is a kill switch for passkey login, because #41 records it as the one feature that cannot be fully proven in QA, and that is still not enough while passkeys are not being built and production is not live.

The conditions that would change the answer are written down rather than left implied, so this does not get reopened from scratch.

Unleash carries a caveat worth keeping: its OSS Edge sunsets on 2026-12-31, which makes a free tool acquire a paid dependency inside a year.

Closes #318

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 13:37:28 -05:00
bermudalamb f1600774db Merge pull request 'fix(admin): refetch Inventory when its tab becomes visible (#327)' (#328) from bugfix/327-inventory-stale-after-publish into main
Linting / lint (push) Successful in 2m58s
SonarQube Analysis / sonarqube (push) Failing after 33m57s
Reviewed-on: #328
2026-09-09 13:32:32 -05:00
synAdminandClaude Opus 5 4b1ffffc25 fix(admin): refetch Inventory when its tab becomes visible (#327)
Two items published from the Review queue did not appear in Inventory. Publishing was never at fault: the items were published, the rows were right, and the API returned them. The Inventory tab was showing a list it had fetched earlier and never refreshed.

antd keeps a tab pane mounted once it has been rendered, and Inventory is the default tab, so its pane mounts at page load whether or not anyone looks at it. Its only fetch runs from an effect depending on the filters, so it fires on mount and when a filter changes and at no other time. Open the admin, switch to the Review queue, publish, switch back, and nothing has re-run — the table still holds the list built before the submissions existed. A browser reload shows them, which is what makes this read as publishing being broken rather than as a stale table.

The tab is controlled now and tells Inventory whether it is the one on screen, and Inventory refetches when that becomes true. Guarded on visibility rather than fetching unconditionally, because the pane lives for the life of the page and would otherwise keep refetching while hidden.

destroyInactiveTabPane on the Tabs would also have fixed it, by remounting, and was rejected: it discards every tab's state on every switch — filters, scroll position, a half-filled form — and refetches all of them repeatedly, which is a much larger behavioural change than this bug is worth.

The existing publish test passes against the broken behaviour, and that is the reason this went unnoticed. It asserts against GET /api/admin/items, and the API was always correct. The new test asserts through the UI and never reloads the page: Inventory is opened before publishing, so its list is fetched while the item still carries its submission-timestamp name, and the assertion afterwards looks for the name given at publish — which a stale list cannot contain. A reload anywhere in it would make it pass against the bug it exists to catch.

AdminPage's comment claimed only the active panel is mounted. It is only the active panel that is visible; the rest stay in the DOM. That mistaken belief is the shape of this bug, so the comment now says which it is and why it matters.

Not fixed here: Categories, Tags, Upload links and Customers are mounted-once children of the same Tabs and are all changeable from elsewhere, so they very likely share this. Recorded on the issue rather than assumed to be fine.

Verified: tsc clean for src and tests, lint 0 errors, production build green. The new test needs CI to run — the e2e suite wants a browser and a database this machine cannot provide.

Closes #327

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 13:32:32 -05:00
bermudalamb 83b5b5e287 Merge pull request 'test(ci): record which Postgres actually answered (#154)' (#326) from fix/154-identify-the-answering-postgres into main
Linting / lint (push) Successful in 3m2s
SonarQube Analysis / sonarqube (push) Failing after 27m12s
Reviewed-on: #326
2026-09-09 10:49:03 -05:00
synAdminandClaude Opus 5 2bc9440b38 test(ci): record which Postgres actually answered (#154)
This adds the measurement #154 has needed twice and never had. It does not fix the failure and does not guess at it: the issue has been wrong twice from reasoning ahead of evidence, and the point here is to make the next occurrence answer the question rather than reopen it.

The observation that rules out every explanation so far is that the schema comes back. A suite fails because orders does not exist, and a later suite truncating that same table passes. A dropped database does not un-drop itself, so this was never one database losing its schema. More than one server answering to one name produces exactly this, and Docker embedded DNS round-robins every container sharing an alias, so a leftover service container from an earlier run fits every observation including the empty dmesg that killed the OOM theory.

globalSetup now logs every address the database host resolves to. More than one is the answer outright. One address means this reading is wrong too, and the next suspect is a single container restarted with a fresh data directory.

Alongside it, both globalSetup and a failing assertSchemaPresent record which server actually answered. pg_postmaster_start_time is what settles that and needs no special rights: two Postgres instances cannot share one, so differing values within a single run are proof, where a differing inet_server_addr alone could be argued to be one container that moved. The failure message now says to compare the two rather than leaving the reader to know that is the interesting comparison.

Logged on a passing run as well as a failing one, deliberately. A failing run's addresses mean nothing without a passing run's to compare them against, and this issue has twice suffered from having only the failure to look at.

Neither can throw. A diagnostic that fails the run it was added to explain is worse than no diagnostic, so both are wrapped and both degrade to a printed reason.

The failure path costs one extra round trip, taken only when the schema is already known to be missing. assertSchemaPresent is not on the hot path — resetDb calls it only when its TRUNCATE has already failed.

Verified: tsc clean, typecheck:tests clean, lint 0 errors with no new warnings, and the four message patterns schemaLoss.integration.test.ts asserts on are all still present. The probe reads only pg_catalog functions, so it still answers against the dropped schema that suite creates.

Refs #154

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 10:49:03 -05:00
bermudalamb de979f4bb6 Merge pull request 'chore(ci): a manually-run workflow to delete old Actions runs (#324)' (#325) from chore/324-cleanup-actions-workflow into main
Linting / lint (push) Successful in 2m53s
SonarQube Analysis / sonarqube (push) Failing after 29m56s
Reviewed-on: #325
2026-09-09 10:48:26 -05:00
synAdminandClaude Opus 5 d7bfd47797 chore(ci): a manually-run workflow to delete old Actions runs (#324)
Linting / lint (pull_request) Successful in 4m46s
SonarQube Analysis / sonarqube (pull_request) Failing after 22m15s
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. Clearing it meant 455 API calls from a scratch file on one machine, which is the wrong home for something that has to happen again every few weeks.

Dry run unless apply is typed as true. The operation cannot be undone and there is no confirmation once it starts, so the harmless answer has to be the default rather than the one you get by leaving a box alone.

Manual only, deliberately. The list is an annoyance rather than a problem, and a cron that quietly deletes history deserves to be a decision taken on its own rather than one that arrives bundled with the tool.

A run that is not completed is never a candidate, which is also what stops the cleanup deleting the run it is executing in.

Ages a run by started_at, falling back to completed_at. That fallback is the whole reason this is worth committing rather than repeating from memory: a run cancelled before it ever started reports an epoch started_at while carrying a real completed_at, so reading only the first makes every cancelled run look undateable. The manual pass did exactly that and left nineteen runs from three weeks earlier in a list that was supposed to hold seven days. Neither timestamp usable still means keep — an epoch read as 1969 would delete the runs that have not happened yet.

Uses a dedicated ACTIONS_CLEANUP_TOKEN secret rather than the automatic per-job token, since deleting a run may be beyond what that token permits. If it turns out to be enough, the secret and the env line both go. Host and repository come from the run's own context, so the file carries no hostname and survives the move #313 may yet make.

No npm install: the script uses node's own https module, so there is nothing to fetch and nothing to break when a dependency moves.

Verified by running the script against the live instance: a dry run reported 19 stale cancelled runs the earlier pass had missed, and applying it removed them with no failures.

Closes #324

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 09:30:36 -05:00
bermudalamb 40c5623cdc Merge pull request 'fix(tests): the error-handling suite would not compile, and nothing local said so (#307)' (#323) from fix/307-integration-typecheck into main
Linting / lint (push) Successful in 4m31s
SonarQube Analysis / sonarqube (push) Failing after 34m21s
Reviewed-on: #323
2026-09-09 08:51:33 -05:00
synAdminandClaude Opus 5 5bba28bfda fix(tests): the error-handling suite would not compile, and nothing local said so (#307)
Linting / lint (pull_request) Successful in 3m4s
SonarQube Analysis / sonarqube (pull_request) Failing after 21m3s
The trigger added in #322 does not typecheck. pg declares query with several overloads and jest.spyOn resolves the mock argument against the last of them, whose parameter list is empty, so the inferred type of a rejection value is never and an Error cannot be assigned to it. ts-jest compiles each suite as it runs, so this surfaced as a suite that failed to run rather than as a test that failed — which is why CI reported 446 tests passing, zero failing, and the step red anyway.

The cast is the type system rather than a shortcut, and says so in the file: every overload rejects on failure, and the cast only chooses which one to check against.

The reason this reached main is the part worth keeping. The backend has a tsconfig.test.json covering scripts and tests, and nothing was running it — build compiles src alone, and I had been reporting tsc clean on that basis while touching test files it never looked at. The frontend build has run its equivalent all along, so the gap was one workspace wide and invisible from the other.

It is now a script, typecheck:tests, and it reproduces this failure in seconds against no database. That matters beyond this fix: the standing excuse for integration regressions here has been that the suite needs Postgres and a Node this machine cannot run, and a type error in a test was never actually in that category — it only looked like it because the check that finds it was never invoked.

Not added to build. The Dockerfile runs that, and a production image should not fail to build because a test file has a type error.

Verified: typecheck:tests clean, build tsc clean, lint 0 errors with no new warnings, 485 unit tests passing. The suite itself still needs CI to run.

Refs #307

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:17:34 -05:00
bermudalamb 8c9f28f731 Merge pull request 'fix(items): read the id strictly, and stop the error test needing a route that does not (#307)' (#322) from fix/307-readid-and-error-trigger into main
Linting / lint (push) Successful in 2m53s
SonarQube Analysis / sonarqube (push) Failing after 26m39s
Reviewed-on: #322
2026-09-08 16:00:44 -05:00
synAdminandClaude Opus 5 8956b9f122 fix(items): read the id strictly, and stop the error test needing a route that does not (#307)
Linting / lint (pull_request) Successful in 3m18s
SonarQube Analysis / sonarqube (pull_request) Failing after 24m32s
Item 5. GET /api/items/:id was the last route reading its id with a bare Number(), so an unreadable id reached Postgres and came back to the caller as a 500 for an item that cannot exist. It answers 404 now, like every other id-taking route since #207.

It could not be fixed on its own, which is why it stayed. errorHandling.integration.test.ts used this route's looseness as its way of making a handler reject: tightening the parse would have left that test green while removing the thing it tests. So the test now fails a database call directly, with a spy on pool.query against a route that makes one. That is the failure the error middleware actually exists for, and it does not depend on any route declining to validate — the previous comment's own conclusion, that moving the trigger to cart.ts would only move the wart.

Two things fixed along the way that the issue asked about but that switching to readId would not have delivered on its own.

readId was not as strict as its name suggests. Number reads 5.0, 1e2, 0x10 and +5 as 5, 100, 16 and 5 — every one a positive integer, so every check readId made passed and the route fetched a real row for a URL nobody wrote. /items/5.0 answered with item 5. This never raised an error and so never announced itself; the issue noticed it only because #308 converted the comparison to a real integer. An id is a string of digits, so it is matched against digits before being parsed.

readId is also now bounded at the top of a 32-bit serial. Above that Postgres raises 22003 rather than returning nothing, which is the same wrong answer to the caller as the 22P02 the function was written to prevent — a 500 for an id that identifies nothing.

The leak assertion was passing for the wrong reason. It checks the response does not contain "syntax" or "items", and the error it was checking against happened to contain both only by accident of which route was used. The injected failure now contains both words deliberately, and the whole message is asserted against as well, so a future error format cannot slip through by wording itself differently.

Verified: tsc clean, lint 0 errors with no new warnings, 485 unit tests passing across 33 suites — seven of them new, covering the inputs above. The integration suite cannot run on this machine, so whether the rewritten error test passes is for CI to say.

Item 4, coverage, is not closed by this and cannot be closed yet: the SonarQube scan step has been skipped on every recent run because it is gated on the earlier steps succeeding, and those steps were failing. The dashboard is therefore stale. Reported on the issue rather than guessed at.

Refs #307

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:59:12 -05:00
bermudalamb 530a2e14db Merge pull request 'fix(tests): regenerate the schema mirror and name the controls #56 moved (#320)' (#321) from fix/320-post-brevo-test-fallout into main
Linting / lint (push) Successful in 2m44s
SonarQube Analysis / sonarqube (push) Successful in 27m56s
Reviewed-on: #321
2026-09-08 15:33:03 -05:00
synAdminandClaude Opus 5 5b40ac25db fix(tests): regenerate the schema mirror and name the controls #56 moved (#320)
Linting / lint (pull_request) Successful in 3m24s
SonarQube Analysis / sonarqube (pull_request) Successful in 24m51s
PR #317 was merged while its run was still failing, so four failures landed on main: one integration and three end-to-end. All of them are consequences of #56, and none could have been caught on the dev machine, which has no database and no browser to run those suites with.

The schema mirror was never regenerated. The migration added three columns to customers and src/db-kysely/schema.ts still described the table without them, which is the drift guard from #305 doing exactly what it exists for. Hand-edited to match what kysely-codegen emits — alphabetical, and Generated on the column that has a default — because regenerating properly needs a live database.

The other three are the same mistake three times: a control addressed by position, and the position moved. An unscoped getByRole('checkbox') became ambiguous once the register form had two consents. A toHaveCount(2) on the account modal's switches became three. And favoriteAlertsSwitch was getByRole('switch').last(), which did not error when a switch was appended below it — it silently retargeted, toggled analytics consent instead of favourite alerts, and then failed on a text assertion in favorites.spec.ts, naming neither the file nor the control actually at fault.

The reason position was ever used is that antd's Switch renders a bare role="switch" with no accessible name; the adjacent Text is a sibling, not a label. So each one now carries an explicit aria-label and is addressed by it. That is what makes them addressable from a test, and it is what a screen reader needed regardless — the fix and the accessibility improvement are the same change.

The count assertion stays, but alongside naming each switch, because a count on its own would pass if two of them were swapped for each other.

Two coverage gaps closed while here, both properties the compliance work in #56 depends on and neither previously asserted anywhere a customer could see: the analytics checkbox is unchecked on the register form, and the account toggle is off for a new customer. Quebec's Law 25 s.8.1 requires profiling to start off, the integration suite asserts the server half of that, and nothing asserted the half rendered on screen.

The fourth Playwright entry, the logged-out header surviving a reload, is reported flaky rather than failed and passed on retry. Left alone; it is unrelated to #56 and #257 covers flakes in this suite.

Verified: backend tsc clean, frontend tsc against the test config clean, production build green, both lint suites 0 errors, 478 unit tests passing. The integration and e2e suites still cannot run here, so whether this actually clears run 875's failures is for CI to say — which is the same gap that produced them.

Closes #320

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:09:37 -05:00
bermudalamb 0ec6064391 Merge pull request 'test(e2e): make the resend allowance failure say why (#257)' (#319) from fix/257-resend-verification-flake into main
Linting / lint (push) Successful in 2m53s
SonarQube Analysis / sonarqube (push) Failing after 26m59s
Reviewed-on: #319
2026-09-08 14:01:09 -05:00
synAdminandClaude Opus 5 218be6d298 test(e2e): make the resend allowance failure say why (#257)
Linting / lint (pull_request) Successful in 3m2s
SonarQube Analysis / sonarqube (pull_request) Failing after 24m53s
The test asserted on toasts, and toasts were the wrong instrument twice over. It failed roughly one full-suite run in three with a strict mode violation — getByText(/already sent several/) resolving to three refusal toasts where one was expected — and two investigations could not establish the mechanism.

The second investigation corrected the arithmetic the first depended on: antd toasts auto-dismiss, so the number visible at the moment of an assertion is a lower bound on how many refusals happened rather than a count. Three visible refusals is equally consistent with four where the first had already faded. That removed the only evidence anyone had for the original theory that the customer's bucket held two hits before the test clicked anything, which left the issue with a symptom and no way to read it.

So this asserts the sequence of response statuses instead. Toasts are a lossy, timing-dependent rendering of the thing the test is actually about, and the responses are the behaviour itself. A failure now reports what happened: four 429s means the bucket really did carry hits from somewhere else, while more than four entries means the UI sent more requests than there were clicks. Either reading identifies the mechanism from one failing run, where before it needed a temporary probe re-added and the suite run until it failed again.

Each click now waits for its own response. The previous "await expect(resend).toBeEnabled()" looked like pacing but was a no-op, since the button is never disabled, so four requests raced. Removing that ordering means a failure cannot be blamed on it. The copy assertion stays, because it is what the test is named for, but scoped with .first() so strict mode does not treat several identical toasts as ambiguous.

This does not fix the underlying flake, and is not meant to. The issue asks for the mechanism to be found before a fix is attempted rather than guessed at, and nothing here changes the limiter or the store.

Verified by typecheck and lint only. The e2e suite needs a database and a browser this machine cannot run, so whether this passes is for CI to say — the same gap that let the integration regression through earlier today.

Refs #257

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:48:33 -05:00
bermudalamb 946ea4de1a Merge pull request 'feat(analytics): report consenting customers' activity to Brevo (#56)' (#317) from feature/56-brevo-tracker into main
Linting / lint (push) Successful in 2m54s
SonarQube Analysis / sonarqube (push) Failing after 27m1s
Reviewed-on: #317
2026-09-08 12:41:50 -05:00
synAdminandClaude Opus 5 e7196f440b test(customers): let the public shape guard see analytics_consent (#56)
Linting / lint (pull_request) Successful in 2m43s
SonarQube Analysis / sonarqube (pull_request) Failing after 25m24s
The register route now returns analytics_consent, and customers.integration.test.ts asserts the exact key set the public customer shape may contain. That test failed in CI, which is the guard doing its job rather than a problem with it: its whole point is that the shape cannot quietly grow, and a field appearing without someone deciding it belongs there is what it exists to catch. This field does belong there, so the expected set gains it.

Three cases added while here, all of them properties the compliance work depends on and none of them observable from a unit test. Analytics consent is off for a registration that does not mention it, which is what Quebec's Law 25 s.8.1 requires and needs the column default, the register route and the stored wording to agree. Opting in to marketing alone leaves analytics off, which is the bundling GDPR treats as invalid and the mistake this branch already made once. And an analytics-only opt-in works with marketing left off, so the granularity holds in both directions rather than only the convenient one.

Found by CI rather than locally: the integration suite needs a database this machine has no Docker to run, which was called out as unverified when the change went up. Typechecked, linted and the 478 unit tests still pass, but the assertion itself is only proven by the next CI run.

Refs #56

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:28:17 -05:00
synAdminandClaude Opus 5 25bec50902 docs(privacy): disclose every cookie and browser-storage item (#56)
Linting / lint (pull_request) Successful in 2m57s
SonarQube Analysis / sonarqube (pull_request) Failing after 25m14s
Completes the consent picture the rest of this branch builds. The claim worth being able to check is that no cookie needing permission is set before it is asked for, so the policy now lists everything rather than asserting it: the rd_session sign-in cookie, which is strictly necessary and therefore exempt, the two preferences kept in localStorage and never sent anywhere, and Brevo's cookie, which cannot exist unless analytics consent was given because the script that would set it is never loaded otherwise.

No cookie banner, and that is a finding rather than an omission. ePrivacy requires consent before storing anything non-essential, and this application does not store anything non-essential until the customer has asked for the feature that needs it. A banner would be asking permission for things that are either exempt or already separately consented to, which teaches people to dismiss the one consent that does matter.

Described in terms of what each thing does rather than by category, since a list of cookie names tells a customer nothing they can act on.

Refs #56

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:08:50 -05:00
synAdminandClaude Opus 5 955049eac9 fix(privacy): separate analytics consent from email consent (#56)
Linting / lint (pull_request) Successful in 3m0s
SonarQube Analysis / sonarqube (pull_request) Failing after 26m24s
The previous commit widened the marketing consent sentence to cover the Brevo tracker, so one checkbox carried both purposes. That is the specific pattern GDPR rejects: consent has 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 s.8.1 is stricter again — profiling technology has to be off until the person switches it on, with no pre-ticked box and no consent inherited from agreeing to something else. Building to both standards was the decision, since the storefront is publicly reachable and anyone can register.

So the marketing sentence is restored to exactly what it was, which leaves every existing email consent valid and untouched, and analytics gets its own column, its own sentence, its own checkbox at registration, its own toggle in the account page and its own endpoint. A customer can now hold either, both, or neither, and withdrawing one does not disturb the other.

The migration defaults analytics_consent to false, which is both the honest answer — none of the existing customers was ever asked — and what Law 25 requires. Nothing about this change opts anybody in.

Two details that are compliance requirements rather than wording preferences. The sentence names Brevo instead of saying "our email provider", because informed consent means the customer can tell who receives their data and a description they cannot act on is not disclosure. And the account toggle is as prominent and as easy to switch off as it is to switch on, because withdrawal has to be as easy as consenting.

The analytics endpoint is separate from the marketing one rather than a second field on it, so that a single call cannot change an answer the customer did not touch — the bundling problem moved from the form into the API. The unit tests now assert the two consents stay apart in both directions, including that the marketing sentence still says nothing about tracking, because re-bundling them would otherwise pass silently and is the mistake this project already made once.

Verified: backend tsc clean, both lint suites 0 errors with no new warnings, 478 unit tests passing across 33 suites, frontend production build green. Not verified: the migration has not been run against a database, and integration and e2e need a Node this machine does not have active. None of this is legal advice and the wording is worth a lawyer's eye before it ships.

Refs #56

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:58:20 -05:00
synAdminandClaude Opus 5 ac3f6e91f5 feat(analytics): report consenting customers' activity to Brevo (#56)
Linting / lint (pull_request) Successful in 3m35s
SonarQube Analysis / sonarqube (pull_request) Failing after 22m26s
Loads Brevo's web tracker for a signed-in customer who has consented, reports route changes as page views, and tracks the three events the issue asked for: added_to_cart, favorited, and checkout_completed. The four design questions were settled on the issue in August and this implements those answers.

The consent gate is the part worth reading. The decision recorded on the issue was "gate it behind consent", but the sentence customers actually agreed to named only email: "I want to receive occasional emails about new one-of-a-kind items". Gating a tracker on `marketing_consent` while that was the stored wording would have treated "email me about new items" as authorisation to send someone's browsing to a third party, which it does not say — and this project stores the wording verbatim against each customer precisely so that a record says what the customer saw.

So the sentence is widened here, and the tracker is gated on `analytics_consent`, a field the server computes by comparing the wording stored against a customer with the current constant. Changing the sentence therefore does not retroactively widen anybody's consent: everyone who agreed to the old text keeps their email consent and is not tracked until they re-consent through the account page. A boolean alone could not tell those two populations apart, which is the whole reason the text is stored per customer. `analyticsConsent` is exported and has its own unit test, because "agreeing to the old wording does not authorise tracking" is the rule that silently tracks people if it regresses — their flag really is true.

QA stays out of the live Brevo account by construction rather than by remembering. The key is per-environment, the tracker never loads without one, and `docker-compose.qa.yml` sets an empty literal with no stack variable behind it, so nothing can inherit a value from the host or be pasted in from production's stack. Same reasoning as QA_DB_PASSWORD and the QA_SMTP_ names beside it.

Events are reported from the API layer rather than the UI call sites, so no caller can add to the cart or favorite an item without it being counted, and each fires only after the response was accepted — a refused add is not reported as one. The two checkout completions each name their processor, because a demo purchase charges nothing and counting it as a sale would overstate revenue.

The privacy policy gains an analytics section in this change rather than a follow-up, since the published policy previously described none of this and would otherwise have lagged the code. It is deliberate about the limits: withdrawing consent stops further reporting, but anything already sent stays with Brevo, and a script already injected cannot be un-injected — `stopBrevoTracking` stops calls, it does not unload sa.js. That is said in the code too, because "tracking stops" reads as a stronger promise than any web tracker can make.

Verified: backend tsc clean, both lint suites 0 errors with no new warnings, 474 unit tests passing across 33 suites, and the frontend production build green including the compose-environment guard. Not verified: integration and e2e, which need a database and a Node this machine does not have active, and no real Brevo key was exercised — the tracker has never been observed reporting to an actual account.

Closes #56

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 10:49:30 -05:00
bermudalamb e0afa1b43e Merge pull request 'fix(admin): keep a modal's controls on screen on a phone (#314)' (#316) from bugfix/314-admin-modal-unclosable-on-phone into main
Linting / lint (push) Successful in 4m34s
SonarQube Analysis / sonarqube (push) Successful in 25m31s
Reviewed-on: #316
2026-09-06 10:04:30 -05:00
synAdminandClaude Opus 5 2866901187 fix(admin): keep a modal's controls on screen on a phone (#314)
Linting / lint (pull_request) Failing after 3h13m0s
SonarQube Analysis / sonarqube (pull_request) Successful in 23m56s
antd treats a Modal's width as a fixed pixel value and does not adapt it to the viewport, so the item editor's `width={720}` in Admin.tsx still laid out at 720px on a roughly 390px screen. The footer's OK and Cancel are right-aligned inside that width and the close X sits in the modal's top-right corner, so all three ended up off-screen, and antd sets `overflow: hidden` on the body while a modal is open, so the page behind could not be scrolled to reach them either. The dialog had no way out short of the browser's back button.

Capped in styles.css rather than at each call site, because `Modal.confirm` — used by the review queue when publishing at a price nobody chose — has no call site to edit and would have been left broken by a per-modal fix. The same rule covers the 640 and 480 widths in Customers.tsx and the preview drawer, all of which overflow a phone for the same reason. `max-width` beats the inline `width` antd writes on the element, so none of it needs `!important`.

Bounding `.ant-modal-body` rather than the modal is what actually keeps the buttons reachable: the footer is a sibling of the body, not a child, so a tall form scrolls inside the modal while the footer stays where it is. Sizing the modal alone would have moved the overflow rather than removed it.

Verified in the built bundle rather than only in source — `@media (max-width: 767px)` and all three rules are present in dist after a production build, which is the step that distinguishes a fix that shipped from one that merely compiled. Not verified visually on a device: this is a layout change with no unit coverage, and the five overlays worth checking at 390x844 are listed on the issue.

MDEditor's `preview="live"` still splits the pane in two and is cramped at this width. That is a behaviour change rather than a layout fix and is deliberately left out; it is recorded on the issue instead.

Closes #314

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 10:03:34 -05:00
bermudalamb 39d59d55ce Merge pull request 'docs(ops): the free tier cannot be applied to this hostname (#285)' (#315) from feature/285-cloudflare-free-tier into main
Linting / lint (push) Successful in 3m5s
SonarQube Analysis / sonarqube (push) Failing after 13m27s
Reviewed-on: #315
2026-09-06 10:02:26 -05:00
synAdminandClaude Opus 5 3e3932b231 docs(ops): the free tier cannot be applied to this hostname (#285)
The spike asked what Cloudflare's free plan would and would not do for a self-hosted site on a dynamic-DNS hostname. The answer is narrower than either option the issue anticipated: the free plan supports only full setup, which requires delegating the zone's nameservers at its registrar. The zone here is synology.me and belongs to Synology, so we cannot delegate it. Partial (CNAME) setup is Business or Enterprise, and subdomain NS delegation is Enterprise. The blocker is therefore not that the useful features are paid — it is that the hostname cannot go on the plan at all. The issue asked for exactly this kind of finding to be recorded rather than treated as a reason to widen the spike.

The remaining questions are answered anyway, on the assumption that a domain we control is bought later, so that decision is made with the consequences already known rather than discovered afterwards.

Two of those consequences are worth pulling out. Cloudflare appends to X-Forwarded-For rather than replacing it, so a second proxy in front of Nginx Proxy Manager makes `trust proxy: 1` in app.ts resolve req.ip to a Cloudflare edge address instead of the client. Four limiters key on req.ip and would silently stop distinguishing callers while continuing to look healthy, which is the dangerous direction for that failure to go. Separately, Bot Fight Mode cannot be skipped with WAF or Page Rules on any plan because it does not run on the Ruleset Engine, and Cloudflare documents that it may challenge API traffic — a challenged POST /webhooks/paypal is a capture notification we never receive.

Everything asserted about this repository was checked against the code; the Cloudflare behaviour was checked against their documentation on 2026-09-05 and not against a live account, because one cannot be set up here. What was not established is listed at the end of the document rather than left implied.

Closes #285

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 10:02:26 -05:00
bermudalamb f99ac108bb Merge pull request 'fix(tests): close the route guard's factory hole and two regex warnings (#307)' (#312) from fix/307-sonar-cleanup into main
Linting / lint (push) Successful in 3m3s
SonarQube Analysis / sonarqube (push) Successful in 25m26s
Reviewed-on: #312
2026-09-05 08:26:22 -05:00
bermudalambandClaude Opus 5 36e6057c6e fix(tests): close the route guard's factory hole and two regex warnings (#307)
Three of the items on the cleanup issue, and the first is the one that mattered.

routesAreWrapped.test.ts could not see a handler built by a factory. `router.post('/x', rotationRoute('left'))` carries no async token of its own, so the guard read those two lines, found nothing to object to, and passed — which is not the same as finding them wrapped. That is how the rotation routes added in #301 went through a test that exists precisely because this convention had already been half-forgotten once, when thirty handlers were added unwrapped after the wrapper existed. It now follows a call to a function declared in the same file and reads its body the same way it reads a registration, so an unwrapped handler inside a factory is an offender. Proved rather than assumed: unwrapping rotationRoute's handler makes the suite fail naming admin.ts, where before it passed.

Only same-file functions are followed, deliberately. app.ts registers express.json(), cookieParser() and uploadsRouter(), none of which is a handler factory and none of which can be resolved from the file being read — treating an unresolvable name as an offender would trade one hole for a permanently red test, so there is a case asserting those are left alone.

The brace and paren walking is now one function rather than two. Adding the factory reader as a near-copy of registrationAt is what a cleanup commit should not do, and the duplicate carried its own cognitive-complexity and loop-counter warnings with it; parameterising the delimiter pair removes both the copy and the warnings it added.

The schema mirror's table regex used `\s*` where kysely-codegen emits exactly two spaces and one after the colon, and `[A-Za-z0-9_]` where `\w` says the same thing. SonarQube flagged both, and the looser form bought nothing and backtracked for it.

An empty status list would have compiled to `in ()`, which is a Postgres syntax error, where the `= ANY($n::text[])` it replaced in #308 was valid and matched nothing. It is unreachable through parseItemFilters, which refuses a list that names nothing — but the obvious guard is wrong in the opposite direction, because dropping the clause entirely would make an empty status filter match every status rather than none, so the empty case is spelled out as false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 08:26:22 -05:00
bermudalamb 020f437326 Merge pull request 'docs(scripts): record the Node pin decision at the constant (#208)' (#310) from fix/208-node-pin-decision into main
Linting / lint (push) Successful in 2m47s
SonarQube Analysis / sonarqube (push) Successful in 25m53s
Reviewed-on: #310
2026-09-05 08:25:11 -05:00
bermudalambandClaude Opus 5 936dcb60a8 docs(scripts): record the Node pin decision at the constant (#208)
Linting / lint (pull_request) Successful in 2m57s
SonarQube Analysis / sonarqube (pull_request) Successful in 24m21s
The last open item on #208, and the only one that needed a person rather than a patch. Local runs stay pinned to 26.7.0 while CI and the production image run 20, and CI on 20 is the backstop.

The comment said this was "an open decision rather than an oversight", which was true when it was written and is not any more. Left as-is it would read to the next person as something still to settle, and they would either re-litigate it or quietly change the pin.

What the decision costs is written down rather than glossed: passing locally does not mean it ships, because a post-20 syntax or node: API is caught after a push rather than before one. That is the whole of the trade, and it is acceptable precisely because it is known — the failure mode this file's own docstring warns about is the one nobody knew they were exposed to.

Items 1, 2, 3 and 5 were already done in 06933ae and the commits around it: the alias check fails closed on a positive match, three stale documents were corrected, the throw quotes `nvm install $Version`, Use-NodeLatest is gone, DEFAULT_NODE_VERSION sits beside NODE_VERSION rather than being duplicated in two scripts, and the floor check runs before the switch where it can actually fire.

Closes #208

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:48:23 -05:00
bermudalamb 3cbf73ee87 Merge pull request 'Feature/308 kysely dynamic queries' (#309) from feature/308-kysely-dynamic-queries into main
Linting / lint (push) Successful in 2m43s
SonarQube Analysis / sonarqube (push) Successful in 25m40s
Reviewed-on: #309
2026-09-04 17:47:41 -05:00
bermudalambandClaude Opus 5 abe8ac8184 test(filters): merge the duplicate itemFilters import (#308)
Linting / lint (pull_request) Successful in 3m8s
SonarQube Analysis / sonarqube (pull_request) Successful in 25m39s
backend/tests/unit/itemFilters.test.ts had two separate import statements from ../../src/itemFilters; merged into one, with nothing else in the file changed. A companion fix to backend/src/routes/items.ts — reading the by-id route's id with the existing readId helper instead of Number(req.params.id), to close the leniency Number() introduced toward inputs like '5.0', '1e2' and '0x10' — was tried and then reverted, because backend/tests/integration/errorHandling.integration.test.ts deliberately drives that exact route with a non-numeric id to prove that asyncRoute plus the error middleware turn a rejected handler into a 500 rather than hanging the request, and readId's stricter parse would answer 404 before that mechanism ever runs, leaving the test green while silently deleting the coverage it exists for; the route now carries a comment explaining why Number() stays and pointing at #307 for giving that test another trigger before making the switch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:20:49 -05:00
bermudalambandClaude Opus 5 306db81966 fix(db): narrow the status column instead of asserting the row (#308)
`.$castTo<AdminItemRow>()` / `.$castTo<PublicItemRow>()` at the two list-query call sites replaced the entire result type with an assertion rather than narrowing the one column that actually disagreed, which meant a projection that silently lost a column would still compile — exactly the failure mode this task exists to close, and the opposite of what the commit body claims.

Fixed at the source instead: `itemSelect.ts` now defines `ItemsWithStatus`/`ItemDB`, narrowing `items.status` from the schema mirror's `Generated<string>` (a CHECK-constrained text column, so `kysely-codegen` has no literal union to give it) to `Generated<ItemStatus>`, and builds `ItemContext`, `adminItemQuery()` and `publicItemQuery()` from an `itemDb` typed with `ItemDB` instead of `db`/`DB`. Both `$castTo` calls and their comments are gone; the `AdminItemRow[]` / `PublicItemRow[]` annotations at the two call sites now check for real. Verified by temporarily dropping a column from `adminItemQuery`'s projection: the `AdminItemRow[]` assignment failed to compile as expected, confirming the guarantee actually holds.

Also corrected two now-false statements left over from the conversion: `db-kysely/CONVENTIONS.md`'s worked-example section said `buildItemFilterSql` was "still raw `pg`" and that converting it "would put a second copy of a live function in `src/` that nothing calls" — both untrue since #308 shipped it as `itemFilterExpressions`. And two doc comments in `itemSelect.ts` still named the deleted `PUBLIC_ITEM_SELECT`/`ADMIN_ITEM_SELECT` constants instead of the functions that replaced them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:08:09 -05:00
bermudalambandClaude Opus 5 ec1020891f refactor(db): build the item queries through Kysely (#308)
The two queries the builder was ever wanted for. #294 removed interpolation from seven sites by hoisting each fixed-shape query into a named constant; these two genuinely composed their WHERE at run time and could not be fixed that way, which is why they are the last S2077 hotspots. They were safe, and itemFilters.ts spent sixteen lines explaining why — that the clause fragments are literals, that the only things interpolated into them are placeholder indices, and that every value goes onto params. That argument was correct and it was still an argument, guarded by a comment and two tests, on a route reachable without signing in.

All four call sites moved rather than only the two flagged ones. The by-id constants carried no hotspot, but they were built by interpolating the same projection strings the list queries used, so converting only the list queries would have left itemSelect.ts holding a Kysely builder and a raw string that had to produce an identical projection — two spellings to keep in step by hand where the file's own header already warned about one.

The second thing this buys may matter more than the first. pool.query<T> asserts a shape TypeScript never checks against the SQL, which is why that header said the selects and their row types are kept in step by hand and the integration suite was the only thing that caught a drop. The projections are built with jsonArrayFrom now, which emits the same coalesce(json_agg(agg), '[]') they hand-wrote, so the row type follows from the projection and a dropped column is a compile error.

The row types stay hand-written and exported rather than being inferred from the query. Inference would be tidier and is refused deliberately: these are the shape the frontend reads, and a type that silently becomes whatever the query happens to return is a contract that can change without anyone deciding to change it.

The two invariant tests survive and got stronger. They used to inspect the clause strings the builder returned; they now compile the expressions and assert on the SQL Kysely actually emits, with the hostile value appearing in the parameters and nowhere else — the same claim, tested against the real artefact instead of an intermediate one.

Closes #308

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:00:21 -05:00
bermudalambandClaude Opus 5 3248ac656e docs(db): plan converting the item queries to Kysely (#308)
One task, and that is a decision rather than a shortcut. itemSelect.ts, itemFilters.ts, both routes and the unit test file are coupled — the exports the routes call are the ones being replaced, and #298 put the test file under a tsconfig that type-checks it, so any partial commit is a red build.

Every line of it was verified by probe against the real generated schema before it was written, not sketched. The projections type-check, jsonArrayFrom correlates through whereRef, the mixed array of sql templates and builder expressions composes under eb.and, and the emitted SQL is quoted in the steps so a wrong result is caught at the step that produces it rather than three steps later. The probe also settled the question the spec left open with a fallback: the row type is assignable to the hand-written contract, so no cast is needed.

The two invariant tests are rewritten rather than ported. They used to read the clause strings the builder returned; they now compile the expressions against the same items-and-categories shape the real queries use and assert on the SQL Kysely emits, with the hostile value present in the parameters and absent from the text. Building a narrower query in the helper would have needed a cast, and a cast in that test would be testing the cast.

The step that verifies the conversion is the one that runs the integration suite unedited. Those tests are the contract — same JSON, same ordering, same statuses — so the plan says plainly that a test needing an edit means the query changed behaviour and the query is what to fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:47:34 -05:00
bermudalambandClaude Opus 5 12e1616392 docs(db): design converting the two dynamic queries to Kysely (#308)
The work #305 made possible and deliberately did not do. These are the two queries the builder was ever wanted for: admin.ts and items.ts both splice a run-time-composed where clause into query text, and they are the only places S2077 has a real point after #294 hoisted the seven fixed-shape queries into named constants. They are safe today, and itemFilters.ts spells out why in sixteen lines — which is the problem, because a property that takes sixteen lines to explain is one an edit can quietly break.

All four call sites convert rather than only the two flagged ones. The by-id constants carry no hotspot and are already safe, but they are built by interpolating the same projection strings the list queries use, so converting only the list queries would leave itemSelect.ts holding a Kysely builder and a raw string that must produce an identical projection — two spellings to keep in step by hand where the file's own header already warns about one.

The filter builder returns an array of expressions rather than taking a query builder and returning it filtered, because the two callers do different things with the result: the storefront prepends its own not-pending clause and the admin route does not. A function that owned the builder would have to be told about that difference. startIndex disappears with the splicing it existed for.

The aggregate subqueries become jsonArrayFrom, which emits the same coalesce(json_agg(agg), '[]') they hand-write today. That is the second thing this buys and it may matter more than the first: pool.query<T> asserts a shape TypeScript never checks against the SQL, which is why itemSelect.ts's header says the selects and their types are kept in step by hand and the integration suite is the only thing that catches a drop. Afterwards that is a compile error.

The row types stay hand-written and exported rather than being inferred from the query. Inference would be tidier and is refused on purpose: these are the shape the frontend reads, and a type that silently becomes whatever the query happens to return is a contract that can change without anyone deciding to.

The two invariant tests survive and get stronger. They currently inspect the clause strings the function returns; afterwards they compile the expressions and assert on the SQL Kysely actually emits, with the hostile value appearing in the parameters and nowhere else — the same claim tested against the real artefact instead of an intermediate one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:42:52 -05:00
bermudalamb 3126e12fc0 Merge pull request 'Feature/305 kysely swap' (#306) from feature/305-kysely-swap into main
Linting / lint (push) Successful in 2m40s
SonarQube Analysis / sonarqube (push) Successful in 25m49s
Reviewed-on: #306
2026-09-04 16:08:47 -05:00
bermudalambandClaude Opus 5 add28c5f16 fix(db): repoint the lint and drift guards at the new mirror (#305)
The eslint config's ignores list and comment still named the deleted src/db-drizzle/schema.ts and relations.ts and never named src/db-kysely/schema.ts, so the generated mirror was being linted for the first time and tripping sonarjs/redundant-type-aliases — exactly the trap the config's own comment already described from #261 and #217. The ignores list now names src/db-kysely/schema.ts and the comment is updated to match.

The schema mirror drift test built one flat Set of every two-space-indented key in the whole generated file and asked only whether a live column name appeared anywhere in it, rather than checking it against the specific table it belongs to. Seventeen column names are declared on two or more tables and created_at is on fourteen of eighteen, so a migration adding created_at, updated_at, status, name, sort_order, token, or expires_at to a table that lacks it would pass vacuously. Replaced mirroredTables with mirroredColumns, which reads the DB interface to map each table name to its declaring interface and then reads that interface's own columns, and changed the column-mirroring test to look up columns per table. Verified the guard can actually fail: removing customer_id from the Carts interface made the test fail naming carts.customer_id exactly, and restoring the file made it pass again.

The root .gitignore still carried a comment block and two patterns for drizzle-kit pull output under backend/src/db-drizzle, a directory this branch deleted along with backend/drizzle.config.ts. kysely-codegen writes only the single tracked file it's pointed at, so nothing replaces the rule — deleted the block and both patterns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:08:47 -05:00
bermudalambandClaude Opus 5 ad5cb28e80 docs(db): stop calling a live query an unimported example (#305)
The paragraph describing buildItemFilterSql claimed "nothing imports it" but it is actually a live production function defined at src/itemFilters.ts:264 and imported by both src/routes/admin.ts and src/routes/items.ts. The conversion example in the conventions file was mistakenly described as though it were the function itself rather than as an example demonstrating the query pattern. Fixed the wording to clarify that the function remains raw pg code and the shown conversion is an example of how to convert it, not a committed version in src/ waiting to be called.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:08:47 -05:00
bermudalambandClaude Opus 5 aa3f75520b docs(db): write the Kysely conventions (#305)
Replaces the Drizzle conventions, and is much shorter, because three of that document's four warnings described the library rather than the practice and stopped being true when the library changed. An array is one bind parameter with no ceremony, a column reference in a raw fragment is the text you wrote, and the generated names are the database's own so nothing needs mapping back.

What survives is what was never about Drizzle. The mirror is generated and refreshing it is manual, so the drift test is the thing that catches forgetting — and it exists because the drift already happened once and nobody noticed for a week. Both drivers share one pool, because a transaction on a second pool would be invisible to the first and the limits would silently double. Migrations stay hand-written, and the reasoning survives the change of library: only the expression-index complaint was specific to drizzle-kit, while losing the prose and being unable to express data migrations are true of any generator.

One warning is genuinely new, and it is the inverse of an old one: driver errors are no longer wrapped, so a SQLSTATE sits on err.code again. That is worth stating precisely because it was not true before, and the last time it moved it turned a handled 409 into a 500 with nothing failing to compile.

The worked example moved into this file rather than staying a source file nothing imports. It is documentation, and it was only ever documentation.

Closes #305

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:08:47 -05:00
bermudalambandClaude Opus 5 731716f760 docs(db): correct the drift test's own stale references (#305)
The #217 doc comment above the describe block still named db-drizzle/schema.ts, drizzle-kit pull, and "Drizzle infers row types" — a file, a command, and a library this same commit had already removed. A comment pointing at deleted paths is worse than no comment at all on a test whose whole job is proving trust in a generated mirror, so it is corrected to name npm run db:types and src/db-kysely/schema.ts while keeping every sentence of the history intact: #217, #222, item_drafts and upload_links, the week nobody noticed. A closing note was added recording that the generator changed in #305 and the test did not, because the drift it guards is a property of generating a mirror at all rather than of any particular library.

mirroredTables' regex also gets the same digit fix the column check already had. Both regexes parse the same generated file for the same kind of identifier, and a table name with a digit would otherwise be read out of the DB interface but reported missing by mirroredTables, sending someone to regenerate a file that was never wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:08:47 -05:00
bermudalambandClaude Opus 5 c71b11e05e refactor(db): swap the query builder from Drizzle to Kysely (#305)
One commit, because a main that carries both builders is one where the next person converting a query has to guess which to reach for, and where two generated mirrors of one database can disagree. There was nothing to stage anyway: one file used the builder.

The safety property that motivated adopting a builder at all is untouched, and was never the thing being traded. A value interpolated into a sql template becomes a bind parameter in either library, so #202's invariant stays a property of the type system and #180's hotspots retire either way. What changes is the three ways the old library made it easy to be quietly wrong, each verified in #297 against the SQL actually emitted: an array interpolating as a placeholder list unless every site remembered sql.param(), a column reference inside a raw fragment silently losing its table so a correlated subquery correlated with itself, and a camelCase mirror that had to be mapped back at every select or the JSON contract changed with no test noticing.

CATEGORY_COLUMNS stops being a translation layer and becomes what it looks like — four column names four selects share. The generated types carry parent_id and sort_order because kysely-codegen emits the database's own names, so there is nothing left to map and nothing left to get wrong by forgetting to.

The drift guard survives the swap rather than being rewritten, and loses its library name in the process: it is schemaMirror.integration.test.ts now, so the next such change renames nothing. It also got stricter for free. The Drizzle version had to match each column two ways and its own comment called that deliberately loose; a generated Kysely interface spells the database's name verbatim as a bare key, so one exact match is the whole rule and snakeToCamel is gone.

isUniqueViolation keeps accepting both error shapes and now has a test behind it. Kysely uses the pg driver directly and should leave the SQLSTATE on err.code, but "should" is the word that turned two 409s into 500s when the last conversion moved it to err.cause.code with nothing failing to compile.

Migrations are untouched. #219 stands, they remain hand-written node-pg-migrate files, and Kysely has no generator to refuse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:08:47 -05:00
bermudalambandClaude Opus 5 22c51b6af7 docs(db): plan the Kysely swap (#305)
Two tasks. The first is the whole swap in one commit — dependencies, generated types, db.ts, the reconverted file, the ported drift test and the new 409 assertion — because splitting it would put a commit on the branch where the build is broken or both builders are present, and neither is a state worth being able to bisect to. The second is the conventions document, which touches no code and is much shorter than the one it replaces.

The plan carries the converted adminCategories.ts in full rather than describing it, and names the two places the conversion could silently change behaviour: the four selects must keep answering id, name, parent_id, sort_order and item_count, and the unique-violation catch must keep producing a 409. The existing category integration suite is the gate on the first, and a new test is the gate on the second.

Three expected outputs are written down so a wrong one is caught at the step rather than three steps later. Codegen must report 18 tables, not 19 — 19 means pgmigrations leaked past the exclude flag. The generated Categories interface must spell parent_id and sort_order, because camelCase there means --camel-case got turned on and the mapping layer this swap removes has come straight back. And the integration count should rise by exactly one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:08:47 -05:00
bermudalambandClaude Opus 5 4f8b15cafa docs(db): design the Kysely swap (#305)
Carries out what #297 decided. Migrations are untouched — #219 stands, they are hand-written node-pg-migrate files, and Drizzle was never doing them.

Both builders must not coexist at any commit. A main that carries Drizzle and Kysely together, even briefly, is one where the next person converting a query has to guess which to reach for and where two generated mirrors of one database can disagree. There is nothing to stage anyway: one file uses the builder.

Three things the swap gets for free, recorded so they are not mistaken for scope creep. The worked example stops being a source file — itemFilters.drizzle.ts was never imported by anything, so it was dead code in src/ that only documentation justified, and its replacement belongs inside CONVENTIONS.md where a worked example goes. The drift test loses its library name, becoming schemaMirror.integration.test.ts, so the next such change renames nothing. And that test gets stricter rather than merely ported: the Drizzle version had to check every column two ways and its own comment calls that deliberately loose, where generated Kysely types emit the database's names verbatim and the check becomes one exact match.

CATEGORY_COLUMNS disappears rather than being translated. It exists only because Drizzle's mirror is camelCase while the API answers snake_case, and its comment says selecting the table directly would silently change the JSON contract with no test noticing. With the generated types carrying parent_id and sort_order the mapping object has nothing left to do, which is the clearest single illustration of what the swap buys.

isUniqueViolation keeps tolerating both error shapes and gains a test that proves which one actually arrives. Kysely uses the pg driver directly and is expected to leave the SQLSTATE on err.code, but "expected" is the word that turned two 409s into 500s last time.

Closes #305 is deliberately not claimed here — this is the design, and the implementation follows on the same branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:08:47 -05:00
bermudalamb a74e950772 Merge pull request 'docs(db): weigh Kysely against the Drizzle decision (#297)' (#304) from feature/297-kysely-vs-drizzle into main
Linting / lint (push) Successful in 2m18s
SonarQube Analysis / sonarqube (push) Failing after 25m18s
Reviewed-on: #304
2026-09-04 14:40:33 -05:00
bermudalambandClaude Opus 5 7ca55eaa1a docs(db): weigh Kysely against the Drizzle decision (#297)
Linting / lint (pull_request) Successful in 2m19s
SonarQube Analysis / sonarqube (pull_request) Successful in 29m38s
The question is not whether Kysely is good, it is whether it is enough better for this codebase to reverse a decision already made in #216 and partly built in #217. That is a higher bar than being the nicer library, so this answers it against the same target #216 used: buildItemFilterSql, with six optional clauses composed at run time, a recursive CTE, an ANY(...::int[]) tag match with a count equality, and array parameters. Kysely compiles without a connection, so the document quotes the SQL it actually emitted rather than a reading of its documentation.

Three of the four hazards that src/db-drizzle/CONVENTIONS.md exists to warn about turn out to be properties of Drizzle rather than of type-safe query building, and two of them are the silent kind. An array interpolates as one bind parameter with no sql.param() ceremony, so the trap that document calls "the rule that will bite you" does not exist. A column reference inside a raw fragment is the text you wrote, so the correlated-subquery rewrite that returned a quietly wrong count in #218 cannot happen. And the generated types carry the database's own snake_case names, so the explicit column mapping that exists to stop a select silently changing the JSON contract is not needed at all. The property that motivated the whole exercise is unchanged: a hostile value lands in the parameters either way, so #202's invariant becomes a type-system property and #180's hotspots retire either way.

What decides it is how little is actually built. One file is converted — adminCategories.ts, three calls — against 238 raw query sites, and the generated mirror and its drift test are things any builder needs an equivalent of. The recommendation is to switch now, while the cost is reconverting one file and rewriting a conventions document that gets substantially shorter.

The counter-argument is recorded rather than hidden: Drizzle is more widely used, and #219's migration reasoning was measured against drizzle-kit specifically. That reasoning survives, because losing the prose and being unable to express data migrations are true of any generator, and Kysely simply has nothing to refuse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:23:22 -05:00
bermudalamb e437584d7e Merge pull request 'Feature/301 rotate photos' (#303) from feature/301-rotate-photos into main
Linting / lint (push) Failing after 0s
SonarQube Analysis / sonarqube (push) Failing after 0s
Reviewed-on: #303
2026-09-04 14:17:09 -05:00
bermudalambandClaude Opus 5 0bdfd100e8 fix(admin): re-request a rotated photo even when the turn failed (#301)
Linting / lint (pull_request) Failing after 0s
The turn handler only bumped the cache-busting version on the success path, so a failure between the backend's two writes (displayed file rotated, then pristine original rotated) left the admin looking at an error toast next to a photo whose src string had not changed and whose bytes the browser still served from cache — even though the displayed file on disk had already turned. The design's stated mitigation for this failure, that the admin can see the photo moved and press back once, depended on the browser re-requesting the file regardless of outcome. Moving setVersion(Date.now()) into the finally block, alongside setTurning(false), makes that re-request happen on both the success and failure paths, so the failure is now visible and recoverable the way the design intends.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:06:32 -05:00
bermudalambandClaude Opus 5 dd533ffd63 feat(admin): rotate a photo from the review queue (#301)
Two icon buttons under every thumbnail, and the client for the item-scoped endpoints behind them. Icon-only with an aria-label rather than visible text, because three labelled buttons under a 120px thumbnail is more furniture than the photo — and a button with no text has no accessible name at all without one.

They are not gated on the background-removal flag. That flag is about the sidecar, and rotation has nothing to do with it: turning a photo is a local file operation that works in every environment, including one where REMBG_URL was never set.

The cache-busting src is the part most likely to have shipped broken. Rotation does not change image_path, so after a successful turn the src is byte-for-byte the string the browser already holds a copy for, and the photo would appear not to have moved. express.static is mounted with no maxAge and would serve the new bytes on a full page reload, but nothing in a session asks it to. A version held in component state is what makes the button visibly do something, and it needs no column and no server change, because the file's identity has not changed — only this page's need to see it again.

The client lives in its own module rather than in draftsApi, whose send() hardcodes the item-drafts prefix these routes deliberately do not use. The inventory editor imports this same module unchanged when it follows, which is the whole reason the endpoints went on the item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:52:20 -05:00
bermudalambandClaude Opus 5 3891f4fd75 feat(admin): endpoints to rotate one photo of an item (#301)
Two POST routes and the module behind them. They live on the item rather than on the draft, and that is the decision that makes the inventory editor free when it follows: 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.

A cut-out and its pristine original turn together. An image that has been through #281 has two files, and rotating only the displayed one would leave them disagreeing — Restore original would then quietly un-rotate the photo, so the undo of one feature becomes a regression of another.

204 rather than 200. Rotation changes no column: the paths are identical afterwards and only the bytes differ, so there is no row worth returning, which is the same reason deleting an image is already a 204.

Only "not on this item" is a 404, and it is indistinguishable from an absent id on purpose, because an image id is a serial and this endpoint should not confirm which ones exist. Everything else stays loud as a 500, and the file is untouched in every one of those cases — rotateInPlace renames over the original only once the new file has been written.

One asymmetry is recorded rather than engineered around: rotation is not idempotent the way background removal is, so a retry after a failure between the two files turns the displayed one twice. That needs the disk to break between two writes, and the remedy is one press in the other direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:46:29 -05:00
bermudalambandClaude Opus 5 3659608abe test(images): release the sharp handle before teardown (#301)
libvips caches input file mappings in memory after a pipeline finishes. On Windows, this keeps an open handle on the input file, and the OS refuses to delete a file with an open handle. The animated-WebP test triggers a pipeline rejection (correctly refusing a multi-page rotation), so the mapping stays in cache and afterEach cannot remove the test directory.

Disabling the cache costs these tests nothing: each file is read exactly once during its test, so there is no reuse to cache. With caching disabled, Windows can delete the input files and afterEach succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:40:18 -05:00
bermudalambandClaude Opus 5 5c0c7186ff feat(images): turn a stored photo a quarter turn (#301)
The file half of the remedy for what #300 could only stop. Fixing the EXIF strip means new uploads arrive the way the sender saw them; it cannot repair what is already stored, because the tag that said which way up the pixels went is gone. Those photos need a person to look at each one and turn it.

Rewrites the pixels rather than recording an angle, because an angle obliges every consumer to honour it — the storefront, both admin screens, the drafting worker's photo reader, and the rembg sidecar — and any one that forgets shows the photo sideways. The sidecar is not ours to teach.

Left is anticlockwise and right is clockwise, which is rotate(-90) and rotate(90); sharp reads a positive angle as clockwise. The direction test asserts a pixel rather than a dimension, because dimensions swap whichever way the turn goes — a reversed sign would pass every size assertion and ship a control that does the opposite of its label.

The animated WebP case is the one that could destroy someone's file quietly. Reading such a file without the animated flag succeeds and hands back the first frame alone, so a rotation that omitted it would write a still back over the animation and report success. Passing the same flag reencodeInPlace passes makes sharp refuse instead — multi-page images turn only by 180° — which is the honest answer and leaves the file untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:37:41 -05:00
bermudalambandClaude Opus 5 df508b3783 docs(admin): plan the photo rotation work (#301)
Three tasks, each with its own test cycle: the file operation, the endpoints and the module behind them, then the control in the review queue.

Two things were probed rather than assumed while writing it, and both changed the design. sharp reads a positive angle as clockwise, so left is rotate(-90) and right is rotate(90) — and the direction test asserts a pixel rather than a dimension, because a rectangle's dimensions swap whichever way the turn goes and a reversed sign would pass every size assertion while shipping a control that does the opposite of its label. And a quarter turn of an animated WebP is refused by sharp itself, which is what makes passing the same animated flag reencodeInPlace passes the safe choice: omitting it would read the first frame alone and write a still back over someone's animation while reporting success.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:32:32 -05:00
bermudalambandClaude Opus 5 1178129110 docs(admin): design rotating a photo from the admin (#301)
The remedy for what #300 could only stop. Fixing the EXIF strip means new uploads arrive the way the sender saw them; it cannot repair what is already stored, because that metadata is gone and the originals kept for #281's cut-outs were re-encoded on the way in too. Every portrait photo since #226 needs a person to look at it and turn it.

Rotation rewrites the file rather than recording an angle. Storing an angle keeps the bytes pristine and makes undo exact, but it puts an obligation on every consumer — the storefront, both admin screens, the drafting worker's photo reader, and the rembg sidecar — and any one that forgets shows the photo sideways. The sidecar in particular is not ours to teach. Rewriting means nothing else in the system has to know rotation exists, and the cost is bounded: one rotation is a second generation at quality 82, which is why the control offers both directions rather than making somebody press one button three times to undo.

Per photo, and that is deliberately the opposite of what #293 decided for background removal. The reason there does not carry: three photos of a vase can each be wrong in a different direction, so turning them together would fix one and break two.

A cut-out and its original turn together. An image that has been through #281 has two files, and rotating only the displayed one would leave them disagreeing — Restore original would then silently un-rotate the photo, turning the undo of one feature into a regression of another.

The endpoints go on the item rather than the draft, so the inventory editor needs no backend work at all when it follows: an image belongs to an item whether or not a draft row exists, and the second screen is then the same call from a different place.

Two things pinned so they are not settled by a coin-flip while implementing. Left is anticlockwise and right is clockwise, which is sharp.rotate(-90) and sharp.rotate(90) — sharp reads a positive angle as clockwise, so the sign is the whole mapping and reversing it produces a control that works and does the opposite of its label. And the displayed image has to be forced to reload: rotation does not change image_path, so the img src is identical afterwards and the browser keeps what it has. express.static is mounted with no maxAge and would serve the new bytes on a page reload, but nothing in a session asks it to, so the src gets a cache-busting parameter after a successful turn.

One honest asymmetry recorded rather than engineered around: rotation is not idempotent the way background removal is, so a retry after a half-done failure turns the displayed file twice. That needs the disk to break between two writes, and the remedy is one press in the other direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:31:08 -05:00
bermudalamb 2ee0f2b35a Merge pull request 'fix(uploads): apply the EXIF orientation before discarding it (#300)' (#302) from fix/300-apply-exif-orientation into main
Linting / lint (push) Successful in 3m29s
SonarQube Analysis / sonarqube (push) Failing after 7m50s
Reviewed-on: #302
2026-09-04 13:30:03 -05:00
bermudalambandClaude Opus 5 d3ccc1b1f6 fix(uploads): apply the EXIF orientation before discarding it (#300)
Linting / lint (pull_request) Canceled after 0s
SonarQube Analysis / sonarqube (pull_request) Canceled after 0s
Photos arrived in the review queue rotated, in an orientation the sender never saw, and we were doing it to them.

A camera does not turn its sensor data round. It writes the pixels as the sensor read them and sets an EXIF 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. The re-encode from #226 rebuilds the file from decoded pixels and drops all metadata, which is right and is the whole point: a product photo should not publish the coordinates it was taken at. But it never applied the orientation first, so the sideways pixels survived and the one piece of information that explained them did not.

The fix is sharp's rotate() with no argument, which reads the tag rather than turning the image by a fixed amount, placed before resize. The order matters: resize bounds width and height, and for a portrait photo those are the wrong way round until the rotation has happened, so a 3000x4000 photograph stored as 4000x3000 would otherwise be bounded on the wrong axis.

Two tests, one of which is a fixture lesson. The fixture is a 400x200 image tagged Orientation 6 — the shape a portrait photo actually has on disk — and the assertion is that it comes back 200x400. The first version built it with withExif({ IFD0: { Orientation: '6' } }), which sharp reads back as orientation 1: a fixture carrying no orientation at all, which would have passed against the unfixed code and proved nothing. It uses withMetadata({ orientation: 6 }) instead, and the comment says why so the next person does not repeat it. Confirmed by removing rotate() and watching the test fail.

The second test pins that the tag itself still goes, so nothing downstream rotates the image a second time.

This does not repair the photos already uploaded. Their EXIF is gone, so nothing records which way up they were meant to be, and the originals kept for #281's cut-outs were themselves re-encoded on the way in. Those need a person and a rotate button, which is #301.

Closes #300

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:05:08 -05:00
bermudalamb 522b8f1f74 Merge pull request 'fix(lint): bring the backend test suites into scope (#298)' (#299) from fix/298-lint-backend-tests into main
Linting / lint (push) Canceled after 0s
SonarQube Analysis / sonarqube (push) Canceled after 0s
Reviewed-on: #299
2026-09-04 12:58:07 -05:00
bermudalambandClaude Opus 5 0b6cc85c4f fix(lint): bring the backend test suites into scope (#298)
Linting / lint (pull_request) Successful in 2m55s
SonarQube Analysis / sonarqube (pull_request) Successful in 30m43s
The backend lint script covered src and scripts; the frontend's has always covered src and tests. So roughly sixty backend test files had never been linted at all.

That was a documented deferral rather than an oversight — the config said so in as many words, because tsconfig.json includes only src and type-aware rules had no program to resolve the test files against. tsconfig.test.json is that program, exactly as frontend/tsconfig.test.json was for the same problem in #137. It is separate from tsconfig.json rather than a widening of it, because that one drives the build and emits to dist, and pulling the suite in would ship the tests. The files were already type-checked at run time by ts-jest; this adds nothing to that, only to what the linter can see.

Pointing it at tests produced 77 warnings and no errors. Sixty of those were rules that cannot be true in a test, so they are switched off here rather than left to accumulate — #60's argument, that a gate nobody reads is not a gate, and that a rule which cannot be true is noise hiding the rules that can. Forty-one alone were hardcoded passwords, which are the entire point of a test and which this project's own rule says must live only in test paths, which is here. The rest were a stub server on http to a socket the test opened itself, an RFC 5737 documentation IP, os.tmpdir, Math.random for a run id, and sorting two arrays to compare them.

What was left was signal, and it found a real one on the first run. testDb.ts cleaned up settings with LIKE 'email\_%', and in a JavaScript string that backslash does nothing: the pattern is 'email_%', and an underscore in SQL LIKE matches any single character. It meant "email plus any one character" rather than "email_". It deleted the right rows only because no other key begins with those letters followed by something else — a setting called emailing_enabled would have been swept away between suites, silently, in a file that never mentions it. It now uses an explicit ESCAPE clause.

It also found five dead `const before: string[] = []` declarations in uploadValidation, left over from #228's redesign of that suite. The tests assert properly through filesSettlingTo; the variables did nothing.

Seven warnings remain, all in routesAreWrapped and workflowGate, and all judgement calls about guard-test complexity rather than defects. Leaving them visible is the point of having lint here at all.

Closes #298

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 12:41:58 -05:00
bermudalamb 1683fbbf0f Merge pull request 'Feature/293 remove backgrounds from inventory' (#296) from feature/293-remove-backgrounds-from-inventory into main
Linting / lint (push) Successful in 2m55s
SonarQube Analysis / sonarqube (push) Successful in 33m58s
Reviewed-on: #296
2026-09-04 12:20:01 -05:00
bermudalambandClaude Opus 5 b70e4a68f0 docs(specs): match the design to the shipped behaviour (#293)
Linting / lint (pull_request) Successful in 2m8s
SonarQube Analysis / sonarqube (pull_request) Successful in 34m10s
The spec asserted two things the implementation disproved. RestoreSummary was described as having no `failed` because "restoring cannot fail the way removing can" — true about the sidecar, wrong about the database, and rethrowing turned a partial success into an opaque 500. And the single-button-with-two-labels rule was described as deliberately covering the mixed case, when in fact it stranded it: a partly cut-out item offered only Remove, so its existing cut-outs had no way back.

Both sections now describe what the code does and why, including why Restore is not gated on the feature being configured, and the outcome table's "feature not configured" row is corrected to say Restore is still offered and still works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 12:02:48 -05:00
bermudalambandClaude Opus 5 64f8efb617 fix(admin): offer Remove and Restore independently (#293)
One button whose label flipped on "is every photo cut out?" could not serve a partly cut-out item, which is not a hypothetical state: it is what a partial removal leaves behind, and it is also what happens when REMBG_URL goes away after some photos were already done. In that state the single button read "Remove backgrounds", so the cut-out photos the item already had could never be restored from this screen. Remove and Restore are now separately gated and can appear together, which is correct — Remove finishes the job on what is left, Restore undoes what is already done.

Restore is deliberately not gated on the backgroundRemoval config flag. Gating it would strand cut-out photos with no way back in exactly the environment that most needs the undo. Remove stays gated, so an unconfigured environment shows no button rather than one that reports zero of four done every time.

The emptiness check moves from `!== null` to `!= null`: original_image_path is optional on the shared Item type because the public storefront response omits it, so a stray undefined has to count as "not cut out" — `undefined !== null` is true, which would misread a public-shaped item as fully cut out.

The modal now refreshes on a non-ok response too. A restore that fails partway can still have swapped some files back before it failed, so returning early left the thumbnails showing files that are no longer on the server. The warning text is now driven off whichever count the action reports, so a partial restore says how far it got the same way a partial removal already did.

The e2e spec seeds its item into a category of its own and filters the table down to it. The inventory table paginates at 10 and the suite runs fullyParallel, so an unfiltered page one was never a reliable place to find the fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 12:02:37 -05:00
bermudalambandClaude Opus 5 445c9c4a22 fix(backgrounds): report a partial restore instead of throwing (#293)
restoreOriginalsForItem rethrew anything that was not NoOriginalToRestoreError, which handed asyncRoute a bare 500 and discarded how far the restore had already got. That breaks the invariant the feature is built on: photos restored before the failure really are back, and an admin standing in front of the modal needs the count to decide whether pressing the button again is worth anything. RestoreSummary now carries `failed` and the loop stops and reports, exactly the shape and the reasoning removeBackgroundsForItem already had.

The restore-originals route gains the missing 404 for an item that does not exist — remove-backgrounds always had it, and the two handlers are copy-paste rather than a shared helper, so nothing would have caught them diverging. draftingWorker's .catch is now only reachable if the image-listing query itself throws, since removeBackgroundsForItem no longer rejects over a single photo; its comment says so rather than describing behaviour that has moved.

The `failed` branch is covered by a unit test that stubs the database module in its own module registry. It cannot honestly be an integration test: the only failure the function 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 and that afterAll calls pool.end() on. Two tests that did exactly that are removed here — they 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 12:02:23 -05:00
bermudalambandClaude Opus 5 dbb63bc3d2 fix(admin): keep the active inventory filter and surface background-swap failures (#293)
handleBackgrounds re-read /api/admin/items unfiltered and called setItems(all) after every remove-backgrounds or restore-originals call, so an admin who had filtered Inventory to one category and opened an item from that filtered view saw the table silently repopulate with the entire unfiltered catalogue the moment the request resolved. Every other mutation in this file goes through load(), which respects the active filters; this one didn't, for no reason the spec required.

The fix reuses load() instead: it now hands back the rows it fetched (previously discarded after setItems), and handleBackgrounds picks the edited item's fresh row out of that filtered result to refresh the open modal, rather than issuing a second unfiltered fetch. There is no GET /api/admin/items/:id route to fetch a single item directly, and the remove-backgrounds/restore-originals routes return only a summary, not the item, so load()'s own result is what's actually available. A background swap never touches the fields anything filters on, so the edited item stays in the filtered result whenever it was in it before.

Also added a catch to handleBackgrounds, matching the message.error shape every sibling handler (handleDelete, handleDeleteImage, handleStatusChange) already uses — previously a network drop or a malformed JSON body became an unhandled rejection with no toast, silently different from how the rest of the file reports failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:44:07 -05:00
bermudalambandClaude Opus 5 385d5b89bf feat(admin): offer background removal where an item's photos are edited (#293)
One button per item in the inventory editor, beside the per-thumbnail delete buttons rather than on them, because an upload is one item and its photos are views of one thing.

Its label is derived from the images rather than stored: Restore originals when every photo already carries an original, Remove backgrounds otherwise. The otherwise deliberately covers the mixed state a partial failure leaves behind — with two of four cut out it reads Remove backgrounds, which is the action that finishes the job, and pressing it skips the two that already worked.

Rendering is gated on the feature being configured or every photo already being cut out, not on the flag alone. Gating on the flag would hide Restore originals the moment REMBG_URL is unset, stranding cut-out photos with no way back — the same reasoning the review queue's control already uses.

The editor is a modal and this changes files on the server while it is open, so the item is re-read afterwards and the open modal updated. Without that the thumbnails keep showing the previous files and the button looks like it did nothing, which is the bug this was most likely to ship with.

frontend/src/api.ts gains original_image_path on the shared Item type's images, since ADMIN_ITEM_SELECT's images aggregate carries it and the public catalogue's does not. It is added as optional rather than required because Item is the same type fetchItems() uses for the public storefront, and a required field the public response never sends would be a type that lies about what is actually there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:36:00 -05:00
bermudalambandClaude Opus 5 8f35204995 feat(admin): remove or restore every background on an item (#293)
Two routes on the item, and a small admin config route so the inventory screen can know whether to offer them.

Both answer 200 once the id is valid, even when the sidecar fails, and that is a deliberate departure from the per-photo endpoints in #281. Those act on one image, so the request either worked or it did not and 502 says which. These act on several, so "did it work" has no single answer — two of four is the normal shape of a bad day here, not an exception — and a 502 would throw away the count that is the only thing making the outcome actionable. Non-200 is reserved for not being able to try at all, which here means an unreadable or absent id.

No status check on either. 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.

The config route follows adminVersion's precedent rather than extending the public /api/config: admin-only, one purpose, and the reason written down. The inventory screen had no other way to learn the feature exists, because GET /api/admin/items answers a bare array with several consumers and reshaping it for one boolean is the worse trade.

Also extends the admin item select to carry original_image_path on each image, behind a new ADMIN_IMAGES_SUBQUERY kept separate from the shared IMAGES_SUBQUERY the public select uses. Task 3 needs to derive its restore-button label from that field, and the server never sent it for items before this — only the drafts endpoint carried it, added by #281 for the review queue. It stays admin-only for the same reason itemSelect.ts already names PUBLIC_ITEM_SELECT's columns explicitly: an internal original filename is nobody's business on the storefront, and sharing one subquery would put it in every public item response.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:26:19 -05:00
bermudalambandClaude Opus 5 6f3e77fa88 feat(intake): report what a whole-item background removal actually did (#293)
removeBackgroundsForItem answered void and threw on the first failure, which is enough for the drafting worker — it catches and logs, and a draft is not worth failing over — and not enough for an admin standing in front of a screen who needs to know whether the thing they pressed happened. It now returns a summary: how many photos the item has, how many carry a cut-out, and whether it stopped early.

It still stops at the first failure. 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 a retry resumes rather than starting over. The count is what turns that retry into an informed choice instead of a guess.

Adds restoreOriginalsForItem alongside it. A photo that was never cut out is skipped rather than refused, because the mixed state a partial removal leaves behind is exactly when somebody reaches for this.

The one existing caller does not change: the worker ignores the result, and ignoring a returned value is legal, which is what makes this additive rather than breaking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:16:04 -05:00
bermudalambandClaude Opus 5 f42ea70d88 docs(admin): plan background removal in the inventory editor (#293)
Three tasks: the two per-item functions, the endpoints plus a small admin config route, and the button.

The config route is the piece the spec did not anticipate. The inventory screen has no way to learn the feature is configured — GET /api/admin/item-drafts carries that flag for the review queue, but GET /api/admin/items answers a bare array with several consumers, and reshaping it for one boolean is the worse trade. routes/adminVersion.ts is the precedent for exactly this: a small admin-only GET, deliberately not folded into the public /api/config, with the reason written down beside it.

Two decisions the plan pins that the spec left as prose. The render condition is "configured OR every photo already cut out", not the flag alone, because gating on the flag would hide Restore originals the moment REMBG_URL is unset and strand cut-out photos with no way back — the same shape DraftQueue already uses for the same reason. And the button re-reads the item afterwards, because the editor is a modal changing files on the server while it is open, and without that the thumbnails keep showing the previous files and the button looks inert.

Self-review caught the mistake I have now made three times this session, which is naming something that does not exist. Task 3's end-to-end case called createItem(page, { withImage: true }); createItem actually takes an APIRequestContext rather than a page, and CreateItemOptions has no image field at all. Since the control only renders for an item that has photos, the item now gets seeded through the admin API with a real PNG attached, which is a thing that works rather than a thing that reads well.

It also records a spec requirement that is deliberately not implemented as written. The spec asks for an end-to-end assertion that the control is absent when the feature is unconfigured; that would mean restarting the backend mid-suite, which the run has no way to do and should not gain one. It is covered where it can be, in the integration test for GET /api/admin/config, and the plan says so rather than dropping it quietly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:08:23 -05:00
bermudalambandClaude Opus 5 43a1bfef69 docs(admin): design background removal in the inventory item editor (#293)
The design behind #293, with the three decisions the issue left open now answered.

It applies to a live product image, and to every status including sold and reserved. The precedent in unpublish, which refuses both by name, does not carry: what that protects is a customer losing an item mid-checkout and a completed sale being quietly rewritten, and neither is at stake in a photograph's background. A sold item's photos are still the shop's photos.

The action is per upload rather than per photo, and that is the decision shaping everything else. An upload is one item — the front, the back and the chipped base are three views of one vase, not three things to cut out separately. It also means DraftPhoto is not the component to lift, despite looking like it: the queue's control is per photo and this one is per item, so sharing it would force one to pretend to be the other. The real reuse is underneath, in removeImageBackground and restoreImageOriginal, which already exist and are already idempotent.

removeBackgroundsForItem gains a summary return. It answers void today and throws on the first failure, which is enough for the worker — it catches and logs, and a draft is not worth failing over — and not enough for an admin standing in front of the screen. The one existing caller ignores the result, so widening it is additive, the same way sendMail was in #260.

Writing it caught a contradiction in my own first draft worth recording. The failure table said a sidecar failure answers 502 while the screen section promised the admin sees "2 of 4 photos done", and both cannot be true, because a 502 throws away the count that makes the outcome actionable. Resolved by these two routes always answering 200 once the id is valid: they act on several images, so "did it work" has no single answer, and the summary is the result. Non-200 is reserved for not being able to try at all. That is a deliberate departure from #281's per-photo endpoints, which act on one image and can honestly say yes or no.

Two ambiguities also fixed before they became implementation coin-flips: what the button says in a mixed state, which is exactly what a partial failure leaves behind and which reads Remove backgrounds because that is the action finishing the job; and that restore has no failure mode of its own, being a database swap with no sidecar in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:03:42 -05:00
bermudalamb ce456dca0c Merge pull request 'chore(sonarqube): keep interpolation out of query call sites (#294)' (#295) from chore/294-no-interpolation-at-query-sites into main
Linting / lint (push) Successful in 2m16s
SonarQube Analysis / sonarqube (push) Successful in 35m40s
Reviewed-on: #295
2026-09-04 09:36:39 -05:00
bermudalambandClaude Opus 5 71efe6ee6a chore(sonarqube): keep interpolation out of query call sites (#294)
Linting / lint (pull_request) Successful in 2m15s
SonarQube Analysis / sonarqube (pull_request) Successful in 29m0s
The quality gate was red on one condition only — new_security_hotspots_reviewed at 75 against a threshold of 100 — and the outstanding hotspot was admin.ts's `${ADMIN_ITEM_SELECT} WHERE i.id = $1`.

Worth being exact about what was wrong with it, because it was not what it looked like. The value was already parameterized: itemId was bound as $1, travelled through the driver's separate parameter channel, and never entered the query text. What was interpolated was a module constant containing no caller data. S2077 fires on the template literal rather than on the value, because the rule cannot tell a constant from a request field — and neither, at a glance, can a person reading it.

So the fix is not to parameterize something already parameterized. It is to stop interpolating at query call sites at all, which turns a property somebody has to verify into one they can see. Every query whose shape is fixed is now a named constant and every such call passes an identifier: ADMIN_ITEM_BY_ID for the two admin routes, PUBLIC_ITEM_BY_ID, LINK_LIST, and the two draft-queue shapes. Seven interpolating call sites become three.

The three that remain cannot become constants and now say so rather than looking like ones nobody got to. admin.ts and items.ts build their WHERE at run time from buildItemFilterSql, whose fragments are string literals whose only interpolations are placeholder indices; that reasoning was already written down and is unchanged. draftingWorker interpolates a table name, and this is the one query here that genuinely cannot be parameterized in any form — a bound parameter is a value, and Postgres will not accept an identifier as one, so the choice is interpolation or nothing. What makes it safe is the closed 'categories' | 'tags' union, and the comment now says that instead of merely asserting there is nothing to worry about.

Also clears the project's only open Sonar issue, S1854 on adminUploadLinks, which #260 introduced and which I had deferred as a tidiness point. It was more than that: outcome was initialised at its declaration and assigned the same value again in the catch, which made two different failures 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" sent the admin looking in the wrong place. The SMTP-rejection conflation that was actually agreed stays, and is now the only thing that catch conflates.

Closes #294

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:23:29 -05:00
bermudalamb b8ea33f45d Merge pull request 'Feature/260 email the upload link' (#292) from feature/260-email-the-upload-link into main
Linting / lint (push) Successful in 7m44s
SonarQube Analysis / sonarqube (push) Successful in 40m14s
Reviewed-on: #292
2026-09-03 18:50:36 -05:00
bermudalambandClaude Opus 5 046937cdce test(e2e): assert a created link's address shows in the table (#260)
Linting / lint (pull_request) Successful in 4m43s
SonarQube Analysis / sonarqube (pull_request) Successful in 38m0s
The spec's E2E section requires that a created link shows its address in the table, but the test that creates one filled a throwaway address and never asserted the cell. The address is now kept in a variable and asserted on the row after creation, using it to locate the row's own cell rather than any other row's.

Not run: the local stack is down and Playwright was explicitly out of bounds for this pass, per the task instructions. This is written and verified by inspection and lint only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:31:35 -05:00
bermudalambandClaude Opus 5 4e7f255782 fix(admin): return contact_email from the revoke query (#260)
The revoke route's RETURNING clause omitted contact_email while its result was typed as UploadLinkRow, which declares the field as present. No user-visible effect since the response was never checked for it, but the type asserted something the query did not actually return. Added the column so the two agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:31:25 -05:00
bermudalambandClaude Opus 5 c12d40dc02 fix(admin): stop the upload links table claiming delivery it never recorded (#260)
The table headed its address column "Sent to", but contact_email records only the intent an admin typed in, never whether delivery happened — that outcome is shown once, at creation, and is not persisted. In QA, where every send is blocked by design, every row read "Sent to ..." for links that were never emailed, and the honest warning that appears at creation is guarded on `issued`, so it vanishes on refresh, leaving the false heading as the only surviving statement. Renamed to "Email", which is true of what the column actually stores.

Separately, create() called setMailed(null) before every request, including one that would go on to 400. If an admin creates link A while mail is down (warning shown, token A still on screen) and then mistypes an address on a second attempt, the 400 path returned early — but the reset had already run, so the warning for link A disappeared while token A was still displayed on the same screen. setMailed is now only called after a successful create, alongside setIssued, so a request that never produces a new link can no longer clear a warning that belongs to the one still shown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:31:16 -05:00
bermudalambandClaude Opus 5 ba26ee99f0 fix(mail-templates): add sample values for the upload-link placeholders (#260)
submitUrl, label and submissionsAllowed were added to the uploadLink template but SAMPLE_VALUES had no entries for any of them, so an admin opening Email templates, Upload link for a contributor, Preview saw literal {{submitUrl}} in the body — the preview being the only way to check an edit before saving.

The unit test meant to catch exactly this, in emailTemplates.test.ts, iterated a hardcoded KEYS array that predated intakeDraft and uploadLink, so it never checked either template's samples. KEYS is now Object.keys(TEMPLATES) as TemplateKey[], so the guard covers every template automatically and cannot go stale the same way again. intakeDraft already had samples for all its placeholders and passes once included, as expected.

One other test in the same file, "every template can address the customer", asserts that available contains greeting/firstName/lastName — a real invariant of the six customer-facing templates, but not of intakeDraft or uploadLink, which notify the shop and a contributor rather than a customer with a name on file. Switching that test to the new all-templates KEYS would have made it fail for both, so it now uses its own explicit CUSTOMER_FACING_KEYS list instead. That is a deliberate, commented exception: a hardcoded list is correct there because the claim itself does not extend to every template, whereas the SAMPLE_VALUES guard's claim does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:31:03 -05:00
bermudalambandClaude Opus 5 0dd4ac36ff fix(mail): bound the transporter's timeouts, and fix a now-stale comment (#260)
adminUploadLinks.ts awaits sendMail on the admin's request path, the first awaited send on a user-facing request in this codebase, but the transporter in mailer.ts set no connectionTimeout, greetingTimeout or socketTimeout. nodemailer's defaults then apply: two minutes to connect, ten minutes on the socket. If the SMTP host is unreachable in a way that drops packets rather than refusing, the link row and its token are already committed by the time sendMail is called, the response hangs for up to two minutes, the browser or reverse proxy gives up first, and the token — shown exactly once and unrecoverable — is never rendered. That is the link being lost in exactly the way this feature's central invariant forbids.

All three timeouts are now set to 5000ms, with a comment explaining why a send on a request path has to fail fast rather than inherit nodemailer's fire-and-forget defaults. Five seconds is generous for a reachable host and short enough that a dead one fails while the admin is still willing to wait, leaving them the "not emailed" warning and a link they can still copy instead of a stuck spinner and a token nobody ever saw.

Also corrects the comment directly above the skipped-blocked return, which said "returning as though it sent" — true before #260, and precisely backwards now that the outcome is reported through MailOutcome rather than swallowed. Reworded to describe what the code actually does today, keeping the explanation of why it skips rather than throws.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:30:49 -05:00
bermudalambandClaude Opus 5 5a9022d8d1 test(integration): mock the mailer everywhere an upload link is issued (#260)
intake.integration.test.ts, intakeCeiling.integration.test.ts and uploadLinks.integration.test.ts all create upload links, and since #260 that now sends real mail. env.setup.ts never clears SMTP_USER, SMTP_PASSWORD or MAIL_ALLOWLIST, so with those inherited from a developer's shell these three files opened live TLS connections to smtp.gmail.com:465 and, with no allowlist set, actually delivered to sarah@example.com. This is the exact hazard the "Do not add one back" comment in tests/unit/mailOutcome.test.ts already warns about, reintroduced at the integration layer.

All three now mock ../../src/mailer the same way accountDetails.integration.test.ts, favorites.integration.test.ts and resendVerification.integration.test.ts already do. uploadLinks.integration.test.ts is the one place that needs to see specific MailOutcome values come back through the route, so its two outcome tests were restructured to drive the mock's return value directly (sentMail.mockResolvedValueOnce(...)) instead of threading SMTP_USER/MAIL_ALLOWLIST through the real sendMail. That is a cleaner test anyway: it isolates the route's job (reporting whatever outcome sendMail returns) from sendMail's own skip logic, which is already covered hermetically by mailOutcome.test.ts and mailAllowlist.test.ts.

Also addresses the related minor finding that nothing asserted the mail actually carried the working link: required: ['submitUrl'] on the template only guards that the placeholder is present in the body, not that the route supplied a correct value for it. A new test in uploadLinks.integration.test.ts inspects the mock's captured call and asserts the html contains the created token's /submit/ URL, and covers the three submissionsAllowed phrasings (a numeric cap, a cap of exactly one, and uncapped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:30:35 -05:00
bermudalambandClaude Opus 5 9c5935e8e0 feat(admin): ask for the contributor's address when creating a link (#260)
The address is now a required field beside the label, the links table shows where each link was sent, and the admin is told plainly when the mail did not go — with the link still on screen to copy, which is the case that matters in QA and in local development where there is no mail at all.

Two specs in unrelated features created links with only a label and the route now refuses that, so they are updated here rather than left to go red on somebody else's branch. That is the cost of making the address required, and it is a small one: the compiler and the suite find every call site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:07:37 -05:00
bermudalambandClaude Opus 5 b18b3e3a3d feat(intake): require an address for an upload link and send the link to it (#260)
Creating a link now requires a valid email address and mails the link to it, which is the whole point: getting a link to a contributor was previously a copy-and-paste into whatever the admin happened to use.

The send is awaited and its outcome reported, unlike every other sender in this codebase, which 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 exactly the thing they need to know — and QA blocks delivery to any address outside MAIL_ALLOWLIST by design, so a link that was never emailed would otherwise look precisely like one that was.

A send that could not happen does not roll the link back. The token is displayed exactly once, so a rollback would leave the admin retrying and holding a different link, discarding work that had succeeded. They end up with a usable link and an honest statement about delivery instead.

One inaccuracy left deliberately: an SMTP rejection is reported as skipped-unconfigured rather than a fourth outcome of its own. The distinction is real but nothing consumes it, and the admin's next action is identical either way.

Also updates the other integration tests that created a link with only a label, since an address is now required, and adds the uploadLink template key that GET /api/admin/email-templates was missing from its list — an omission left by the template's addition in the prior commit on this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:00:15 -05:00
bermudalambandClaude Opus 5 eaecc43379 feat(intake): record where an upload link was sent, and how to say it (#260)
Adds upload_links.contact_email and the uploadLink mail template.

The column is nullable on purpose. Links already exist in QA and a migration cannot invent addresses for them, so they are grandfathered rather than backfilled with something untrue; the requirement belongs in the create route, which is where new links are actually made.

The template requires submitUrl, the same guard verification has on verifyUrl. An email inviting somebody to send in photos, with no way for them to do it, sends perfectly happily and looks fine in the log — it is the one failure here worth making impossible, and a test asserts the default body satisfies the guard it declares.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:50:11 -05:00
bermudalambandClaude Opus 5 823796b92a test(mail): stop the allowlist test from reaching real Gmail (#260)
Review of the Task 1 commit approved the implementation but flagged the third test in mailOutcome.test.ts as a defect carried over from the brief: it set SMTP_USER, SMTP_PASSWORD, and an allowlist the recipient satisfied, so sendMail fell through both early-return guards and reached the real transporter, opening a live TLS connection to smtp.gmail.com:465. The .catch(() => 'threw') wrapper hid a fast auth rejection, a slow timeout, or an accidental real send equally, and on a restricted CI runner it would hang to the Jest timeout rather than fail fast.

The fix deletes that test rather than replacing it. What it was trying to prove — that an allowlisted recipient is not blocked — is already covered hermetically by backend/tests/unit/mailAllowlist.test.ts, which exercises isAllowedRecipient directly across exact matches, plus-suffixes, domains, and refusals. The other two tests in mailOutcome.test.ts are untouched; they cover the two paths that return early, which is the entire point of the change, and neither one reaches the transporter.

The file's top doc comment is updated to match: it now says only the two skip paths are covered here, names mailAllowlist.test.ts as where the allowlist's own behaviour is tested, and spells out why a third test that reaches the transporter does not belong in this file, so nobody adds one back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:46:29 -05:00
bermudalambandClaude Opus 5 8a35c213fc feat(mail): have sendMail say what it actually did (#260)
It returned Promise<void> and returned early in two cases that were indistinguishable from success at the call site: no SMTP credentials, and a recipient outside MAIL_ALLOWLIST. A caller could therefore report that it had emailed somebody a message nobody would ever receive, and in QA — which restricts delivery deliberately, as its entire safety property — that is the normal case rather than an edge one.

It now returns a MailOutcome saying which of the three happened. No existing caller changes: there are seven and every one ignores the result, so this is additive. Re-deriving the answer at a second site would have duplicated isAllowedRecipient and the SMTP check, which is exactly the drift the guard-in-one-place comment above them exists to prevent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:39:59 -05:00
bermudalambandClaude Opus 5 a9e865b4bc docs(intake): plan emailing the upload link (#260)
Four tasks over the approved design: sendMail gains an outcome, the column and template land together, the route requires an address and reports what the send did, and the admin screen asks for it.

sendMail goes first deliberately. Everything else depends on being able to tell a skipped send from a real one, and it is the only change touching a file seven other things already use — so if it is going to break anything, it should break before three tasks are stacked on top of it.

The plan is explicit that no existing caller changes. Ignoring a returned value is legal, which is what makes widening the return type additive rather than breaking, and re-deriving "would this address be blocked?" in the route would have duplicated isAllowedRecipient and the SMTP check at a second site.

Two specs in unrelated features create links with only a label, and the route will refuse that. They are fixed in the same task as the form rather than left for the suite to find, because the alternative is two unrelated features going red on somebody else's branch. That cost is named in the plan rather than discovered.

One inaccuracy is left in deliberately and said out loud: an SMTP rejection is reported as skipped-unconfigured rather than a fourth outcome. The distinction is real, nothing consumes it, and the admin's next action is identical either way — copy the link and send it by hand.

Self-review caught the failure mode from #281, where tasks referred to helpers that did not exist. Task 4 originally said the created-link state "may not be called created". Reading the component showed it is `issued` and holds a bare URL string with nowhere to put a delivery outcome, so the plan now adds a separate `mailed` state beside it rather than widening the one-time token display. Every name in that task is now one that exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:32:09 -05:00
bermudalambandClaude Opus 5 9e1e650786 docs(intake): design emailing an upload link to its recipient (#260)
The issue asked for the security question to be settled before any code, and it is: the mail carries the working link.

The issue framed that as a loosening comparable to a password reset, and on inspection that framing overstates it. A reset token takes over an account. An upload token grants exactly one capability — submit photos into a queue where a person must approve them before anything reaches the storefront. It reads nothing, it is revocable, max_submissions caps it, and #227 caps the whole intake surface regardless of any single link. The worst outcome of a leaked upload link is junk in the review queue, which is bounded and reversible. That is a reasonable thing to put in an inbox, and this project already makes the much larger bet with reset links.

The address is required for new links while the column stays nullable, which is not a contradiction: links already exist in QA and a migration cannot invent addresses for them, so they are grandfathered, and the requirement belongs in the route where new links are actually made. It lives on the link rather than on a contributor entity, because a link already carries a label naming who it is for and nothing yet suggests the same people submit repeatedly.

A failed send 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. The link is created, the send is attempted, and the response says which happened — which matters concretely because QA's MAIL_ALLOWLIST silently skips any address outside it and returns as though it sent. Without an explicit outcome, testing this in QA against a contributor's real address looks exactly like success, which is the afternoon the issue warned would otherwise be wasted.

Writing it turned up one thing the design had assumed and the code does not support. sendMail returns Promise<void> and returns early both when SMTP is unconfigured and when the recipient is not allowlisted, so a caller cannot tell either from success. It gains a MailOutcome return value instead. No existing caller changes — there are seven and every one ignores the result — and the alternative would have duplicated isAllowedRecipient and the SMTP check at a second site, which is the drift the guard-in-one-place comment in mailer.ts exists to prevent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:28:45 -05:00
bermudalamb 5e8f0eabee Merge pull request 'fix(scripts): make the database the e2e suite reads a recorded fact (#273)' (#291) from fix/273-record-the-database into main
Linting / lint (push) Successful in 9m2s
SonarQube Analysis / sonarqube (push) Successful in 30m21s
Reviewed-on: #291
2026-09-03 16:59:16 -05:00
bermudalambandClaude Opus 5 4e2357356b fix(scripts): make the database the e2e suite reads a recorded fact (#273)
Linting / lint (pull_request) Successful in 7m20s
SonarQube Analysis / sonarqube (pull_request) Successful in 32m55s
The end-to-end helper opens its own connection to read a password-reset token, and nothing guaranteed it pointed at the database the application was actually using. With -E2eDb the app runs on redefined_e2e at 55501 while the helper kept its redefined_local default at 55500, so the app wrote to one database and the suite read another — and the specs failed for a reason that had nothing to do with them.

start-local.ps1 now records the coordinates it chose in .local/database.json, and run-tests.ps1 reads them into TEST_PGHOST, TEST_PGPORT, TEST_PGUSER, TEST_PGPASSWORD and TEST_PGDATABASE before Playwright starts. The answer now comes from one place, written by the thing that made the decision at the moment it made it.

It is written after Start-Database rather than before, so the file never names a database that failed to come up, and removed by -Stop, so a stopped stack does not leave a record pointing at a container that is gone.

Setting all five closes the second fault in the same change. Invoke-IntegrationSuite sets TEST_PGPORT and PowerShell keeps it for the rest of the session, so a -Suite all run leaked the integration port into the e2e run that followed — with none of the matching credentials, leaving the helper offering redefined_local's password to the integration database. Overwriting every one of them is what makes that leak harmless.

A missing record throws rather than falling back. A default is what produced both faults in the first place: always plausible, silently wrong, and it fails in ways that look like application bugs rather than configuration.

The guard test is the point of the change as much as the fix is. This is the third instance today of two files having to agree with nothing comparing them — #107 and #118 were envValidation against a compose file, #287 was the workflow against start-local.ps1, and this is three files rather than two. The test pins the whole chain: that the writer records the five settings, that the runner reads each of them from the record rather than a default, and that the helper reads no connection variable the runner does not set. Removing a single line from the runner fails four of its assertions, which was checked rather than assumed.

What it cannot do is run PowerShell, so these are text assertions against the scripts. That is weaker than executing them and still catches the drift that actually happened.

Closes #273

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:57:53 -05:00
bermudalamb 6b5f1fd5a3 Merge pull request 'fix(admin): let a setting whose default is empty actually be cleared (#280)' (#290) from fix/280-clearable-text-settings into main
Linting / lint (push) Successful in 10m0s
SonarQube Analysis / sonarqube (push) Successful in 37m17s
Reviewed-on: #290
2026-09-03 16:53:43 -05:00
bermudalambandClaude Opus 5 be6800181d fix(admin): let a setting whose default is empty actually be cleared (#280)
Linting / lint (pull_request) Successful in 8m44s
SonarQube Analysis / sonarqube (pull_request) Successful in 40m1s
intakeNotifyEmail and intakeCeilingResetAt both document empty as their default and as a working configuration — no notification address, and no ceiling reset recorded. The validator refused every empty text value, so either could be set and then never removed through the admin at all; the only way back was a DELETE against admin_settings. An admin who turned intake notifications on could not turn them off.

Whether empty is a mistake is a fact about the setting rather than about its type, so it is now declared on the setting, in the DEFINITIONS row that already carries its type and fallback. A new setting states it once, in the place someone adding one is already editing, and nothing else has to know. That is what makes this different from special-casing two names in the validator, which would have left the next such setting to rediscover the same bug.

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 at all, which reads as a broken email rather than as something a person cleared. Both those cases keep their tests.

Whitespace is normalised to empty rather than stored. Somebody clearing a field they cannot see the end of leaves spaces behind, and they meant cleared.

The tests check that the clearing survives the request rather than only being echoed back — the last one sets a value, clears it, and then reads it again through GET, which is the assertion that would have caught this had it existed.

Closes #280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:51:32 -05:00
bermudalamb 9cc1ac1918 Merge pull request 'fix(admin): answer 404 for an unreadable id instead of 500 (#207)' (#289) from fix/207-guard-every-admin-id into main
Linting / lint (push) Successful in 7m50s
SonarQube Analysis / sonarqube (push) Failing after 44m6s
Reviewed-on: #289
2026-09-03 16:47:32 -05:00
bermudalambandClaude Opus 5 d5e599a30e fix(admin): answer 404 for an unreadable id instead of 500 (#207)
Linting / lint (pull_request) Successful in 11m5s
SonarQube Analysis / sonarqube (pull_request) Successful in 38m13s
The issue asked for two things and only one had been done. A well-formed but absent id already answered 404 — the PUT route carries a comment saying so. A malformed one still reached Postgres as text, raised 22P02 on an integer column, and surfaced through the route's catch as a 500, telling the admin the server had broken when the truth is that no such item can exist. That half is now closed everywhere rather than on the three routes that happened to have it.

Guarded: DELETE an item, DELETE an image, unpublish, and every route in adminItemDrafts — publish, regenerate, discard, restore, and the two background-removal endpoints added by #281. The last of those were flagged in that feature's own final review as sharing this pre-existing shape, so they are fixed with the rest rather than left to be found again.

Routes carrying two ids guard both. A route can guard the first and forget the second, and the forgotten one fails exactly as loudly, so there is a case each way for both image endpoints and for DELETE image.

DELETE deliberately still answers 204 for a well-formed id that is absent. The method is idempotent and the caller's intent, that the item should not exist, is satisfied either way; what must not happen is a 500. There is a test pinning that so the distinction is a decision rather than an omission.

Also replaced the raw req.params.id and Number(req.params.id) uses that sat inside routes which had already computed a validated id. They were safe, because the guard above them made them safe, but a validated id and a raw one side by side in the same handler is how this bug comes back.

The test block named "a non-numeric id on every admin item route" covered two routes. It now covers every route that takes an id, which is what makes its name true.

Closes #207

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:45:51 -05:00
bermudalamb fe1ae64752 Merge pull request 'fix(ci): give the end-to-end backend a REMBG_URL so its controls render (#287)' (#288) from fix/287-ci-rembg-url into main
Linting / lint (push) Successful in 9m20s
SonarQube Analysis / sonarqube (push) Successful in 34m6s
Reviewed-on: #288
2026-09-03 16:37:31 -05:00
bermudalambandClaude Opus 5 0a300f3c4b fix(ci): give the end-to-end backend a REMBG_URL so its controls render (#287)
Linting / lint (pull_request) Successful in 6m44s
SonarQube Analysis / sonarqube (pull_request) Successful in 38m15s
main went red on 86095cb with "Playwright: 157 passed, 3 failed" while every other step in the job passed. The three are the background-removal cases from #281, and they fail for the most boring reason available: REMBG_URL appears nowhere in .gitea/workflows, so isRembgConfigured() is false, both endpoints report backgroundRemoval as false, and neither the submitter's checkbox nor the review queue's per-photo button is rendered for the tests to find. The application is right and the tests are right; the environment they run in was missing one variable.

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. Standing up a real rembg in CI would cost a 4.24 GB image and about forty seconds of startup to prove that a control is on screen.

How it got in is worth recording, because the missing line is the symptom. #281 added the variable to scripts/start-local.ps1 and stopped there, and its three end-to-end tests were merged unrun — verified locally against a stack that did have the variable, then handed over as outstanding rather than run before the merge. A test that has never been run is not evidence of anything, and two of the three would have failed on the first honest execution.

The deeper gap is that a variable which changes rendered behaviour now lives in two places nothing compares. composeEnvironment.test.ts exists for exactly this failure and caught it twice before, in #107 and #118, but it guards the deploying compose files rather than the CI workflow. Whether it should reach into .gitea/workflows is a decision rather than another hand-added line, and #287 leaves it open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:34:25 -05:00
bermudalamb 8e9d6ebdcc Merge pull request 'Feature/269 paginate catalogue' (#286) from feature/269-paginate-catalogue into main
Linting / lint (push) Successful in 4m5s
SonarQube Analysis / sonarqube (push) Failing after 40m13s
Reviewed-on: #286
2026-09-03 16:29:40 -05:00
bermudalambandClaude Opus 5 1bd0024e6f test(storefront): cover paging, and say what the filter tests meant (#269)
Linting / lint (pull_request) Successful in 3m17s
SonarQube Analysis / sonarqube (pull_request) Failing after 31m17s
Two assertions named a fixture and expected it visible in the unfiltered grid. No paginated catalogue can promise that — the item is on some page, not necessarily the first — so both would have started failing the moment paging landed. They were only ever proxies for "the result set got bigger", and the visible total lets them say that directly, which is what the issue predicted when it asked for a count.

The new cases assert the control and the URL rather than which item is on which page, because the development database never truncates and which item lands where is not something a test may rely on. That is the same trap the two rewritten assertions had fallen into, and repeating it in new tests would have been worse than leaving them alone.

Writing them found a real defect rather than just covering the feature. The control was rendering while the catalogue was still loading, showing "0 items" for a moment before the real count arrived — the empty-state early return only fires once loading has finished, so a mid-load render fell through to the grid branch with a total of zero. It is now suppressed until there is something to count, which is both true and what makes the count usable as a signal in a test. StorefrontPage.totalItems waits for the control for the same reason: reading during the load returned zero and quietly made "the result set shrank" compare against nothing.

The conditional skips carry a file-level eslint exception with its reasoning rather than being left to add four warnings. They are honest about a real limit: against a catalogue of ten items or fewer these cases prove nothing, and if the e2e database is ever seeded that thinly they need fixtures of their own instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:11:07 -05:00
bermudalambandClaude Opus 5 f9073c515e feat(storefront): page the catalogue instead of rendering all of it (#269)
The page number joins the filters in the URL, which is already the single source of truth for what the storefront is showing. That is the whole reason numbered pages were chosen over infinite scroll: a page is a place you can send someone, and a scroll position is not. The page size deliberately does not go there — it is a preference belonging to one person, and putting it in the URL would mean sharing a link to an item also imposed your page size on whoever opened it.

Changing a filter returns to page one, and it does so for free: filtersToSearchParams builds a fresh URLSearchParams, so applying filters drops the page parameter while goToPage copies the existing params and keeps the filters. That is behaviour worth having rather than an accident to tidy up — landing on page seven of a two-page result is a state a customer cannot get out of without understanding the URL.

The control carries the total, because showing the count was a requirement in its own right and the only count that existed before this was on the filter drawer's "Show N items" button, which is hidden whenever the drawer is closed. It is therefore shown even when everything fits on one page: hiding the control on a single page would hide the count with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:56:49 -05:00
bermudalambandClaude Opus 5 013b6abb52 feat(storefront): the rules for paging the catalogue (#269)
Every decision paging needs, as pure functions: which page a URL is asking for, which page is actually showable given how much there is, which slice of the items that is, and what page size to use. Pure because that is the only thing this project can unit-test — vitest runs in a node environment with no jsdom and no testing-library, so a hook or a component is only reachable through Playwright. Keeping the rules here means the rules have tests and the React wrapper stays thin enough not to need any, which is the same split filters.ts already uses for the URL.

An unrecognised page size is refused rather than clamped. A stored or hand-edited 5000 would render the entire catalogue in one page, which is the exact failure this issue exists to prevent, and clamping would quietly honour a value nobody offered. Storage access is guarded on both sides because localStorage is absent when there is no window and throws outright in some privacy modes, and neither is a reason for a customer to lose the catalogue — the worst acceptable outcome of a broken preference is the default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:45:32 -05:00
bermudalambandClaude Opus 5 48b456d1b8 docs(storefront): plan the catalogue pagination (#269)
Three tasks over the decisions recorded on the issue: the pure paging rules with their tests, the wiring into the catalogue, and the end-to-end cases including the two assertions this finally lets say what they mean.

Two decisions the issue left open are settled here. The page number goes in the URL beside the filters, because a numbered page being linkable is the whole reason numbered pages were chosen over infinite scroll, and the URL is already the single source of truth for what the storefront is showing. The page size deliberately does not, because it is a preference belonging to one person — putting it in the URL would mean sharing a link to an item also imposed your page size on whoever opened it. It lives in localStorage instead, which also keeps it inside the issue's own scope boundary of not touching the API.

The third open question, whether page size changes with viewport, is answered no and written down as such rather than left silent. A size that moved on rotation would fight the preference the customer had just set.

Everything testable is a pure function, because that is all this project can unit-test: vitest runs in a node environment with no jsdom and no testing-library, so a hook or a component is only reachable through Playwright. That is the same split filters.ts already uses, and it is why the React wrapper is thin enough to need no test of its own.

Writing it turned up one piece of luck worth not breaking: filtersToSearchParams builds a fresh URLSearchParams, so applying a filter already drops the page parameter and returns to page one, while paging copies the existing params and keeps the filters. The plan says so explicitly so nobody 'fixes' it later.

It also confirmed the issue's own warning. filters.spec.ts:165 asserts a named fixture is visible in the unfiltered grid, and favorites-filter.spec.ts:106 does the same — with ten items to a page over thousands, both would start failing the moment paging landed. They were always proxies for 'the result set got bigger', and a visible total lets them assert that directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:28:44 -05:00
bermudalamb 86095cb278 Merge pull request 'Feature/281 background removal plan' (#284) from feature/281-background-removal-plan into main
Linting / lint (push) Successful in 2m22s
SonarQube Analysis / sonarqube (push) Failing after 33m15s
Reviewed-on: #284
2026-09-03 14:09:50 -05:00
bermudalambandClaude Opus 5 6f8a0db130 test(integration): clean up background-removal test-harness leftovers (#281)
Linting / lint (pull_request) Successful in 2m43s
SonarQube Analysis / sonarqube (pull_request) Failing after 35m48s
backgroundRemoval.integration.test.ts exported seedSubmission for no reason — nothing imports it, since draftingBackgroundRemoval.integration.test.ts and adminItemDrafts.integration.test.ts each wrote their own seeding helpers. Dropped the export, kept the function for local use.

All three of these suites create a temporary uploads directory with mkdtemp and point UPLOADS_DIR at it, but none of them removed the directory afterward or restored the previous UPLOADS_DIR value — checked and the leak existed in all three, not just the one the review flagged. Each afterEach now removes its temp directory with fs.rm and restores (or deletes) UPLOADS_DIR to what it held before the test touched it, so this suite no longer leaves rubbish in the OS temp directory or a stale environment variable for whatever runs after it in the same process.

This is test scaffolding cleanup, not a feature change — no runtime path in the application deletes anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:43:25 -05:00
bermudalambandClaude Opus 5 66cafeb89c docs(compose): keep the prod comment block's sentences with their own variable (#281)
ANTHROPIC_API_KEY's explanatory paragraph already had a dangling continuation trailing after later entries. When REMBG_URL was added, its entry was inserted ahead of that continuation, so the file read as though "put a spend limit on the key in the Anthropic console" belonged to the background-removal sidecar rather than to Anthropic. This file is read during the cutover runbook, so a misattributed sentence there is not just cosmetic.

Reordered the comment lines so ANTHROPIC_API_KEY's full paragraph is contiguous and REMBG_URL's own two-line entry stands on its own at the end. No environment: line was touched — only the comment block above the services: section.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:43:07 -05:00
bermudalambandClaude Opus 5 3edcc7fcfa docs(intake): correct a false claim about cut-out durability (#281)
The module comment on backgroundRemoval.ts said "The original file stays on disk and so does every cut-out ever made." The original half is true and load-bearing; the cut-out half is not. cutoutPathFor is deterministic, so a photo that is restored and then cut out again overwrites the previous cut-out at the same path. Harmless — no original is ever touched — but the comment overstated what the module guarantees. Corrected it to say what is actually true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:42:58 -05:00
bermudalambandClaude Opus 5 dfd900aadd fix(admin): stop reporting every removeImageBackground failure as a sidecar failure (#281)
The remove-background route's catch block turned every throw from removeImageBackground into a 502 "the background-removal service did not answer". But removeImageBackground also throws for an unrecognised file extension (a legacy .jpeg), for a file missing from the uploads volume, and when REMBG_URL is not set at all — none of which involve contacting the sidecar. The admin was told to retry a service that was never reached, while the real reason existed only in the server log.

Added SidecarRequestError in rembgClient.ts, following the NoOriginalToRestoreError pattern already in backgroundRemoval.ts. It is thrown only for failures that happen after actually attempting to reach the sidecar: the fetch call itself throwing (now wrapped in a try/catch, covering unreachable and timed-out), a non-2xx response, or a response that is not a PNG. It is deliberately not thrown for "REMBG_URL is not set", since that path never attempts contact at all.

The remove-background handler now checks err instanceof SidecarRequestError before answering 502; everything else answers 500 with a message that says what actually went wrong.

Added a unit test pairing (rembgClient.test.ts) asserting the sidecar-contacted failures are SidecarRequestError and the unconfigured case is not, and an integration test (adminItemDrafts.integration.test.ts) proving a missing upload file answers something other than 502 with a message that does not claim the service did not answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:42:47 -05:00
bermudalambandClaude Opus 5 b06eac4640 fix(intake): clear remove_background when an admin restores a photo (#281)
item_drafts.remove_background is written once at intake and never updated afterward. Restoring a photo clears original_image_path, which is exactly what makes the row look "never cut out" to removeImageBackground — so a submitter's ticked checkbox, followed by the worker cutting the photo out, followed by an admin restoring a poor result, followed by a click on Regenerate, would silently re-cut the same photo the admin had just put back. Nothing was lost, but the control the design calls "what makes a poor result survivable" was quietly defeated by the button sitting next to it.

restoreImageOriginal now swaps the image's paths back and clears item_drafts.remove_background for that item in one transaction, so a restore that succeeds while the flag update fails cannot reintroduce the bug. An admin restoring any photo on an item is treated as overriding the submitter's original request for the whole item — the flag is per-item while the swap is per-photo, so there is no narrower place to record the decision, and turning off the whole item's auto-removal is the conservative direction: the alternative is re-cutting something a person deliberately undid.

Added an integration test in draftingBackgroundRemoval.integration.test.ts that drafts a submission with the intent set, cuts it out, restores it, mirrors what the admin's Regenerate button does (state back to queued, attempts cleared), runs the worker again, and asserts the photo is still not cut out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:42:24 -05:00
bermudalambandClaude Opus 5 786996b7ac fix(admin): keep Restore original working after REMBG_URL is unset (#281)
The DraftQueue background-removal control gated both "Remove background" and "Restore original" on the same `backgroundRemoval` flag, which only reflects whether a sidecar is currently configured. Restoring is a pure database swap and never calls the sidecar, so once photos had already been cut out and REMBG_URL was later removed from the stack, the admin was left looking at a cut-out photo with no control at all and no way back to the original short of a hand-written SQL UPDATE — directly breaking the "the original is always restorable" invariant the feature is built on.

DraftCard now computes `enabled` per photo as `backgroundRemoval || image.original_image_path !== null`, so Restore original stays available whenever a photo has an original regardless of whether the sidecar is configured, while Remove background still requires a configured sidecar. Also corrected the docstring on `DraftQueueResponse.backgroundRemoval` in draftsApi.ts, which claimed the flag hides "the control" generically — it only ever governed the remove-background control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:41:55 -05:00
bermudalambandClaude Opus 5 09686d9f94 feat(admin): remove or restore a photo's background from the review queue (#281)
Adds the per-photo control that closes out the background-removal feature: each photo in the review queue now gets a "Remove background" or "Restore original" button, whichever matches its current state, and the button only appears when the server reports a sidecar is configured. The label is read from original_image_path alone rather than a second flag, so there is nothing that could disagree with what the button actually does.

draftsApi.ts's fetchDrafts now returns { drafts, backgroundRemoval } instead of a bare Draft[], matching the breaking change Task 6 made to GET /api/admin/item-drafts. DraftImage gains original_image_path, and a new setImageBackground(itemId, imageId, action) posts to the remove-background/restore-original endpoints, preferring the server's error message the same way publishDraft does.

Also updates docs/ops/image-background-removal-stack.md: the status line no longer says "evaluated, not adopted", since the feature is adopted here, and the closing "If this is adopted" section is replaced with "How the application uses it", describing the two real entry points (the drafting worker's default-on checkbox, and this per-photo control) and confirming that nothing in the feature deletes a file or a row.

Adds an e2e case asserting the button's label appears on a freshly submitted item's card, scoped to that card by the sender's note per #241. It is unrun in this environment — the local stack was not started, per standing instruction not to run start-local.ps1 or Playwright without the user's supervision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:14:06 -05:00
bermudalambandClaude Opus 5 1902db6d04 feat(intake): offer background removal on the submission page, ticked (#281)
Adds a checkbox to the public submission page that lets a sender opt out of background removal, ticked by default because most items look better cut out and the reverse default would mean almost nobody got it. It only renders when the server reports the sidecar is configured, matching the intake link's new backgroundRemoval flag from Task 5 — an unconfigured environment gets no checkbox rather than one that would do nothing.

submitItem now takes removeBackground as a required fourth parameter, sent as the multipart string 'true' or 'false' to match the backend's exact-string opt-out contract. Making the parameter required rather than optional was deliberate, so the compiler would catch any call site left unupdated; the frontend build (which also type-checks tests/ via tsconfig.test.json) confirmed the only call site, in Submit.tsx, was updated.

scripts/start-local.ps1 now sets REMBG_URL for the local backend so the checkbox is visible during local and e2e runs; the value need not resolve, since no e2e submission reaches the sidecar without a configured drafting step.

Adds two e2e cases to intake-submit.spec.ts: the checkbox appears ticked by default, and a sender can uncheck it and still submit successfully. Both are written per the task-7 brief but not run in this session, since running Playwright requires the full local stack (database, backend, frontend dev server) which was not started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:07:17 -05:00
bermudalambandClaude Opus 5 984e329c47 fix(admin): narrow the restore-original catch to the race it exists for (#281)
Round 1 caught every throw from restoreImageOriginal() and reported it as a 404, on the theory that losing the concurrent-restore race is the only way that call fails. But the throw carried nothing to distinguish that race from a genuinely different failure during the same UPDATE — a dropped database connection, a transient outage — so a real failure was now silently reinterpreted as "someone already restored this" instead of surfacing as the loud 500 it was before.

backend/src/intake/backgroundRemoval.ts now exports NoOriginalToRestoreError, a named subclass of Error thrown in place of the bare Error restoreImageOriginal previously threw. The message text is unchanged, so backgroundRemoval.integration.test.ts's rejects.toThrow(/no original/) assertion keeps passing without modification.

backend/src/routes/adminItemDrafts.ts catches that class specifically in the restore-original handler and rethrows anything else, so a real failure still reaches the app-level error handler and comes back as a 500 instead of being mislabeled as "already done".

backend/tests/integration/adminItemDrafts.integration.test.ts adds a test that spies on restoreImageOriginal via jest.spyOn on the module namespace (the project compiles to CommonJS, so the route's call site reads the export off that object at call time, which makes the spy effective without jest.mock) to reject once with a plain Error, and asserts the response is 500 rather than 404 — proving the narrowing changes real behavior, not just internal structure. The spy is restored in a finally block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:01:36 -05:00
bermudalambandClaude Opus 5 5129112266 fix(admin): answer 404, not 500, when two restores race (#281)
restore-original's precheck (existing.original_image_path === null) and restoreImageOriginal's own guard (WHERE ... AND original_image_path IS NOT NULL) could disagree under a race: two concurrent restores, or a rapid double-click, could both pass the precheck before either commits, and the loser's UPDATE would then match zero rows and throw. The handler had no try/catch around that call, so the throw propagated through asyncRoute to the app-level error handler and the caller got a bare 500, breaking the route's documented 200 | 404 contract even though the row itself was left correct.

Wraps the restoreImageOriginal call in a try/catch, matching the shape remove-background already uses in this file, but answering 404 rather than 502: losing this race means another admin already finished the restore, not that a downstream service failed. Adds a comment on the catch explaining why it exists, and a test that fires two restores concurrently and asserts neither comes back 500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:56:15 -05:00
bermudalambandClaude Opus 5 9ced34ad19 feat(admin): remove and restore a photo's background per image (#281)
Adds two admin-gated endpoints on the review queue router: POST /:itemId/images/:imageId/remove-background and POST /:itemId/images/:imageId/restore-original. Both run synchronously and reuse the same backgroundRemoval module the drafting worker uses, so a cut-out obtained either way is identical and either can be undone by Restore.

Ownership is scoped by item as well as by image (imageOfItem selects on id AND item_id), because the image id is a serial and guessing one is easy — a photo belonging to a different submission must not be reachable through another item's URL. A sidecar failure returns 502, not 500, and leaves the row untouched, since removeImageBackground only writes the row after the cut-out file already exists on disk.

GET /api/admin/item-drafts now returns { drafts, backgroundRemoval } instead of { drafts }, and each image in the payload gains original_image_path, which is what the review queue UI will use to decide between "Remove background" and "Restore original". DRAFT_SELECT's images aggregate is extended accordingly, keeping the deliberate column spelling that guards against the upload_links token digest leaking into the response.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:49:38 -05:00
bermudalambandClaude Opus 5 885a78c572 feat(intake): record whether the submitter asked for a cut-out (#281)
The public intake POST now reads a `removeBackground` multipart field and stores it on the new `item_drafts.remove_background` column. The checkbox on the submission page is ticked by default, so a client that sends nothing gets `true` — only the exact string `'false'` opts out, so a stray or unexpected value is treated as consent rather than a silent refusal.

The GET now also reports `backgroundRemoval: isRembgConfigured()` alongside the label, so the submission page knows up front whether the feature exists in this environment at all. Neither handler calls the sidecar or the AI — this task only records intent for the drafting worker to act on later, and the existing ordering of `requireUsableLink` and `requireCapacity` ahead of `uploadImages` is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:44:20 -05:00
bermudalambandClaude Opus 5 81524a2849 feat(intake): cut out backgrounds in the worker, never in the upload (#281)
Wires removeBackgroundsForItem into draftQueued, gated on the submitter's remove_background intent recorded on item_drafts. The step runs after the draft is committed and catches for itself, so an unreachable or erroring sidecar never turns a draft that was written correctly into a failed one — the photo simply keeps its original, and the admin's per-photo control in the review queue is still there to do it by hand. It is awaited, unlike the notification below it, so a sweep that has returned has finished its work; nothing on the request path waits on it.

Adds backend/tests/integration/draftingBackgroundRemoval.integration.test.ts as a new file rather than extending drafting.integration.test.ts, because that suite has never produced a successful draft and therefore has no draftListing mock — adding one there would be file-wide and would change what its existing tests exercise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:38:49 -05:00
bermudalambandClaude Opus 5 8d12cb2f2d feat(intake): swap a photo for a cut-out, keeping the original (#281)
Adds backend/src/intake/backgroundRemoval.ts, the shared module the drafting worker and the admin endpoints both call so a cut-out obtained either way is undoable the same way.

cutoutPathFor is pure and writes a new file beside the original rather than overwriting it, which is what keeps the original restorable and makes the JPEG-to-PNG change free. removeImageBackground only points the row at the new file after it is already on disk, and is idempotent via the original_image_path IS NOT NULL check — load-bearing twice, since it also stops a second pass from recording the cut-out as the original and losing the real one for good. restoreImageOriginal swaps the paths back and deliberately leaves the cut-out file on disk.

Extends the Task 1 integration test file with a stub sidecar bound to an ephemeral port and covers the no-op-on-repeat case plus three failure modes (500, non-image body, unreachable), asserting the row is left untouched in every failure case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:34:56 -05:00
bermudalambandClaude Opus 5 61e9c239d3 feat(intake): talk to the rembg sidecar, always naming u2net (#281)
Adds the client that will let the intake path remove backgrounds from submitted photos via the rembg sidecar over HTTP. isRembgConfigured() reports whether REMBG_URL is set (unconfigured is a normal, working state, not a failure), and removeBackground() posts a file to /api/remove and resolves with the PNG bytes it gets back, rejecting on every failure — unconfigured, unreachable, a non-2xx response, or a body that fails the same magic-byte PNG check the upload path already uses.

The one hard rule: every request names model=u2net explicitly and this is never configurable. The sidecar's default model, reached simply by omitting the parameter, is bria-rmbg, which is licensed non-commercial — a licensing problem that a shop cannot silently ship, and one that would produce a perfectly good image with nothing in it to reveal the mistake. The test that posts against a real stub HTTP server and asserts model=u2net appears on the wire is the only thing guarding against that regressing.

Wires REMBG_URL into both docker-compose.qa.yml and docker-compose.prod.yml as an optional variable, right after ANTHROPIC_WORKSPACE_ID, following the existing style in each file's environment block and header comment. It is deliberately left out of envValidation.ts's ALWAYS_REQUIRED — requiring it would make an environment with no sidecar refuse to boot, which is exactly the failure mode this feature is designed to avoid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:22:43 -05:00
bermudalambandClaude Opus 5 534e3d6228 feat(intake): record the background-removal intent and the original path (#281)
Adds the two columns the background-removal feature (#281) is built on: item_drafts.remove_background (boolean, not null, default true) records the submitter's per-submission intent, and item_images.original_image_path (nullable text, no default) records where a cut-out photo came from so it can be restored. The default on remove_background is load-bearing — any row written by a path that does not mention the column behaves like the new default, so no backfill is needed. original_image_path stays null until a photo has actually been cut out, which doubles as the answer to "can this be restored?" rather than needing a separate flag. Also updates the Drizzle mirror in src/db-drizzle/schema.ts by hand (the local dev database was not running to re-pull from) and adds the integration test backgroundRemoval.integration.test.ts, including the exported seedSubmission helper that Task 3 will reuse.

Closes #281

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:15:38 -05:00
bermudalambandClaude Opus 5 1c265840a9 docs(intake): plan the background-removal implementation (#281)
Eight tasks over the approved design, each ending in something independently testable: the two columns, the sidecar client, the shared swap-and-restore, the worker step, the intake route, the admin endpoints, the submitter's checkbox, and the review queue's per-photo control.

Three things the plan pins down that the spec left to implementation.

The `model=u2net` assertion lives in a unit test against a real stub HTTP server rather than a mocked fetch, because what has to be checked is the shape of the request that reaches the wire. Nothing in the returned image would reveal that the non-commercial default had been used, so that assertion is the only thing standing between this and a licensing problem that produces perfectly good pictures.

Removal in the worker follows drafting rather than running on its own pass, which couples the two: an environment with no ANTHROPIC_API_KEY drafts nothing and so cuts out nothing. That is the deliberate trade — a separate pass would re-attempt an unreachable sidecar on every five-minute sweep for a row that is going to sit at `queued` indefinitely — and the plan says so in the worker's own header comment rather than leaving it to be rediscovered.

`removeImageBackground` is idempotent through the `original_image_path IS NOT NULL` check rather than a separate flag, and that guard is load-bearing twice: it makes a repeat call a no-op, and it stops a second pass recording the cut-out as the original and losing the real one for good.

Writing it turned up two things worth knowing about the existing tests. `drafting.integration.test.ts` has never produced a successful draft — every case in it either has no key or no readable photo — so the worker's new cases need their own file with `draftListing` mocked, rather than a mock added file-wide to a suite that deliberately never reaches the model. And `adminItemDrafts.integration.test.ts` calls `request(app)` directly with no helper, so the plan spells out the seed it needs instead of pointing at one that does not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 11:18:50 -05:00
bermudalamb ddde51c14e Merge pull request 'docs(intake): design background removal for submitted photos (#281)' (#283) from feature/281-background-removal into main
Linting / lint (push) Successful in 2m5s
SonarQube Analysis / sonarqube (push) Successful in 29m10s
Reviewed-on: #283
2026-09-03 10:51:57 -05:00
bermudalambandClaude Opus 5 196d83a56e docs(intake): design background removal for submitted photos (#281)
The design behind #281, written before any code so the decisions can be argued with while they are still cheap to change. The implementation follows on this branch.

Six decisions, each recorded with what it rests on rather than just what it is. The one that matters most is that `model=u2net` goes on every request: the sidecar's default is `bria-rmbg`, which is licensed non-commercial, and it is reached by simply not specifying a model — a silent licensing problem that produces a perfectly good image. A test asserts the parameter is present, because nothing in the output would reveal its absence.

The other consequential one is that the submitter's tick records an intent rather than doing the work during their upload. Inline removal would make them 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. Recording the intent means the submission always succeeds and keeps its original photo, and the cut-out arrives with the AI draft seconds later.

Everything else follows the rule the pipeline already runs on: a submission is the only irreplaceable thing here. The original is never destroyed, every failure path leaves the photo exactly as it was, and an unset REMBG_URL means the feature simply does not exist rather than that the environment is broken.

Documents what is not established too — quality on a real photograph is unknown, because the engine evaluation used a generated rectangle on a flat ground. The per-photo control and Restore original are what make a poor result survivable rather than something to prevent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:51:57 -05:00
bermudalamb 927625fe4d Merge pull request 'chore: clear the six code smells SonarQube reported (#181)' (#282) from chore/181-clear-the-six-smells into main
Linting / lint (push) Successful in 2m31s
SonarQube Analysis / sonarqube (push) Successful in 32m28s
Reviewed-on: #282
2026-09-03 10:50:49 -05:00
bermudalambandClaude Opus 5 cd2676233c chore: clear the six code smells SonarQube reported (#181)
Linting / lint (pull_request) Successful in 2m47s
SonarQube Analysis / sonarqube (pull_request) Failing after 55m42s
The list finally arrived from the reporting added earlier, and confirmed what #181 could only suspect: these are not the five eslint-plugin-sonarjs warnings that issue lists. Those were fixed under #261 and the count staying at five was a coincidence. It is six now, 25 minutes of debt, and one of them was mine.

admin.ts imported '../utils' twice — I added readId in #207 without noticing the file already imported from there. One import now.

filters.ts had a redundant `as ItemStatus[]`. TypeScript narrows an array through `.every()` with a type predicate from 5.5, and this project is on 5.9, so the assertion stopped telling the compiler anything. Removed, and the build confirms the narrowing holds without it.

adminSettings.ts was the only CRITICAL: cognitive complexity 18 against a limit of 15, almost all of it three near-identical loops differing only in how they validated. Each validation is now a small pure reader returning a refusal rather than sending one, and the handler is one loop over a table. Adding a setting type means adding a row.

That refactor is deliberately behaviour-preserving. Two things were left alone on purpose: the blanket rejection of empty text, which is wrong for the two settings whose documented default is empty and is filed as #280 rather than folded in where it would be invisible; and the absence of the `count` settings, which no caller submits and which the admin screen has no control for. I had started adding count validation and reverted it — widening behaviour under cover of a complexity fix is how a refactor stops being reviewable.

The three S6478s are render props, not components defined during render. ErrorBoundary's `fallback` is typed `(error: Error) => React.ReactNode` and called as `this.props.fallback(...)`, so React only ever sees returned elements and never a new component type — the subtree destruction the rule describes does not happen, and the rule's own message offers `allowAsProps` for this shape, which cannot be set from here. Hoisted rather than suppressed because none of them closes over anything local, so at module level each is one stable function instead of a new closure per render. That is a mild improvement, not a contortion.

Verified: 402 backend unit, 358 backend integration, 30 frontend unit, 157 e2e, both lints clean, both builds clean. The e2e run matters most here — storefront-errors.spec.ts exercises all three hoisted fallbacks, and it was run on its own first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 09:54:47 -05:00
bermudalamb e36e61880e Merge pull request 'docs(ops): setup notes for the rembg background-removal sidecar' (#279) from docs/background-removal-stack into main
Linting / lint (push) Successful in 2m28s
SonarQube Analysis / sonarqube (push) Successful in 29m2s
Reviewed-on: #279
2026-09-03 09:05:45 -05:00
bermudalambandClaude Opus 5 f8cfd93b0c docs(ops): setup notes for the rembg background-removal sidecar
Linting / lint (pull_request) Successful in 2m37s
SonarQube Analysis / sonarqube (pull_request) Failing after 56m35s
Measured rather than described. Everything in here was run against danielgatis/rembg:latest on 2026-09-02 and the numbers are from those runs, with the caveat recorded that a dev box under Docker Desktop is not the NAS.

The finding that matters is the default model. This image downloads bria-rmbg on first use, and BRIA's RMBG models are licensed for non-commercial use — the same trap that ruled out @imgly/background-removal-node, reached silently by making one request. Sending model=u2net explicitly is both the licensing answer and ten times faster: 1.1-2.3s against 14-20s for a 2000x1500 image, and 168MB on disk against 977MB. That speed difference decides the interaction on its own, because a click can wait two seconds and cannot wait twenty.

Also records the volume path, which most documentation online gets wrong for this image: models land in /root/.rembg, not /root/.u2net, and mounting the old path silently re-downloads 168MB on every start.

The output was verified rather than assumed — PNG, four channels, alpha spanning 0 to 255, dimensions preserved, 1.7x the input JPEG's bytes.

Marked as evaluated rather than adopted, since the engine choice is still open, and the three things not established are listed: quality on a real photograph, behaviour on the NAS, and concurrency. The test input was a generated rectangle on a flat ground, which says nothing about a chipped vase on a patterned rug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 08:23:05 -05:00
bermudalamb b260d20d37 Merge pull request 'test(integration): say so when the database loses its schema (#154)' (#278) from fix/154-name-the-schema-loss into main
Linting / lint (push) Successful in 2m11s
SonarQube Analysis / sonarqube (push) Successful in 26m59s
Reviewed-on: #278
2026-09-02 18:43:54 -05:00
bermudalambandClaude Opus 5 813216f971 test(integration): say so when the database loses its schema (#154)
When the integration run lost its schema partway through, it presented as 36 assertion errors about categories, price filters and favourite notifications. The real message — `relation "items" does not exist` — was further down the same log, and hours went into chasing the assertions instead.

The cause is still open and needs runner-side evidence this cannot reach: whether the Postgres service container is being recreated mid-run, which would come back with an empty data directory. This is the half that can be fixed from here — whatever the cause, the next occurrence reads as "the database lost its schema" on the first line, names which tables are gone, and says that nothing in the suite drops tables so the database was replaced underneath the run.

globalSetup asserts once after migrating, which establishes the fact the rest of the run depends on. Without it, a run that never had a schema and one that lost it midway are indistinguishable from the failures they produce.

resetDb checks only when its TRUNCATE fails, rather than on every reset. It runs in a beforeEach several hundred times a suite, and an extra round trip each time to guard against a rare event would be paying continuously for it. When the schema is fine, an unrelated failure is passed through untouched rather than dressed up as a schema problem.

The diagnostic is tested against a database that has actually lost its schema, not reasoned about. An earlier attempt dropped the schema before the run and proved nothing — globalSetup re-migrates, so it repaired itself and every test passed. The suite drops and rebuilds around each case and restores in afterAll; the full integration suite was then run twice to confirm the restore holds for everything ordered after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 18:43:54 -05:00
bermudalamb b35e858092 Merge pull request 'feat(build): pass the commit to the image as a build arg (#248)' (#277) from feat/248-commit-build-arg into main
Linting / lint (push) Successful in 2m31s
SonarQube Analysis / sonarqube (push) Successful in 26m34s
Reviewed-on: #277
2026-09-02 18:37:02 -05:00
bermudalambandClaude Opus 5 a02214108a feat(build): pass the commit to the image as a build arg (#248)
Linting / lint (pull_request) Successful in 2m19s
SonarQube Analysis / sonarqube (pull_request) Successful in 25m44s
The admin version stamp has reported commit "unknown" everywhere. #233 read it out of .git during the build and #235 removed that, because Portainer's build context has no repository history and the COPY failed every deploy — the version stamp became the thing that stopped deployments. #237 then established that building in Gitea Actions does not help: the Dockerfile no longer copies .git, so where the build runs is irrelevant.

So the builder hands the commit over rather than the build going to look for it. ARG GIT_COMMIT, empty by default, passed through to writeBuildInfo, which prefers it and still falls back to reading .git so a local build stamps itself with no argument needed.

An empty value is treated as absent rather than stamped. `--build-arg GIT_COMMIT=` is what an unset shell variable expands to, and a blank commit reads as one that happens to be empty rather than one nobody supplied.

Nothing regresses for Portainer. It cannot pass the argument, so its images keep saying "unknown" exactly as today, and they still deploy — the property #235 was bought with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 17:39:24 -05:00
bermudalamb e19561cde6 Merge pull request 'Fix/271 anthropic workspace id' (#276) from fix/271-anthropic-workspace-id into main
Linting / lint (push) Successful in 2m19s
SonarQube Analysis / sonarqube (push) Successful in 26m54s
Reviewed-on: #276
2026-09-02 17:25:03 -05:00
bermudalambandClaude Opus 5 baa5bb4fdf fix(intake): wake the worker when a draft is regenerated (#272)
Regenerate set state='queued', cleared attempts and ai_error, and answered 200 — then nothing ran the worker, so the row sat until the five-minute sweeper happened along. From the admin's side that is indistinguishable from a dead button, and the obvious response is to press it again.

The submission path has kicked the worker since #223. Both paths put a row into 'queued'; only one asked for it to be drafted. That was an oversight in #225 rather than a decision: the kick was added to the submission path in a later task and the action routes were never revisited.

Fixed in both places that re-queue — the admin route and the signed regenerate link from the notification email, which sets the identical state. Fire and forget with a logged catch, exactly as the submission path does: a slow or failing model call must not become a failed request for the admin, and the sweeper is still the backstop if the kick misses.

The card still does not update itself once the draft lands, since drafting takes a few seconds and the screen has no way to know it finished. That is a UI question rather than this bug, and is noted on #272.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 17:25:03 -05:00
bermudalambandClaude Opus 5 6c6aaa46eb fix(intake): send the workspace id an identity-linked key requires (#271)
Every draft in QA failed with a 400: "anthropic-workspace-id is required when authenticating with an identity-linked API key". A key issued against a workspace, rather than standing alone, is refused unless the request names the workspace it acts in — and the client was constructed with an API key and nothing else.

Nothing about a key's shape says which kind it is, so no amount of configuration checking would have caught this. Only a real call would, which is exactly what #223's task 8 existed to make.

Sent only when ANTHROPIC_WORKSPACE_ID is set. Plenty of keys need no workspace, and sending an empty header would turn the ordinary case into a different error rather than leaving it working. Both compose files carry it with an empty default so an unset variable cannot fail a deploy, and the cutover doc goes from fifteen interpolated names to sixteen — checked against the file, and every name in the list now matches one in the compose.

The failure handling needed no change and got none. The submission kept its photos, the draft recorded ai_error, and the review queue showed the reason. A model call failing must never lose somebody's consignment, and it did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 17:25:03 -05:00
bermudalamb e69bd1ef93 Merge pull request 'chore(sonar): name the findings instead of counting them (#181)' (#275) from chore/181-name-the-sonar-findings into main
Linting / lint (push) Successful in 2m13s
SonarQube Analysis / sonarqube (push) Successful in 27m27s
Reviewed-on: #275
2026-09-02 17:21:44 -05:00
bermudalambandClaude Opus 5 d75c45cf91 chore(sonar): name the findings instead of counting them (#181)
The measures step reported "Quality gate ERROR" and "Code smells 5", which is enough to notice debt and useless for clearing it. It now also prints the failing gate conditions, the open issues with their rule, file, line and effort, and the security hotspots awaiting review.

#181 is why this matters. It assumed the five smells were the five eslint-plugin-sonarjs warnings, on the strength of the counts matching, and hedged that the server's rule set is not the plugin's. The hedge was right: those five warnings were fixed under #261, both workspaces lint at zero, and the analysis of the #261 merge still reported five smells and 24 minutes. They are a different five, and nothing short of the list settles which.

The gate condition list matters for the same reason. "ERROR" sends a reader to a dashboard, which is the thing this script exists to avoid needing.

Issues are capped at 25 rather than paged: past a couple of dozen the answer is not "read the list", and an unbounded fetch on every CI run is a cost with no reader.

Verified against a stub SonarQube serving canned responses, so the parsing and formatting are exercised rather than reasoned about — failing conditions filtered from passing and NO_VALUE ones, components stripped of their project-key prefix, absent metrics rendered as a dash, and every path still exiting 0. The no-server and unreachable-server paths were exercised too; the step still cannot fail the job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 17:21:44 -05:00
bermudalamb 15beda1f0b Merge pull request 'fix(scripts): refuse to reuse a backend that is serving the previous database (#257)' (#274) from fix/257-stale-backend-guard into main
Linting / lint (push) Successful in 2m7s
SonarQube Analysis / sonarqube (push) Successful in 27m29s
Reviewed-on: #274
2026-09-02 17:11:36 -05:00
bermudalambandClaude Opus 5 175d21d11c fix(scripts): refuse to reuse a backend that is serving the previous database (#257)
Linting / lint (pull_request) Successful in 2m13s
SonarQube Analysis / sonarqube (pull_request) Successful in 26m47s
start-local.ps1 said "something is already listening on 3000; leaving it alone" and carried on. That is safe only while the database has not changed underneath that process. When it has, the old backend is serving a database it no longer owns and its in-memory state describes rows that no longer exist.

The rate limiter is the sharpest example. It keys on customer id and keeps buckets in memory for an hour, so a recreated database restarting ids at 1 hands a brand-new customer a previous run's spent allowance. That is #257: the resend-verification allowance test failing roughly one full run in three, never in isolation, with three refusal toasts where one was expected.

Reproduced deterministically rather than reasoned about. Fresh database and fresh backend: three of three pass. Recreate the database only, leaving the same backend running: the same test fails with exactly the reported "resolved to 3 elements". Control — recreate the ids again but restart the backend as well: passes. So the variable is the process outliving the database, not the id restart on its own.

That also explains why #257 could not find the mechanism. It had ruled out contention, a mis-keyed limiter, a shared fixture and identity reuse in the test helpers, all correctly. The recycling happens outside the suite entirely, in a process the suite never sees.

Now it refuses, names the reason, and says to run -Stop. A run that stops loudly is recoverable; one that quietly tests the wrong thing is not — and a stale listener on 3000 has already produced two wrong measurements in this project, a rate-limiter reading and an e2e run reported as 23 passed when the backend was talking to a deleted database.

DatabaseIsNew is set when -Fresh removes the container, when the container is created, and always under -E2eDb, whose tmpfs storage means it comes up empty whether created or restarted.

Verified by AST-parsing the script and confirming every reference to the flag is script-scoped — a function-local read would see $null and the guard would never fire. The script is deliberately never executed from an agent shell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:13:02 -05:00
bermudalamb 93f05d06db Merge pull request 'test(e2e): give the suite a throwaway database of its own (#186)' (#270) from fix/186-disposable-e2e-database into main
Linting / lint (push) Successful in 1m57s
SonarQube Analysis / sonarqube (push) Successful in 26m22s
Reviewed-on: #270
2026-09-02 11:06:04 -05:00
bermudalambandClaude Opus 5 6a90e957f2 test(e2e): give the suite a throwaway database of its own (#186)
Linting / lint (pull_request) Successful in 2m33s
SonarQube Analysis / sonarqube (pull_request) Successful in 28m49s
The e2e suite ran against the development database and nothing truncated it. Every run seeded more fixtures and left them, so the unfiltered storefront grew monotonically — 1,662 items by the time #186 was filed — until rendering it outran the assertions' timeouts. It failed locally, passed in CI where the database is fresh, and got steadily worse, which is the combination nobody can act on.

start-local.ps1 -E2eDb runs the stack against a separate container on a separate port with tmpfs storage, so it starts empty every time. Migrations already run on every start, so an empty volume is a working one. The development database is untouched, so anything set up there by hand survives.

Deliberately a third database rather than sharing either existing one. The integration suite truncates between tests, so an e2e run sharing with it would have its fixtures deleted underneath it (#116) — different container, different port, different credentials, so the mistake is impossible rather than discouraged.

Verified by recreating the database from the compose file, confirming it came up with zero items, and running the full suite against it: 157 of 157. An earlier attempt at this reported 23 passed and 53 not run, which was worthless — a stale backend from a previous run still held port 3000 and was talking to a database I had already deleted. The port is checked before the run now, and the same mistake produced a wrong rate-limiter measurement earlier in this work.

Pagination is the other half of #186 and is filed separately: a shop that renders its whole catalogue in one page is worth fixing on its own merits, not as a side effect of a test fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 10:57:02 -05:00
bermudalamb 527c0a6417 Merge pull request 'test(uploads): stop the cleanup assertions racing the cleanup (#228)' (#268) from fix/228-upload-cleanup-race into main
Linting / lint (push) Failing after 0s
SonarQube Analysis / sonarqube (push) Failing after 1s
Reviewed-on: #268
2026-09-02 10:00:28 -05:00
bermudalambandClaude Opus 5 2b8be434c0 test(uploads): stop the cleanup assertions racing the cleanup (#228)
Linting / lint (pull_request) Failing after 1s
SonarQube Analysis / sonarqube (pull_request) Failing after 0s
discardUnlessAccepted unlinks from a res.on('close') handler, so nothing awaits it and nothing can. `await request()` resolves when the response completes, which is when close fires — so a read taken straight afterwards races the unlink it is meant to observe. The property is an eventual one and the assertions were synchronous.

The issue counted three tests. Injecting a 400ms delay into the unlink to make the race deterministic showed five: "leaves nothing on the volume when it refuses the content" and "discards the valid files from a request that also carried an invalid one" race too, and are not in the describe block the issue named.

It also showed that polling alone is not enough. The race runs in both directions, and the second direction is easy to miss: a deletion still pending from the *previous* test corrupts the next test's baseline before its request is even sent. No amount of waiting fixes a baseline that is already wrong. My first attempt waited for the directory to look quiet, which only works while the unlink is faster than the wait — precisely the assumption this issue is about, and it still failed four tests under the injected delay.

So the baseline is removed as a variable: beforeEach empties the directory, every test starts from empty, and the assertions poll for the expected count. Any orphan from an earlier suite goes with it, which is correct — the directory is temporary and nothing outside these tests owns it. discardUploads already catches per-file errors, so a pending unlink finding its file gone logs and moves on.

Verified by injecting the 400ms delay again: four to five failures before, twelve passing after, with the production file restored untouched.

Closes #228

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 09:45:44 -05:00
bermudalamb c40df1b6c2 Merge pull request 'Fix/197 restrict scan publishing' (#267) from fix/197-restrict-scan-publishing into main
Linting / lint (push) Successful in 2m15s
SonarQube Analysis / sonarqube (push) Failing after 5m30s
Reviewed-on: #267
2026-09-02 09:33:09 -05:00
bermudalambandClaude Opus 5 508eac715b docs(db): record the migrations decision (#219)
node-pg-migrate keeps the schema; Drizzle is for queries only. The conventions doc said this was unsettled and now says what was settled and why: drizzle-kit cannot diff expression indexes and emitted six statements for one column, our migrations are mostly prose that generated SQL does not carry, and data migrations cannot be generated at all.

The reasoning in full is on the issue. This is the version a reader converting a query will actually find.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 09:33:09 -05:00
bermudalambandClaude Opus 5 7ef3553744 docs(db): record the two traps the first conversion found (#218)
Columns in a sql template render unqualified, so a correlated subquery correlates with itself and returns a plausible wrong number rather than failing. That is worse than the array trap already recorded here, which at least produces invalid SQL — this produces valid SQL and quietly wrong data, and only an integration test asserting a value caught it.

And a driver error code moves when Drizzle wraps it, so a catch keyed on a SQLSTATE still compiles and silently stops matching.

Also records that the camelCase mirror and the snake_case API mean every select must map columns explicitly, because selecting the table changes the JSON contract with nothing to notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 09:33:09 -05:00
bermudalambandClaude Opus 5 58d5925466 feat(db): convert routes/adminCategories.ts to Drizzle (#218)
All nine sites, chosen because the file is awkward rather than easy: a recursive CTE consumed two ways, a correlated subquery, an array match, and two error paths keyed on a Postgres SQLSTATE. A file of plain CRUD would have produced a flattering number that does not generalise.

The hand-declared row interfaces are gone. CATEGORY_COLUMNS is written once and the row type is inferred from it, which closes the drift itemSelect.ts documents as "KEPT IN STEP BY HAND". That mapping has to be explicit rather than selecting the table: the mirror names columns in camelCase and this API answers in snake_case, so selecting the table directly would have silently changed the JSON contract the admin frontend reads, and no test asserting status codes would have caught it.

strict and noUncheckedIndexedAccess hold with no non-null assertions added. requireRow covers the RETURNING rows and the existing lookup destructures and branches, exactly as before.

Two bugs were introduced and caught by the integration suite, and both are worth recording because neither produced a type error.

Drizzle renders a column reference inside a `sql` template UNQUALIFIED. `${items.categoryId} = ${categories.id}` became `WHERE "category_id" = "id"`, which Postgres resolved against items on both sides — so the item count came back plausible and wrong rather than failing. That is worse than the documented array trap, which at least produces invalid SQL. The fragment is now literal text, which is honest since it binds no values.

And the driver's error code moved. Drizzle wraps errors, so the SQLSTATE that sat on err.code now sits on err.cause.code; the old check compiled, never matched, and turned two 409s into 500s. isUniqueViolation accepts both shapes.

inArray replaced the ANY(...::int[]) match and sidesteps the sql.param trap entirely — there is no template to forget it in, and the builder emits the placeholder list correctly by construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 09:33:09 -05:00
bermudalambandClaude Opus 5 a3e8a7a1d8 chore(db): remove the spike's drizzle output directory (#217)
backend/drizzle/ was `drizzle-kit`'s output from the #216 spike, superseded now that pull writes into src/db-drizzle. Removing it also removes something that should never have been on main.

#216's closing comment said the spike branch carried "an experimental condition_note column that must not reach main". Checking backend/migrations for it found nothing, which is where I stopped looking last time — but it was here, as backend/drizzle/0001_add_condition_note.sql. It reached main in the same merge that brought the Tinqer probe #261 removed.

Nothing ran it. node-pg-migrate only executes backend/migrations, so this SQL was inert and no database has the column. It was a loaded gun rather than a fired one, which is the only reason this is a cleanup rather than an incident.

That file is also the evidence #219 needs, so it is quoted in that issue before being deleted: adding one nullable column emitted three DROP INDEX statements and three CREATE UNIQUE INDEX statements alongside it, for the expression indexes drizzle-kit could not diff. On a large table those recreations take real locks, and a generated migration nobody read would have taken them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 09:33:09 -05:00
bermudalambandClaude Opus 5 e7fd63c2e6 feat(db): land the Drizzle schema, config and conventions (#217)
Infrastructure only. No route is converted, nothing changes at run time.

The mirror had already drifted, which settles how it should be maintained. schema.ts was missing item_drafts and upload_links from the moment #222 landed, because the spike pulled into ./drizzle and copied the file into src/ by hand, and nobody had reason to look at the copy for a week. So `out` now points at src/db-drizzle and pull refreshes in place — the copy step that made the drift possible is gone — and tablesFilter excludes pgmigrations, which is node-pg-migrate's bookkeeping and has no business in a model of the application's schema.

A stale mirror is worse than no mirror, because Drizzle infers row types from it: a converted query would type-check against a schema the database does not have and fail at run time on a column that does not exist. drizzleSchema.integration.test.ts fails when the two disagree, on tables and on columns. It was checked by removing item_drafts from the mirror and confirming the test fails naming it, rather than trusting a green run on a file that already matched.

pull also emits 0000_*.sql and meta/ into `out`, because that directory serves both purposes. Both are gitignored: this project's migration history is backend/migrations, hand-written and mostly prose, and #219 has not chosen otherwise — a stray SQL file in src/ is at best noise and at worst mistaken for real history.

db is exported beside pool and shares its connections. Both must work at once, since conversion is file by file across 187 sites; separate pools would make a transaction on one invisible to the other and silently double the configured limits.

The generated files are excluded from linting. #261 hand-fixed an unused-parameter warning in schema.ts and this re-pull put it straight back, which is the argument in one line: linting generated code buys a fix the next regeneration undoes. itemFilters.drizzle.ts, which is hand-written, is still linted.

CONVENTIONS.md records the sql.param() array trap before anyone hits it — the wrong form type-checks, reads correctly and fails at run time as invalid Postgres — and the reason the adoption is worth doing at all, which is that ${value} emits a bind parameter and there is no way to spell "interpolate this as SQL" by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 09:33:09 -05:00
bermudalambandClaude Opus 5 06933aec75 fix(scripts): make the alias check fail closed, and correct three stale docs (#208)
The alias check was a negative match on an allowlist of English error strings, which returned True for empty output, for $null, and for "exit status 1: Access is denied." — so an alias switch producing nothing, or failing on the symlink permission error this file's own header warns about, was reported as success while the old version kept running. That is the bug #198 was filed about, narrowed rather than removed, and it also broke whenever nvm reworded an error. It is now a positive match on "Now using node v<what is actually running>".

The floor check moves ahead of the switch and reads the constant rather than the result. Where it sat, $major was always whatever NODE_VERSION says, so it validated the switch it had just made instead of the pin it exists to guard, and could never fire.

Use-NodeLatest is now Use-PinnedNode. In a change whose whole subject is that "latest" means something people do not expect, the name was an avoidable trap.

The restore default moves beside NODE_VERSION. It deliberately is not a param default: a param block runs before the dot-source, so $script:DEFAULT_NODE_VERSION is still $null there and the restore would have quietly restored nothing — leaving the machine on the pinned version, which is the exact failure the restore exists to prevent. It is resolved after the dot-source instead, and an explicit -DefaultNodeVersion still wins.

Three documents described behaviour the code no longer has: README's "both scripts run nvm use latest", run-tests.ps1's .DESCRIPTION, and project-context.md's instruction to agents. All corrected, and project-context.md now also says not to run these scripts from an agent shell, which is how this machine once ended up with no Node at all.

Part 4 of the issue is partly stale: backend/package.json already declares engines >=20.9.0. frontend now matches it. The larger question — whether local should be pinned to the Node 20 that CI and the production image actually run — is a decision rather than an oversight and is left open on the issue.

Verified by parsing all three scripts with the PowerShell AST parser, which does not execute them. They are deliberately never run from an agent shell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 09:33:09 -05:00
bermudalambandClaude Opus 5 f52976dec9 fix(admin): answer 404 for an item id that does not exist (#207)
Three routes in admin.ts answered a miss with a success. PUT /items/:id ran an UPDATE that matched nothing, committed happily, selected nothing back and replied 200 with an empty body — a success the admin client could do nothing with, and no record anywhere that the item was not found. mark-sold and mark-available did the same. The create route beside them has always used requireRow for exactly this, which is why this reads as an oversight rather than a decision.

A garbage id was worse in a different direction. Number('abc') is NaN, the driver sends it to Postgres as the text "NaN", Postgres raises 22P02 for an integer column, and the catch turned that into a 500 — so a caller asking for an item that cannot exist was told the server broke. Both now answer 404, because from the caller's side "/items/abc" identifies no item in exactly the way "/items/999999" does.

readId is shared rather than repeated, and rejects zero, negatives and fractions as well as text: every id in this schema is a positive serial, so anything else identifies nothing.

mark-sold now notifies favouriters only after the row is known to exist, so nobody is told about a sale that did not happen.

The issue asked for the same shape to be checked across the other admin routes. It was: unpublish already looks the item up and 404s, and the tags and categories PUT routes both do an existence check before their UPDATE, so their rows[0] is guaranteed. items.ts already guards the public read. These three were the only ones lying about a miss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 09:33:09 -05:00
bermudalambandClaude Opus 5 0872cad4df fix(ci): stop pull request scans overwriting the dashboard's picture of main (#197)
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 each pull request, and each push to one, overwrote the dashboard's analysis of main with the branch. The new-code period, the gate result, the coverage percentages and the hotspot list then all described whatever was scanned last, with nothing on the dashboard saying which revision that was. A gate that went green on a feature branch read exactly like a gate that went green on main.

It was caught only by luck: #180's hotspots reported line numbers that landed on a comment and a blank line in main, which is the kind of nonsense a person notices. Everything else it misreported would have looked fine.

scripts/scan-local.sh has always refused to do this, defaulting to a scratch key, and its header says why in as many words. CI walked into the hazard that script guards against. Now the two tell the same story.

The suites still run on pull requests, which is where their value is — only publishing is restricted. The measures report is skipped alongside the scan, because with nothing published it would print main's numbers into a pull request's log, which is noise at best and misread as the branch's own at worst.

A test asserts both steps carry the restriction and that the three suites do not, because the failure leaves no trace and the `if:` is one line for somebody to drop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 09:33:09 -05:00
bermudalamb 2818a0d240 Merge pull request 'Feature/227 submission ceiling' (#266) from feature/227-submission-ceiling into main
Linting / lint (push) Successful in 2m12s
SonarQube Analysis / sonarqube (push) Successful in 28m7s
Reviewed-on: #266
2026-09-02 09:07:10 -05:00
bermudalambandClaude Opus 5 92c04847d6 feat(intake): refuse submissions past the daily ceiling, and reset it from the admin (#227)
Linting / lint (pull_request) Successful in 2m5s
SonarQube Analysis / sonarqube (pull_request) Successful in 26m27s
The check is ordered ahead of uploadImages, for the same reason requireUsableLink is: a refused submission must write zero bytes. Ordering it after would accept the upload, store the files and then throw them away, which is the expensive half of the work the ceiling exists to prevent. A test asserts nothing is stored.

503, not 403. The sender has done nothing wrong, their link is fine, and the condition clears by itself as the window rolls — so the link stays usable and works again the moment there is room.

The ceiling never touches the admin upload path, which has its own test. Intake being throttled is an inconvenience; the shop being unable to add its own stock is an outage.

reset-ceiling is declared above /:id/revoke because Express matches in order and would otherwise read it as an id and try to revoke a link named "reset-ceiling". That has its own test too.

The per-link alert is a named function rather than the inline IIFE the plan wrote. routesAreWrapped.test.ts flags any async inside a route registration not directly wrapped in asyncRoute, and it cannot tell an inner IIFE from an unwrapped handler — nor should it have to. The guard caught this, and the extraction reads better than what it rejected.

Backend now 373 unit and 337 integration, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 16:29:00 -05:00
bermudalambandClaude Opus 5 55cb7eaa13 feat(intake): count submissions across every link, and alert on abnormal volume (#227)
The count comes from item_drafts rather than a tally. Every submission creates exactly one 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.

windowStart is the part worth testing on its own and the part most likely to be quietly wrong. A reset older than the window must not widen it, which would make the ceiling stricter over time rather than rolling; a reset in the future must not disable it; and a malformed value must not produce an Invalid Date, which compares false against everything and would silently switch off the limit it was set to impose. Each is a test.

Alerts go through sendMail directly rather than 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.

The throttle is in memory, so a restart during an incident can send one extra alert. That beats writing to admin_settings from the request path on every refused submission; a replicated deployment would have to move it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 16:20:44 -05:00
bermudalambandClaude Opus 5 22215f32eb feat(intake): settings for the submission ceiling (#227)
A count type beside the existing hours, text and choice readers. resolveHours would have worked — it is parseFloat with a positive guard — but calling a submission ceiling an "hours" setting is a lie in the type name that every later reader has to decode. Whole numbers only, so a ceiling of 12.5 is a typo rather than a preference, and a malformed value falls back rather than yielding a NaN that compares false against everything and silently disables the limit.

resetDb is widened to clear intake_ settings as well as email_ ones. It deliberately does not truncate admin_settings, so a ceiling of 1 left behind by one suite would make every later suite's submissions refuse with a 503, in files that never mention a ceiling. The comment there already records that exact failure happening once with an email template subject, which reached the favorite-alert tests and failed five of them somewhere else entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 16:19:45 -05:00
bermudalambandClaude Opus 5 722ff91378 docs(intake): plan the global submission ceiling (#227)
Six tasks: the settings, stopping them leaking between test suites, the count, the alerts, the refusal, and the manual reset.

The count is derived from item_drafts rather than a tally, as the issue asks. That forces a decision it left open: a reset cannot delete anything, because the rows are real submissions whose items are sitting in the review queue. So a reset stores a timestamp and the window becomes the later of that and 24 hours ago — one derived count, no second tally, and a reset that is an auditable fact rather than a deletion.

The refusal is ordered ahead of uploadImages, for the same reason requireUsableLink is: a refused submission must write zero bytes. Ordering it after would accept the upload, store the files and throw them away, which is the expensive half of the work the ceiling exists to prevent.

The alert throttle is in memory rather than in the database, so a restart during an incident can send one extra alert. That is a better trade than writing to admin_settings from the request path on every refused submission, and it is noted that a replicated deployment would have to move it.

Self-review caught two things against the tree. POST /api/admin/items answers 200 rather than 201, so that assertion was wrong. And resetDb deliberately does not truncate admin_settings — it deletes only the email_ rows — so a ceiling of 1 left behind would make every later suite's submissions refuse with a 503, in files that never mention a ceiling. The comment in that file records the same failure happening once already with an email template. Task 1b widens the cleanup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 16:18:39 -05:00
bermudalamb 46fee44a7a Merge pull request 'Feature/224 notification impl' (#265) from feature/224-notification-impl into main
Linting / lint (push) Successful in 2m21s
SonarQube Analysis / sonarqube (push) Successful in 26m59s
Reviewed-on: #265
2026-09-01 15:47:28 -05:00
bermudalambandClaude Opus 5 7b4fbbb9e3 build(intake): give the containers an INTAKE_ACTION_SECRET (#224)
Linting / lint (pull_request) Successful in 2m17s
SonarQube Analysis / sonarqube (pull_request) Successful in 28m3s
A gap in the plan rather than in the code: nothing wired the secret into either stack, so the feature would have shipped with its signed links permanently disabled and nothing saying why. Both compose files now interpolate it, with `:-` so an unset variable stays empty rather than failing the deploy.

QA takes QA_INTAKE_ACTION_SECRET, its own value rather than production's, for the same reason as every other QA_ prefixed credential — and more sharply here, because a link signed with it acts on a draft without a login.

Rotating the secret revokes every outstanding link, which is the intended answer to one leaking.

The cutover doc counted fourteen interpolated names and now counts fifteen. That document says it is checked against the file rather than from memory, so it was: fifteen in the compose file, the same fifteen listed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 15:33:12 -05:00
bermudalambandClaude Opus 5 346e9eae4c feat(intake): act on a signed link from the notification (#224)
Mounted publicly, deliberately not behind requireAdminGate. These are clicked from an inbox by someone who is not signed in, which is the whole point; the signature is what protects them.

GET confirms and changes nothing, POST acts. Mail scanners and corporate link-rewriting gateways issue a GET against every URL in a message before a human sees it, so a GET that discarded a draft would fire itself on delivery — carrying a valid signature, looking entirely legitimate in the log, and nobody would know to go and recover it. That is the case the split exists for and it has its own test.

Forged, replayed, upgraded and expired links are each refused with the same 403. Distinguishing them would tell somebody probing which of those they had achieved. There is no signable publish, and asking for one finds no handler.

The two registry guard tests are updated rather than worked around: they assert the full set of settings and template keys, so adding either is exactly what should trip them.

Backend now 367 unit and 329 integration, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 15:32:07 -05:00
bermudalambandClaude Opus 5 faed47e105 feat(intake): tell the admin when a draft is ready (#224)
Sent after the draft commits, fire and forget. A mail failure must never mark a draft that was written correctly as failed: the review queue is what the admin actually works from, and the email is a convenience on top of it.

Every quiet path returns rather than throws. No recipient configured is not an error — nobody has said where to send it and the draft is waiting regardless. No INTAKE_ACTION_SECRET means the two shortcut links render empty rather than broken, because a link that could not be verified is worse than none. An item with no draft row simply returns.

A missing description is said plainly rather than left blank. An empty paragraph in a notification reads as a bug; "no description was drafted for this item" reads as the fact that it is, and tells the admin what to expect on the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 15:24:54 -05:00
bermudalambandClaude Opus 5 0517faca60 feat(intake): add the submission notification template (#224)
Editable from the settings screen like every other template. reviewUrl is the only required placeholder: a notification with no link in it still sends, still looks fine in the log, and is useless to whoever receives it, which is what the required-placeholder validation exists to catch.

The two signed links are deliberately optional. They are absent whenever INTAKE_ACTION_SECRET is unset, and a template demanding them would leave an unconfigured environment unable to send this at all.

A test asserts the template offers no way to publish. That the email cannot publish is what bounds the risk taken by pricing items on arrival, and it is a property of the copy as much as of the routes — a publish link in the body would be one nobody reviewed.

Where the notification goes is an admin 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. Empty is the default and means do not notify, which is a working configuration.

INTAKE_ACTION_SECRET warns rather than fails at boot, like the drafting key. Being told an item arrived matters far more than being able to discard it in one click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 15:23:32 -05:00
bermudalambandClaude Opus 5 acc1b406d8 feat(intake): sign the two actions an email may take (#224)
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. Each of those is a test.

Compared through a second digest rather than directly, because timingSafeEqual throws when the buffers differ in length, and a truncated link is an ordinary thing to receive rather than an exception. Same idiom as the admin gate.

actionUrl returns null rather than throwing when there is no secret or no PUBLIC_URL. An unconfigured environment still sends the notification with its review link — being told an item arrived matters far more than the shortcuts do — and a link that could not be verified must never be offered in the first place.

Uses the shared trimTrailingSlashes rather than a trailing-slash regex, which is what utils.ts exports it for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 15:20:19 -05:00
bermudalamb 4ccc1ffcaf Merge pull request 'Feature/224 intake notification' (#263) from feature/224-intake-notification into main
Linting / lint (push) Successful in 2m23s
SonarQube Analysis / sonarqube (push) Successful in 28m25s
Reviewed-on: #263
2026-09-01 15:08:48 -05:00
bermudalambandClaude Opus 5 c88d1eee11 docs(intake): plan the submission notification (#224)
Linting / lint (pull_request) Successful in 2m9s
SonarQube Analysis / sonarqube (pull_request) Successful in 26m6s
Five tasks: a pure HMAC signer, the editable template and its recipient setting, the send from the drafting worker, the public routes that act on a signed link, and the quiet paths.

The plan makes one decision the issue does not, and it changes the shape of the feature. Mail scanners and link-rewriting gateways issue a GET against every URL in a message before a human sees it, so a GET that discarded a draft would fire itself on delivery — with a valid signature, looking entirely legitimate in the log, and nobody would know to go and recover it. So the signed link is a safe GET that confirms and a POST that acts. It costs one extra click and is cheap to reverse if that is the wrong trade.

Everything about the notification is best-effort. No recipient configured, no INTAKE_ACTION_SECRET, or SMTP down all end in a log line: the review queue is the source of truth, and a draft that was written correctly must never be marked failed because an email did not send.

The email still cannot publish. The two signable actions are exactly the ones whose worst case is a wasted API call or a hide the queue can undo, which is what makes putting them in an inbox acceptable at all.

Branched from feature/225-review-queue rather than main, because the review link has nowhere to land without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 14:57:55 -05:00
bermudalambandClaude Opus 5 fdc92d8f19 test(intake): cover reviewing and publishing a submission (#225)
Linting / lint (pull_request) Successful in 2m17s
SonarQube Analysis / sonarqube (pull_request) Successful in 28m52s
Seeded through the intake route rather than POST /api/admin/items, which writes no item_drafts row — an item created that way would never appear in a queue that joins that table. Going through the link is also the path a real submission takes, so the test exercises what actually ships.

Two cases. The first edits the price and publishes, which confirms the number, so no dialog appears and the item reaches the storefront at what was typed. The second publishes an untouched price and asserts the confirmation names the figure and the fact that nobody chose it, then cancels and checks the item is still unconfirmed. That second case is the protection this whole screen exists to provide.

Scoped to the card each test creates, located by the sender's note carrying the run id — the item's own name is a submission timestamp and not unique to the test. The dialog is matched on its accessible name because antd nests the confirm title in two elements and getByText resolves to both.

Full suite 157 of 157, up from 155.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 14:26:21 -05:00
bermudalambandClaude Opus 5 2b06538ef9 feat(intake): review, edit and publish drafts from the admin (#225)
The screen that makes the intake pipeline usable. Until now a draft existed only in item_drafts and nothing rendered it, so a successful draft and a failed one looked identical from the admin — the item shows its placeholder submission-timestamp name either way, and telling them apart needed SQL.

The price carries the weight the schema no longer does. It is labelled with where the number came from, anything not set by a person is marked unconfirmed, and publishing at an unconfirmed price asks first rather than reporting afterwards. Editing the field is what confirms it, so opening the card and leaving the price alone is not recorded as approval — the same rule the server applies, which this only has to agree with.

Discard is offered rather than delete, and a discarded card offers Restore in its place.

The e2e page object's AdminTab union is extended alongside the tab itself. It is a closed union, so admin.open('Review queue') would not type-check without it — and the tab strip and that union have to be changed together or the next spec to use it fails to compile.

Both frontend lint and build clean, still at zero warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 14:19:49 -05:00
bermudalambandClaude Opus 5 d8a7588685 feat(intake): regenerate, discard and restore a draft (#225)
Regenerate clears attempts 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 would produce a button that appears to work, does nothing, and leaves nothing anywhere to say why.

Discard deletes nothing — not the item, not the photographs. It 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 the word is deliberately not available here. The item returns to pending, because a discarded submission must not stay on sale.

Restore returns a draft at the state its own contents justify rather than unconditionally ready. A submission discarded before it was ever drafted has no copy, and coming back as ready would present an empty draft as a finished one. Judged on whether a name was ever written, because the state held before discarding is not stored.

Backend now at 346 unit and 317 integration tests, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 14:15:22 -05:00
bermudalambandClaude Opus 5 44a9037121 feat(intake): publish a reviewed item to the storefront (#225)
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 the copy and the publish have to be one transaction: an item published carrying the previous draft's name would be worse than one not published at all.

The price rule is applied here rather than trusted from the client. A changed number becomes the admin's; an unchanged one keeps whatever it was, so publishing without touching the field records that nobody chose it. The row is locked for the transaction so two admins publishing the same submission cannot interleave one's price decision with another's name.

Whole cents only. A fractional value would round somewhere nobody is looking and put the item on sale at a price no one entered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 14:11:51 -05:00
bermudalambandClaude Opus 5 81886f84c2 feat(intake): list submitted items waiting for review (#225)
Columns are spelled out rather than selected with a wildcard, so a column added to item_drafts later does not silently start reaching the browser. That matters most for the join to upload_links, which carries the token digest — only the label is taken, and a test asserts the digest never appears in a response.

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.

The gate goes on the mount in app.ts rather than inside the router, matching every other admin router. Since ADMIN_GATE_SECRET is unset for integration runs the gate is disabled there, so the test that asserts the mount is actually gated sets the secret for its own duration — leaving requireAdminGate off a new mount is otherwise a silent hole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 14:10:38 -05:00
bermudalambandClaude Opus 5 7522826bd0 feat(intake): record who chose an item's price (#225)
Pure and separately tested because the failure it guards is silent. Items are priced on arrival, so the schema no longer stops a number nobody chose reaching the storefront — the review queue does, by showing that nobody chose it, and an item selling at a default price looks exactly like one selling at a chosen price.

Editing the number is the only thing that confirms it. Publishing an untouched field deliberately does not, because that would record "I did not look at this" as "I approved this".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 14:07:35 -05:00
bermudalambandClaude Opus 5 c119f79747 docs(intake): plan the review queue (#225)
Six tasks: the price provenance rule as a pure unit, the list endpoint, publish, the three state actions, the screen, and an end-to-end pass.

The price field is the reason this screen exists. Items are priced on arrival, so the schema no longer prevents a number nobody chose from reaching the storefront — that protection moves here, into presentation, where it is weaker. So the rule is a pure tested function rather than a line inside a route: editing the number is the only thing that confirms it, publishing an untouched field deliberately does not, and publishing something still unconfirmed asks first rather than reporting afterwards. 80.00 was chosen because it reads as a decision rather than as an obvious sentinel, which is exactly why it has to be called out rather than left to be noticed.

Discard deletes nothing. It is one click from an inbox, and the photos are often the only copy of an item no longer in the sender's hands, so it marks the draft and returns the item to pending. Restore brings it back at the state its own contents justify rather than unconditionally ready, because a submission discarded before it was ever drafted has no copy and must not return claiming otherwise.

Regenerate clears attempts along with the state. The worker only picks up rows below the attempt cap, so re-queueing a draft that already failed three times would otherwise produce a button that appears to work, does nothing, and says nothing.

The end-to-end test seeds through the intake route rather than POST /api/admin/items, which writes no item_drafts row and would never appear in a queue that joins it.

One deviation from the issue is recorded in the plan rather than buried: it asks for the existing admin item components to be reused, and this builds a purpose-made card instead, because the fields differ in kind rather than arrangement. The cost — two places rendering a name, description and price — is named there too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 13:54:11 -05:00
bermudalamb 20dd7e82a2 Merge pull request 'chore(sonar): remove the rejected Tinqer spike, clear the lint debt, and report measures in CI (#261)' (#262) from chore/261-sonarqube-cleanup into main
Linting / lint (push) Successful in 2m14s
SonarQube Analysis / sonarqube (push) Successful in 25m51s
Reviewed-on: #262
2026-09-01 13:41:57 -05:00
bermudalambandClaude Opus 5 fe28c97e0f chore(sonar): remove the rejected Tinqer spike, clear the lint debt, and report measures in CI (#261)
Linting / lint (pull_request) Successful in 2m7s
SonarQube Analysis / sonarqube (pull_request) Successful in 25m21s
The standing cleanup, three features behind. Four changes.

Report the measures in CI. This is the one that matters, because the rest was only findable by reading the tree. SonarQube here is 9.9 Community: no Bearer auth, so the official MCP cannot connect, and the host is a CI secret, so hotspots, duplication, debt and coverage existed only on a dashboard — which made "reduce the debt" an instruction nobody could act on without a browser open beside them. scripts/summarize-sonar.js queries the measures API with the secrets the workflow already holds and prints the result into the job log. The scanner masks the URL and token; measures are not secret.

It polls the compute task before reading. The workflow does not set sonar.qualitygate.wait, so the scan step returns once the report is uploaded and the server computes measures afterwards — reading immediately would return the previous analysis, indistinguishable from this one and quietly wrong. When it cannot confirm, it says so in the output rather than presenting stale numbers as current. It is deliberately not guarded with continue-on-error: it exits 0 on every path, and guarding it would oblige it to appear in the final gate, whose job is to fail the build.

Remove the Tinqer spike. #216 evaluated Drizzle against Tinqer and rejected Tinqer, and its closing comment said the throwaway src/db-tinqer/ probe must not reach main. The whole spike commit was merged, so it did. The probe is 71 lines imported by nothing, and @tinqerjs/tinqer, @tinqerjs/pg-promise-adapter and pg-promise were dependencies for a library nobody chose. The condition_note column that warning also named did not reach main.

Clear the lint debt, both projects now at zero warnings from six and two. One of these was a real defect rather than tidiness: the third catch block in shippingAddresses.ts rolled back and returned 500 while discarding the error, so a failed default-address change left nothing behind to say why — the two catch blocks above it in the same file already logged, and this one had simply been missed. The Express namespace augmentation is a false positive and is disabled with the reason written beside it, because an interface that must merge into one Express declares inside a namespace has no ES module spelling.

Dedupe the extension map. backfillImageReencode.ts kept its own .jpg/.png/.webp table whose comment named uploadTypes.ts as the source of truth, directly above duplicating it. That file rewrites stored images, so the two disagreeing would silently skip files it should re-encode.

src/db-drizzle/ deliberately stays. #217 is open to promote exactly those files properly, with tablesFilter and the sql.param() array rule; deleting them here would be doing #217 badly in the wrong issue. Only their unused-symbol warnings are fixed, and if drizzle-kit pull regenerates schema.ts the table warning returns — worth #217 knowing.

Hotspots and coverage are untouched because both numbers are still invisible. They are the next pass, once the step above has printed them once.

Closes #261

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 13:30:29 -05:00
bermudalamb eaa1f323ad Merge pull request 'build(qa): take the SMTP host, port and TLS flag from QA_ stack variables (#258)' (#259) from chore/258-qa-smtp-stack-vars into main
Linting / lint (push) Successful in 2m15s
SonarQube Analysis / sonarqube (push) Successful in 27m15s
Reviewed-on: #259
2026-09-01 11:51:14 -05:00
bermudalambandClaude Opus 5 cbb0090579 build(qa): take the SMTP host, port and TLS flag from QA_ stack variables (#258)
Linting / lint (pull_request) Successful in 2m9s
SonarQube Analysis / sonarqube (pull_request) Successful in 28m7s
QA_SMTP_HOST, QA_SMTP_PORT and QA_SMTP_SECURE were set on the stack but nothing read them: the compose file hardcoded all three, so changing one in Portainer had no effect and gave no sign of that. They now interpolate like the credentials beside them.

Each keeps its Brevo value as a default rather than being left to fall through. The mailer's own fallbacks are Gmail's — smtp.gmail.com, 465, implicit TLS — and Brevo is STARTTLS on 587, so an unset variable with no default here would quietly aim QA at Gmail and fail at send time rather than at boot. `:-` supplies the default only when the variable is unset or empty, so setting one still wins.

Verified with `docker compose config` both ways: unset resolves to smtp-relay.brevo.com/587/false, and set resolves to the supplied values. The #107 compose guard still passes.

Production is deliberately untouched. It hardcodes the same three and nobody has asked to vary them there, and a needless change to the production stack is not worth the deploy.

Closes #258

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:46:17 -05:00
bermudalamb 46f5064567 Merge pull request 'fix(e2e): match the modal OK button exactly so a random suffix cannot collide (#253)' (#256) from fix/253-exact-locator-names into main
Linting / lint (push) Successful in 2m25s
SonarQube Analysis / sonarqube (push) Successful in 26m23s
Reviewed-on: #256
2026-09-01 09:42:37 -05:00
bermudalambandClaude Opus 5 94b2dfd186 fix(e2e): match the modal OK button exactly so a random suffix cannot collide (#253)
Playwright matches an accessible name case-insensitively and as a substring unless exact is passed, so `getByRole('button', { name: 'OK' })` matched any button whose name merely contained "ok".

Caught on a full serial run of main:

    strict mode violation: getByRole('button', { name: 'OK' }) resolved to 2 elements:
        1) aka getByRole('button', { name: 'Freed rmtiq9okg22k9e' })
        2) aka getByRole('button', { name: 'OK', exact: true })

A leftover "Freed …" toast was still on screen and the random base36 suffix happened to contain "ok". Roughly one suffix in a few hundred does, which is the profile of a test that fails occasionally and reproduces for nobody.

This is not the contention #241 addressed. It reproduced with workers: 1, serially, on a fresh database — it needs only a stale toast and an unlucky suffix. It is at least part of what #245 skipped a real test to work around.

Also makes the taxonomy modal's OK and the sold-filter 'All' radio exact, the only other targets short enough to appear inside a random suffix. The dozen or so remaining short names are left alone deliberately: they would need a collision in deliberately-chosen test data rather than a random one, and substring matching is load-bearing in some of them.

Verified with two consecutive full e2e passes, 155 of 155 both times.

Closes #253

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 09:42:37 -05:00
bermudalamb 8eba49c029 Merge pull request 'fix(e2e): restore the frontend build by typing the responses findOrFail searches (#254)' (#255) from fix/254-frontend-build-break into main
Linting / lint (push) Successful in 2m36s
SonarQube Analysis / sonarqube (push) Successful in 28m35s
Reviewed-on: #255
2026-09-01 09:33:14 -05:00
bermudalambandClaude Opus 5 42412e8eeb fix(e2e): restore the frontend build by typing the responses findOrFail searches (#254)
Linting / lint (pull_request) Successful in 2m10s
SonarQube Analysis / sonarqube (pull_request) Successful in 28m5s
main did not build, so every Portainer deploy failed with `npm run build` exit code 2. My regression from #241.

findOrFail<T>(items: T[], predicate: (item: T) => boolean) infers T from both parameters. The call sites annotate the predicate as documentation, and the arrays come from .json(), which is any and offers no competing candidate — so T became the one-field shape written in the lambda and every caller failed on the field it actually wanted. Array.prototype.find has no such problem, which is why the code this replaced type-checked.

The four responses are now typed at their call sites, so T is inferred from real data and the predicates need no annotation. findOrFail additionally takes NoInfer<T> on its predicate, so a stray annotation can never drive the element type again. The specs are better typed than before this change: `.json()` was plain any, and the annotations only ever documented a shape nothing enforced.

I did not catch this because I verified with a bare `npx tsc --noEmit`, and tsconfig.json is `"include": ["src"]` — it structurally cannot see tests/. The specs are checked by the second command in `npm run build`, which is the step Docker runs and the step that failed. Verified this time with `npm run build` itself, plus lint, the unit suite, and two consecutive full e2e passes.

Closes #254

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 09:09:01 -05:00
bermudalamb 3ab265a653 Merge pull request 'Feature/223 drafting worker' (#251) from feature/223-drafting-worker into main
Linting / lint (push) Successful in 2m16s
SonarQube Analysis / sonarqube (push) Failing after 2m7s
Reviewed-on: #251
2026-09-01 08:32:40 -05:00
bermudalambandClaude Opus 5 d887cf1d15 docs(intake): mark the drafting worker plan complete through task 7 (#223)
Only the manual verification against a real photograph is left, and it needs an API key that does not exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:32:40 -05:00
bermudalambandClaude Opus 5 e163bb39c6 feat(intake): run the drafting worker after a submission and on a sweep (#223)
Two drivers. The call after a successful submission means a draft is usually waiting by the time anybody looks; the five-minute sweep means a restart mid-draft is recoverable rather than a permanently stalled row, and picks up whatever the first call missed.

Neither is awaited. 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 cost of a dropped call is a few minutes' delay, not a lost submission.

Both catch for themselves. The comment beside the existing schedulers points out that `void` is only safe because those functions handle their own errors, and draftQueued does not: its first query can reject, and an escaping rejection would take the container down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:32:40 -05:00
bermudalambandClaude Opus 5 e0d7e92e4b feat(intake): draft queued submissions without ever losing one (#223)
The governing rule is that a submission is the only irreplaceable thing in this pipeline. The photos are often the only copy of an item no longer in the sender's hands, so a missing key, an unreadable file, a failed call and three exhausted retries all end the same way: the item keeps its photos, stays pending, and waits. Nothing in this file deletes anything.

An absent key returns early and spends no attempt. Counting it as a failure would mean a fortnight without a key exhausted the retries and marked every waiting submission failed, with nothing wrong with any of them.

A failure leaves the row queued while tries remain, so the sweeper picks it up again, and failed once they are spent, so a dead submission stops costing money and waits for a person instead of retrying forever.

Photos are read once and passed down rather than loaded again inside the drafting call — the first read already has to happen to check there is at least one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:32:40 -05:00
bermudalambandClaude Opus 5 511022d248 feat(intake): record a draft without publishing it (#223)
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, so the review queue can show it as unconfirmed. With no suggestion the item keeps the migration's 8000 default and price_source stays 'default'; the queue shows both the same way, as a number nobody has chosen yet.

A category is 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.

A successful retry clears ai_error, or a draft that eventually worked would still read as broken in the queue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:32:40 -05:00
bermudalambandClaude Opus 5 eae2c15f1a feat(intake): draft a listing from photos and a note (#223)
The client is a parameter rather than a module import, so every test passes a stub. A test that reaches the real API is a defect in the test: this runs on a route a stranger with a link can trigger, and each call costs money.

getAnthropicClient returns null rather than throwing when there is no key. An unconfigured environment is a working one, and the worker treats null exactly as it treats a failed call — one path rather than two.

parsed_output is guarded, not asserted. The SDK returns null there when the answer did not satisfy the schema, which is what a model replying in prose looks like; failing cleanly leaves the submission queued for a retry, where asserting would crash the worker mid-loop. Absent usage figures are treated as zero for the same reason: undercounting a cost is survivable, throwing away a draft that actually succeeded is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:32:40 -05:00
bermudalambandClaude Opus 5 d27dcae62b feat(admin): choose the drafting model from Settings (#223)
The model was going to be an environment variable, which meant a redeploy to change it. It is now an admin setting, so it can be changed from the Settings page like the cart expiry and the greeting.

A dropdown validated on the server, not a free-text field. The API only rejects an unknown model at the point of use, so a typo would be stored happily and then fail on every submission, surfacing as drafts quietly not appearing rather than as an error anybody could act on. The PUT refuses anything outside the offered set, and getSettings falls back rather than handing on a value that is no longer offered — drafting with the default beats drafting with a model the API will refuse.

One catalogue rather than two lists. The 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 two are maintained separately. Rates were confirmed against the pricing page rather than recalled: Sonnet 5 $2/$10, Opus 5 $5/$25, Haiku 4.5 $1/$5 per million tokens. The unknown-model fallback is deliberately the most expensive rate and never zero, because a budget that reads as unspent however much was spent is the one failure a spend guard cannot have.

Adding a third setting type pushed getSettings past the cognitive complexity limit, so the per-type resolution moved out into one small function each — the same shape the definitions block above it already argues for.

The exhaustive assertion in the GET test gained the new field rather than being loosened. It exists to catch a setting silently vanishing from the response, and that is worth more than not having to touch it here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:32:40 -05:00
bermudalambandClaude Opus 5 96af5a571d docs(intake): make the drafting model an admin setting (#223)
Added mid-execution at Thom's request. Two consequences worth recording: it is a dropdown validated on the server rather than a free-text box, because a mistyped model name fails on every submission and surfaces only as drafts quietly not appearing; and the model list lives in one catalogue shared with the cost table, so the settings dropdown and the per-token rates cannot drift apart.

Rates confirmed against the pricing page rather than recalled. Worth having checked: an increase to $3/$15 had been scheduled for tomorrow and was cancelled, with $2/$10 made permanent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:32:40 -05:00
bermudalambandClaude Opus 5 5988f4a545 feat(intake): tell the model to describe rather than invent (#223)
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 instruction is the only place that distinction can be enforced, and the tests assert it is actually present.

On a one-of-a-kind item an invented "1930s hand-thrown stoneware" is not a cosmetic error but a false claim on a public shop, and the shop answers for it rather than the model. Visible damage is called out for the same reason in reverse: a buyer finding a chip on arrival is worse than reading about it beforehand.

Categories and tags are listed rather than described, so the model chooses from what exists instead of inventing a taxonomy the storefront filters know nothing about, and declining is explicitly allowed so a model with no matching option does not pick the closest wrong one.

The note is quoted and labelled as the sender's rather than merged into the instruction: it is untrusted text from an unauthenticated stranger and should read as evidence to weigh, not as something the shop asserts. A whitespace-only note counts as no note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:32:40 -05:00
bermudalambandClaude Opus 5 22910ce9e2 feat(intake): constrain what a draft may contain (#223)
The SDK validates the model's 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.

Everything the model may decline to answer is nullable, because it is told to say nothing rather than guess. A null category is a better answer than a wrong one, and resolving it is what the review queue is for. The name and description are not nullable: a draft without them is not a partial success worth storing.

The price is an integer, bounded at both ends. 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:32:40 -05:00
bermudalambandClaude Opus 5 1b87e08262 build(intake): add the Anthropic SDK and warn when its key is absent (#223)
Both packages go in dependencies rather than devDependencies. The final Docker stage installs with --omit=dev, so the wrong section produces a container that fails on the first submission and nowhere else — which is how sharp went wrong in #226.

ANTHROPIC_API_KEY is a warning, not a requirement. Absent, the container still boots and a submission still arrives, keeps its photos and waits in the queue undrafted. 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 a worse outcome than an item arriving without its description written. Silence would be wrong too: an operator who believes drafting is on and finds every item undrafted has nothing to tell them why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:32:40 -05:00
bermudalambandClaude Opus 5 054a4b0cfd docs(intake): plan the AI drafting worker (#223)
Eight tasks: the SDK and its optional key, the Zod shape the model must answer in, the prompt, the call, writing a draft back, the worker that drives it, the wiring, and one real photograph to see whether any of it writes something worth reading.

The prompt is the correctness surface and gets its own task with its own tests. On one-of-a-kind stock an invented "1930s hand-thrown stoneware" is not a cosmetic error but a false claim on a storefront, and nothing downstream can tell an invented detail from an observed one — the only place that distinction can be enforced is in the instruction, so the tests assert it is there.

The other governing rule is that a submission is the only irreplaceable thing in the pipeline. The photos are often the only copy of an item no longer in the sender's hands, so a missing key, a failed call, a malformed answer and three exhausted retries all end the same way: the item keeps its photos and waits undrafted. Nothing in the worker deletes anything, and an absent key does not spend an attempt.

Only the suggested price reaches the item, per #220, with price_source recording that a model rather than a person chose it. The name and description stay on the draft row until the review queue in #225 exists.

The monthly spend ceiling is deliberately left out. Task 8 measures what a real call costs first, because a budget set from a guessed number is one nobody trusts.

Every test stubs the Anthropic client. A test that reaches the real API is a defect in the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:32:40 -05:00
bermudalamb 2424305d43 build(intake): give the container an ANTHROPIC_API_KEY (#223)
The drafting worker needs a credential, and a Portainer stack variable alone does not reach the container — stack variables are interpolated into the compose file as ${VAR}, and a service receives exactly what its own environment block lists. That is how UPLOADS_DIR went missing in #118, and both files say so; this adds the line that makes the variable actually arrive.

QA takes it from QA_ANTHROPIC_API_KEY, prefixed like the database and SMTP credentials so production's key cannot be pasted there and silently work. It is also worth a key of its own rather than sharing production's, because this is the only credential in either stack that spends money per call, on a path anybody holding an upload link can trigger.

Absent is a working configuration in both, deliberately, which is why prod's line carries `:-` and neither variable joins the always-required list. A submission still arrives, keeps its photos and waits undrafted. Losing somebody's consignment to an expired key would be far worse than an item arriving without its description written, and the photos may be the only copy of something no longer in the sender's hands. USPS is the existing precedent for a credential whose absence degrades rather than fails.

The comments say plainly that a spend limit belongs on the key in the Anthropic console, since nothing in this repository can enforce one and #227's submission ceiling bounds the volume rather than the bill.

docs/ops/production-stack-cutover.md said the compose file interpolates thirteen names and listed them. It now says fourteen, because that document stakes its usefulness on being checked against the file rather than written from memory — a cutover working from a stale list is how a variable gets left behind, which is the failure the document exists to prevent. Counted from the file: exactly fourteen.

composeEnvironment.test.ts passes, 24 tests. It checks that every deployment sets what the validator requires, so adding a variable ahead of a validator entry cannot break it — the entry itself comes with the worker.

Ref #223
2026-09-01 08:32:40 -05:00
bermudalamb 789bb32450 Merge pull request 'Fix/241 e2e isolation part2' (#252) from fix/241-e2e-isolation-part2 into main
Linting / lint (push) Successful in 2m46s
SonarQube Analysis / sonarqube (push) Failing after 1m45s
Reviewed-on: #252
2026-09-01 08:32:14 -05:00
bermudalambandClaude Opus 5 6489486de3 test(e2e): run the suite on one worker so a red run means something (#241)
Linting / lint (pull_request) Successful in 2m37s
SonarQube Analysis / sonarqube (pull_request) Failing after 1m43s
The lever the plan deliberately held back, applied now that there is evidence it is needed. Better failure messages and a wider assertion timeout were not enough.

Measured on this branch, same commit, same machine. Two parallel runs each failed 3 of 155, and not the same three: password-reset and admin-inventory-filters in one, admin-email-settings, admin-inventory-filters and resend-verification in the next. All 16 tests from those three specs then passed when run serially, and the full suite passed 155 of 155 twice in a row. Failures that move between runs of identical code are contention, not defects — specs asserting over tables other specs are concurrently writing to.

The cost is about three minutes, roughly one minute parallel against 3.9 and 4.0 serially. A red run that means something is worth three minutes; the previous state was a suite whose result nobody could act on, which is what #245 had to skip a real test to work around.

If that time is ever needed back, the cheaper fix is giving each worker its own database rather than raising this number and reopening #241.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:13:37 -05:00
bermudalambandClaude Opus 5 895c08d1a7 test(e2e): replace unchecked lookups, widen the assertion timeout, restore the skipped test (#241)
Tasks 2 to 4 of the plan. Nine `collection.find(...)` dereferences become findOrFail, so a missing row fails as a named assertion naming what was wanted and how many rows were searched, rather than "Cannot read properties of undefined" pointing at test plumbing. Where the old code followed the lookup with expect(x).toBeTruthy(), that assertion is dropped: findOrFail already guarantees it, and with a better message.

The expect timeout goes from Playwright's default 5s to 10s. It costs nothing on a green run — it bounds how long a failing assertion waits, not how long a passing one takes — and #239 died reporting exactly Timeout: 5000ms on a runner that also builds, migrates and runs three other suites.

The admin-save happy path comes back from the #245 skip. It is the only end-to-end check that adding an item reaches the database rather than merely firing a toast, and it passed both full parallel runs and in isolation.

filters.spec.ts:216 is deliberately untouched: its .find() searches CSS class names on a string array, not test data, and has no missing-row failure mode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:04:43 -05:00
bermudalamb 93a451dbf0 Merge pull request 'feat(intake): issue named upload links and accept photo submissions (#222)' (#250) from feature/222-upload-links into main
Linting / lint (push) Successful in 2m26s
SonarQube Analysis / sonarqube (push) Successful in 22m1s
Reviewed-on: #250
2026-08-31 15:47:09 -05:00
bermudalamb bcecda9122 fix(intake): stop a throttled sender being told their link is dead (#222)
Linting / lint (pull_request) Successful in 2m27s
SonarQube Analysis / sonarqube (pull_request) Successful in 22m27s
Adding e2e specs for the submission page found a defect in the page they were written for, which is what they were for.

One limiter counted page loads and submissions against the same twenty-per-quarter-hour allowance, so a sender working through a box of stock ran out after ten items — the exact person the feature exists for, and the exact case the limiter's own comment said must not be refused. The comment said refusing them costs a consignment while the number quietly did it.

Worse, the page could not tell a 429 from a 404. `fetchIntakeLink` treated any non-OK response as "no link", so a throttled sender was told "This link is not active" and sent to ask for a replacement — which could not have helped, because the problem was their address and a minute of patience. Two conditions needing opposite reactions were sharing a message.

Now two limiters, because the two requests cost different things. Reading a link hits one indexed row and writes nothing, so that allowance is generous at 120: someone re-reading the form or losing their signal should never be told to wait. Submitting writes up to six files, so that is the one worth bounding, at 30 — more than anyone photographing items can manage and far less than a script would want.

The page gains a third state. Unknown, revoked and used-up still collapse into one "not active" card, because whether a link exists is not something a stranger needs to learn. Throttled is deliberately kept apart from them, since "wait a moment" and "go and ask for another link" are opposite instructions.

Measured rather than assumed, on a freshly started process both times: before, 25 page loads produced 14 rejections; after, 40 produce none. The first attempt at that measurement was wrong and worth recording — the restart had failed with EADDRINUSE, so it read 30 of 30 against the old process's already-exhausted store.

The two specs now pass in a full parallel run alongside everything else. They are scoped the way #241 asks: unique run ids, assertions naming only this run's rows, nothing asserted about the table as a whole.

Backend: 284 integration, 309 unit. Frontend: build clean, lint unchanged at 2 pre-existing warnings.

Ref #222, #241
2026-08-31 15:42:26 -05:00
bermudalamb 9b4d7f2d03 feat(intake): manage upload links from the admin (#222)
An Upload links tab beside Tags: issue a named link, see how much of its allowance is spent, revoke it. Until now the only way to create one was curl, which is how the earlier tasks were exercised.

The token is shown once, in an alert that says so plainly, because the server stores only a digest and genuinely cannot produce it again. A refresh loses it — that is the honest behaviour rather than a bug, so the copy says to revoke and reissue if it is lost instead of leaving somebody hunting for a reveal button.

The cap field starts at 25 and unlimited is a checkbox rather than an empty field. Blank-means-unlimited would make the least deliberate action produce the least bounded link, and this screen sends all three cases explicitly so the server's default only ever has to cover callers that are not this screen.

Two things came out of driving it in a browser rather than reading it. The revoke confirmation said "OK", and every other destructive confirm in this admin names its action — Delete, Disable, Re-enable — so it now says Revoke, in danger styling. A confirm button reading OK makes the reader go back and re-read the question to find out what they are agreeing to. And Popconfirm turned out to be a component nothing else here uses; the rest use Modal.confirm with an explicit okText. Keeping Popconfirm but matching its labelling to the established pattern seemed the smaller inconsistency, since the interaction is a row action rather than a page-level one.

The load-on-mount effect carries the same eslint-disable and reasoning Tags and Categories already use, rather than a new shape.

Verified in a browser: create shows the one-time reveal, the row lists as 0 of 25 and Active, revoke flips it to Revoked, and an explicitly unlimited link shows a bare count with no cap. The database then confirmed a default of 25, a null for the unlimited one, and a stamped revoked_at.

Frontend: build clean, lint unchanged at 2 pre-existing warnings, 30 unit tests pass.

Ref #222
2026-08-31 15:42:26 -05:00
bermudalamb e7b01fdb36 feat(intake): add the public submission page (#222)
Where someone with no account sends in photos of one item. Route /submit/:token, outside the authentik gate by design: the token in the URL is the whole access control, which is what #222 chose deliberately over accounts.

One state for every refusal, matching the server's single 404. Unknown, revoked and used-up links all render the same "this link is not active" card, because saying which kind of dead it was would tell a stranger whether a link they guessed at exists — the server is careful about that and the page must not undo it.

`beforeUpload` returns false so antd keeps the files rather than uploading each one as it is picked. The submission is then a single request the server can accept or refuse as a unit, which is what makes the transaction on the other side meaningful.

The accepted types and the six-file cap are stated here so the picker offers exactly what will be taken, but both are checked again server-side, because everything on this page is under the sender's control.

The fetch effect guards against a late response from a previous token overwriting the current answer, which is reachable simply by editing the URL.

TypeScript caught a real mistake rather than a stylistic one: `.filter((f): f is File => ...)` on antd's originFileObj does not narrow, because RcFile extends File and the predicate would widen rather than narrow. flatMap avoids the predicate entirely.

Verified in a browser rather than by inspection: a throwaway Playwright run against the live stack confirmed the form renders for a good token, the inactive card renders for a bad one, and a photo can actually be sent and acknowledged. The database then showed the item at status pending with the default price, the draft carrying the note and its originating link, the image row written, the link's counter at one — and zero storefront-visible items, which is the property that matters most.

Ref #222
2026-08-31 15:42:26 -05:00
bermudalamb 1fc632598a feat(intake): accept photo submissions through a shared link (#222)
The public way in. Photos of one item plus a free-text note, from someone with no account, landing as an `items` row at status 'pending' — already invisible to every public and storefront query since #90, so nothing is live by accident.

Every refusal is a 404. Unknown, revoked and exhausted links are indistinguishable from outside, because whether a link exists is not something a stranger needs to be able to learn — the same reasoning uploads.ts applies to files.

The link is resolved *before* multer runs, and that ordering is the point rather than an implementation detail. discardUnlessAccepted would delete the files afterwards, but "written then deleted" is materially worse than "never written" on an endpoint the whole internet can reach: it is disk churn an unauthenticated caller controls, and it leans on an unlink that a crash between write and delete would skip. A test asserts the volume is untouched for a bad token, so a future reordering fails loudly instead of quietly handing that control away.

The link counter is incremented 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. The response carries no item id: the sender has no business knowing about the catalogue and nothing they could do with it.

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 waits at state 'queued' for #223.

The new limiter keys on the caller alone, since a submission carries no email. keyByCallerAndEmail's comment warns that a bare ip bucket is a shared allowance, and that trade is taken knowingly: the link is the per-caller identity and its cap is the per-caller bound, while this limiter does the different job of bounding what one address can throw at an endpoint that writes files. Twenty per fifteen minutes is deliberately looser than the password-reset allowance — somebody photographing a box of stock legitimately submits several in a row, and refusing them costs a consignment.

Because the route mounts the shared uploadImages, it inherits the type allowlist, the magic-byte check and #226's EXIF stripping without asking for any of them. A test asserts the stripping specifically, since this is the route where it matters most: the photo comes from a stranger's phone rather than the shop's own camera.

Backend: 284 integration (12 new), 309 unit, lint unchanged at 6 pre-existing warnings, build clean.

Ref #222
2026-08-31 15:42:26 -05:00
bermudalamb 3392f6f10d feat(intake): issue and revoke named upload links (#222)
Three routes behind the admin gate: list, create, revoke. A link is named because provenance matters more than convenience — when one is shared further than intended the question is which one, and every submission will record the link it arrived through, so revoking kills that link rather than the feature.

The token is returned by exactly one response and is unrecoverable afterwards, which is why the admin screen has to present it as a one-time reveal. The listing selects its columns explicitly rather than `SELECT *`, so `token_hash` cannot reach a response the moment somebody adds a convenience — and a test asserts the listing carries neither the token nor the digest.

An absent `maxSubmissions` gets a bounded default of 25 rather than null. Absent means nobody decided; an explicit null means unlimited, which is a decision visible in the request. Reading absent as unlimited is what would quietly make every link unbounded, and the common case is the one that has to be safe.

Revoking is idempotent through COALESCE, and a test asserts the second call returns the *same* timestamp rather than merely succeeding. The useful fact is when access ended, and 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.

Mounted above the `/api/admin` catch-all, which would otherwise swallow the path, and behind requireAdminGate on the router itself per the reasoning in middleware/adminGate.ts.

Lint caught me reintroducing something this codebase had already solved: I wrote `.replace(/\/+$/, '')` to trim PUBLIC_URL, and app.ts carried a hand-written loop with a comment explaining that exact regex backtracks. Rather than duplicate the loop, trimTrailingSlashes moved to utils.ts and both callers now share it.

Backend: 272 integration (9 new), 308 unit, lint back to its 6 pre-existing warnings, build clean.

Ref #222
2026-08-31 15:42:25 -05:00
bermudalamb 2b2cbe119e feat(intake): generate and hash upload link tokens (#222)
The token is the entire access control on an endpoint the whole internet can reach, so both halves are pure and tested directly rather than through a request — the same reasoning that has uploadTypes.ts and keyByCallerAndEmail exported for their tests.

32 bytes of CSPRNG output, base64url so the value survives being pasted into a URL, a chat message or a QR code without escaping. That matters for something a person is handed rather than something a machine reads. The collision test runs a thousand generations rather than asserting the obvious, because a repeat would mean one person's link opening another's.

SHA-256 rather than bcrypt, and the reasoning inverts the one that governs passwords. A password hash is slow on purpose because a human password carries little entropy and must survive an offline dictionary attack. This is 256 bits from a CSPRNG: there is no dictionary, so slowing the hash buys nothing. Meanwhile the digest is computed on every submission to an unauthenticated endpoint, where a deliberately slow hash would be a denial-of-service surface — #242 is the local proof that cost-12 hashing on a request path is enough to push it past a timeout under load.

No timing-safe comparison, deliberately: the lookup is an indexed equality match on the digest rather than a byte-by-byte compare of the secret, and an attacker who could mount a timing attack against a 256-bit random value would still need the value.

Backend: 307 unit tests, lint unchanged at 6 pre-existing warnings, build clean.

Ref #222
2026-08-31 15:42:25 -05:00
bermudalamb 1a5a8b837b refactor(uploads): extract the validated image pipeline for a second caller (#222)
A pure move, no behaviour change. #222's public intake endpoint needs the same path from a multipart request to files on the uploads volume that the admin routes use, and the alternative to sharing it is a near-copy that has to reproduce every safety property exactly: 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. A copy that drifted on any of those is the gap #95, #103, #180 and #226 exist to close.

`stripUploadedImages` moved with the rest, which the plan originally did not say — it was written before #226 added it. Leaving it behind would have given the intake route an upload path that skips EXIF stripping, and no test would have failed to say so, because the intake tests are written against a route that does not exist yet. The refreshed plan added a check with a definite answer, and it now holds: routes/admin.ts no longer imports imageProcessing at all.

Every comment came across verbatim. They record why the code is shaped as it is and are the most valuable part of what moved.

Lint caught something the compiler did not: MAX_IMAGES_PER_REQUEST was left imported into admin.ts, where its only use — `upload.array('images', MAX_IMAGES_PER_REQUEST)` — had moved away with the middleware. It stays exported from imageUpload for the intake route's caps, but admin.ts does not need it.

Verified as a refactor rather than as a change: the four suites that exercise this path hardest were run before the move and after it, 44 tests both times, same suites, same count. Full backend: 263 integration, 302 unit, lint back to its 6 pre-existing warnings, build clean.

Ref #222
2026-08-31 15:42:25 -05:00
bermudalamb 6df32af784 feat(intake): add upload_links and item_drafts, and default an item's price (#222)
The schema for the intake pipeline. A submission becomes an `items` row at status 'pending' — already invisible to every public and storefront query since #90 — with an `item_drafts` row beside it holding the submitter's note, which link it arrived through, and the fields the drafting worker will fill in later.

`upload_links` stores a digest rather than a token, so a leaked database is not also a leaked set of working links, and the admin screen can show a token exactly once. `max_submissions` is nullable for "no cap", but the route will default it to a finite number: an unbounded link should be something asked for, not something that happens when nobody thought about it.

`item_drafts.upload_link_id` is ON DELETE SET NULL rather than CASCADE. Deleting a link must not delete the items that arrived through it — provenance is lost, the goods are not.

`items.price_cents` keeps NOT NULL and gains a default of 80.00, so an arriving item is always priced. That is the decision taken in the design review over making the column nullable: it costs the schema-level guarantee that nothing can publish at a price nobody chose, and buys not having to teach the cart, the checkout and thirteen other files about an item without a price. The protection moves into the review queue, and `price_source` exists so that queue can say whether a number came from a model, the default, or a person.

The number lives in the migration rather than in configuration. 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.

Verified up, down and up again rather than only forwards — an irreversible migration is one that cannot be tested. Then verified by inspection rather than assumption: the default reads 8000, both tables and the state index exist, and an item inserted with no price comes back at 8000.

Backend: 263 integration, 302 unit, all passing against the new schema.

Ref #222
2026-08-31 15:42:25 -05:00
bermudalamb 7d8ac15ef8 docs(intake): refresh the extraction task for the changes #226 made (#222)
This plan was written on 2026-08-29, before #226 landed. Its Task 2 lists what to move out of routes/admin.ts into the shared image pipeline, and that list is now missing `stripUploadedImages` and the `reencodeInPlace` import it depends on, because neither existed when the list was written.

Executing it as written would have left the re-encode behind in admin.ts, and the public intake route added in Task 5 would then have had an upload path that skips EXIF stripping entirely. That is precisely what #226 exists to prevent — a stranger photographing an item at home publishing the coordinates it was taken at — and nothing in the suite would have failed to say so, because the intake tests are written against a route that does not exist yet.

The task now names the function, says why it matters, and adds a check with a definite answer: after the move, routes/admin.ts must no longer import imageProcessing. If it still does, something was left behind.

Ref #222, #226
2026-08-31 15:42:25 -05:00
bermudalamb 7100352d98 Merge pull request 'chore(ci): remove the registry spike workflow (#237)' (#249) from chore/237-remove-spike-workflow into main
Linting / lint (push) Successful in 2m27s
SonarQube Analysis / sonarqube (push) Successful in 20m1s
Reviewed-on: #249
2026-08-31 15:40:34 -05:00
bermudalamb f5deb31370 chore(ci): remove the registry spike workflow (#237)
Linting / lint (pull_request) Successful in 2m32s
SonarQube Analysis / sonarqube (pull_request) Successful in 19m39s
The spike is answered, so the throwaway goes as it always said it would.

What it established. The runner can build images once a docker CLI is installed — the socket was mounted all along and only the client was missing. The container registry works and accepts pushes; it had never been exercised, so that was genuinely unknown. TLS from the runner to the Gitea host is trusted, which also answers the certificate half of the Portainer question. A personal access token with write:package authenticates where the token Actions injects automatically does not. And a full image build and push costs 9m14s on that runner.

What it disproved, which was the point. A CI-built image still reports `commit: "unknown"`. #235 removed `COPY .git` from the Dockerfile to stop the version stamp breaking every Portainer deploy, so it does not matter that an Actions checkout has history — the Dockerfile never copies it. Build location was never the problem, and moving builds to CI would have delivered nothing on its own. #248 carries the actual fix, a build arg, which is a few lines and does not need the registry at all.

Deleting this from main rather than only from the spike branch: it was merged here in #238, before iteration 2 showed that workflow_dispatch fires from a branch and a spike never needed to reach main at all.

Still owed by hand: the spike-trivial and spike-3085970 packages in the registry.

Closes #237
2026-08-31 14:45:11 -05:00
bermudalamb a800533be7 Merge pull request 'test(e2e): design, plan, and the find-or-fail helper (#241)' (#247) from fix/241-e2e-isolation into main
Linting / lint (push) Successful in 2m21s
SonarQube Analysis / sonarqube (push) Successful in 20m11s
Reviewed-on: #247
2026-08-31 14:44:14 -05:00
bermudalamb 64324609e1 test(e2e): add a find-or-fail helper for collection lookups (#241)
Linting / lint (pull_request) Successful in 2m10s
SonarQube Analysis / sonarqube (pull_request) Successful in 19m45s
Nine sites across five specs do `collection.find(...)` and dereference the result immediately. When the row is missing the test dies with "Cannot read properties of undefined" naming a line of test plumbing, which says nothing about what was expected — and that is exactly how favorites-filter:169 failed without producing a usable signal.

The message names what was wanted and how many rows were searched. That distinction carries real diagnostic weight: "0 rows" means the fixture never landed, "37 rows" means it landed and the predicate is wrong, and those are different bugs to chase.

It lives in its own module importing nothing, rather than in support/api.ts. That file imports @playwright/test, and vitest.config.ts runs tests/unit with environment: 'node' — putting six lines of pure logic there would drag a browser harness into the unit suite to test them. api.ts re-exports it so specs still reach it through fixtures.

Throws rather than returning null, because every caller wants the row: an error at the point of the miss beats a null threaded through three more lines before something unrelated fails.

Frontend: 30 unit tests pass, lint unchanged at 2 pre-existing warnings, build clean.

Ref #241
2026-08-30 17:02:07 -05:00
bermudalamb d16aba1647 docs(test): plan the e2e trustworthiness work (#241)
Four tasks. A pure findOrFail helper with Vitest coverage, the nine unchecked lookups converted to use it, the expect timeout raised from Playwright's unset default of 5s to 10s, and the test #245 skipped brought back.

The helper deliberately imports nothing and lives apart from support/api.ts. api.ts imports @playwright/test, and vitest.config.ts runs with environment: 'node' over tests/unit only — putting the helper there would drag a browser harness into the unit suite to test six lines of pure logic.

Two things the survey changed. filters.spec.ts:216 is excluded: its .find() searches CSS class names on a string array rather than test data, so it has no missing-row failure mode. And three `expect(row).toBeTruthy()` assertions are deleted rather than kept, because findOrFail has already thrown by then — leaving them would tell the next reader the value might be falsy, which is the confusion the change exists to remove.

Worker-count reduction is explicitly not in this plan. It is a real lever and may still be needed, but applying it at the same time would make it impossible to tell which change fixed anything.

The plan states the criterion it cannot check: CI's load is not reproducible here, so two consecutive green local runs mean the refactor is sound, not that the flakiness is gone. Several consecutive green CI runs are the real bar, and #241 stays open until then.

Ref #241
2026-08-30 16:52:05 -05:00
bermudalamb f8b2b68f0d docs(test): design for making the e2e suite trustworthy (#241)
Five distinct specs failed across two runs of identical code with no overlap between the sets, so which test fails is decided by the scheduler. The cost is already being paid: #245 skipped the only end-to-end check that adding an item reaches the database, purely to get main green.

The design separates two mechanisms that had been treated as one. Unchecked lookups into shared collections — `collection.find(...)` dereferenced immediately, found at eight or more sites — are a defect regardless of concurrency: when the row is missing the test dies with "Cannot read properties of undefined" naming test plumbing rather than failing an assertion that says what it wanted. Load-induced timing is the other, and is the larger share of what has actually been observed: three of four local failures and the CI one are assertions in a spec's own browser context that nothing else can touch.

Two claims from earlier in this investigation are retracted in the document rather than quietly dropped. The verification-resend limiter is not a shared axis — it is keyed per customer and every test registers its own — and the suite contains no snapshot-style assertions, so there is nothing to convert to web-first. Both were stated as fact on the issue, and both would have justified work that was not needed.

Per-worker databases are ruled out structurally: every worker talks to one backend on :3000, so isolation there means N backends, not N databases. Worker-count reduction is deliberately deferred rather than taken now, because applying it at the same time would mask whether fixing the defects worked.

The honest limit is recorded too. CI's load cannot be reproduced here on demand, so the timing changes rest on reasoning rather than a red-to-green demonstration, and the success criterion is several consecutive green runs rather than one.

Ref #241
2026-08-30 16:45:22 -05:00
bermudalamb cd4618e266 Merge pull request 'test(e2e): skip the admin save happy-path test while #241 stands (#245)' (#246) from fix/245-skip-flaky-admin-save into main
Linting / lint (push) Successful in 2m37s
SonarQube Analysis / sonarqube (push) Successful in 20m20s
Reviewed-on: #246
2026-08-30 16:36:11 -05:00
bermudalamb 66c696ce40 test(e2e): skip the admin save happy-path test while #241 stands (#245)
Linting / lint (pull_request) Successful in 2m5s
SonarQube Analysis / sonarqube (pull_request) Successful in 20m53s
`main` has been failing on one e2e test since the sold-filter fix landed, and it is a different test from the one #239 corrected: admin-save-failures' "saves an item successfully when the server accepts it".

Skipped rather than fixed, deliberately. It fails in CI and passes locally, and which test fails moves around — a local parallel run of the whole suite on the same commit failed four *different* specs (admin-inventory-filters, auth, favorites-filter, resend-verification) and not this one. That is #241: fullyParallel against a single shared database. Fixing this test on its own would be guessing at a symptom that reappears somewhere else next run.

Ruled out before disabling anything: the re-encoding from #226 is not involved. AdminInventory.addItem fills a name and a price and saves, attaching no files, so stripUploadedImages iterates an empty array and the image path is never entered. Checked rather than assumed, because this spec is on the admin save route and that is exactly where a regression of mine would surface.

What this stops covering is not trivial, and the comment says so at the call site: it is the only end-to-end check that adding an item actually reaches the database rather than merely firing a toast. #245 exists so that it is un-skipped when #241 lands, rather than left behind. A skipped test on the core admin save path is worse than a red build, because a red build is at least visible.

Ref #245, #241
2026-08-30 16:20:28 -05:00
bermudalamb feb714c49d Merge pull request 'test(perf): stop hashing test passwords at production cost (#242)' (#243) from fix/242-bcrypt-cost-in-tests into main
Linting / lint (push) Successful in 2m6s
SonarQube Analysis / sonarqube (push) Failing after 19m6s
Reviewed-on: #243
2026-08-30 13:32:14 -05:00
bermudalamb d7dacffa11 test(perf): stop hashing test passwords at production cost (#242)
Linting / lint (pull_request) Successful in 2m10s
SonarQube Analysis / sonarqube (pull_request) Successful in 19m15s
The integration suite registers around thirty-five customers and asserts nothing about any of their hashes, yet paid bcrypt cost 12 for every one. bcryptjs is a pure-JS implementation, so it pays that cost several times over compared with a native build, and hashing was most of the suite's wall clock. On a contended runner it pushed adminInventory.integration.test.ts past its twenty-second timeout, which then surfaced as a foreign key violation somewhere else entirely — the test timed out, jest moved on, beforeEach truncated, and the still-in-flight registration wrote a token for a customer that had just been deleted.

Measured rather than asserted, warm run against warm run with only the constant changed: 34.5s at cost 12, 9.8s at cost 4. Three and a half times faster, about twenty-five seconds off every integration run, with all 263 tests passing either way.

The first attempt at that measurement was wrong and worth recording. Comparing a cold run at cost 4 against a warm run at cost 12 made the change look like a 36% regression-shaped improvement of the wrong size; the difference was ts-jest and Postgres warming up, not the cost factor. Both numbers above are second runs, and the cost-12 figure was taken twice — 34.3s and 34.5s — before being believed.

Deliberately not configurable. An environment variable here would be a way to weaken password hashing in production by misconfiguration, and nothing needs to tune it. The only route to the cheap cost is NODE_ENV=test, which a deployed container would announce anyway by refusing to serve the built frontend, since app.ts gates static serving on the same value. A setting that quietly degrades a security property should be unreachable rather than warned about, which is the reasoning that already made DEMO_MODE strict.

`hashRoundsFor` is pure and separately tested because the failure it guards against is silent: only the exact string 'test' earns the cheap cost, and an unset NODE_ENV gets the strong one, so the dangerous direction has to be asked for explicitly. Both constants are pinned by assertions too — without that the branch tests pass while the numbers drift to something useless.

Closes #242
2026-08-30 12:29:19 -05:00
bermudalamb 2ba35f8732 Merge pull request 'fix(test): correct the sold-filter tally assertion stranded by #188 (#239)' (#240) from fix/239-sold-filter-tally into main
Linting / lint (push) Successful in 3m10s
SonarQube Analysis / sonarqube (push) Failing after 23m29s
Reviewed-on: #240
2026-08-29 19:22:12 -05:00
bermudalamb 30227fb1e1 fix(test): correct the sold-filter tally assertion stranded by #188 (#239)
Linting / lint (pull_request) Successful in 2m20s
SonarQube Analysis / sonarqube (pull_request) Successful in 21m4s
main has been red since 2026-08-25. Every SonarQube run reported 147 passed, 1 failed, and it was this test every time — expected "Filters", received "Filters (1)".

The test is stale, not the code. It was last touched on 2026-08-23 in #137; the tally logic changed on 2026-08-25 in #188, which never touched the spec. #188 redefined the tally as the number of chips and moved the availability preset into the dimension system as a bar dimension — one that still emits a chip for any non-default status, deliberately, because without it `?status=reserved` is an empty grid with no Clear filters button and no way out but editing the URL. The test asserted the rule that held before that change.

Counting bar chips differently from drawer chips would restore exactly the per-screen special-casing #188 removed, and the drift it fixed was the admin's tally disagreeing with the storefront's. So the assertion moves, not the tally.

The replacement also checks the tally comes back down when the default is restored. The original only ever asserted one direction, which would pass against a count that incremented and never decremented — worth fixing while the test is open rather than leaving a second gap behind the first.

There is a real wart left standing: `Filters (1)` opens a drawer with nothing selected in it, because the filter it is counting lives in the bar. That is a cost of #188's design rather than a defect in it, and the comment now says so rather than leaving the next reader to rediscover it.

Verified by running the spec in isolation with a single worker: 6 passed.

Closes #239
2026-08-29 19:18:27 -05:00
bermudalamb 65fcba24f2 spike(ci): probe whether the runner can build and push an image (#237)
Linting / lint (pull_request) Successful in 2m19s
SonarQube Analysis / sonarqube (pull_request) Failing after 22m6s
Linting / lint (push) Successful in 2m14s
SonarQube Analysis / sonarqube (push) Failing after 23m19s
Throwaway. Deleted once #237 has an answer, whichever way it goes.

#233 was designed on an assumption about the Docker build context and broke every Portainer deploy. The chosen fix for the commit stamp — build in Actions, push to Gitea's registry, have Portainer pull — rests on three more assumptions about infrastructure that nothing in this repository can confirm. This probes them instead of designing around them.

Whether a job here can run `docker build` at all is genuinely unknown: the other workflows use `services:`, which proves the runner can start containers, not that it can build images. Whether the registry is usable is likewise unconfirmed — the packages API answers but lists nothing, so it has never been exercised. And the real build is timed because backend-integration.yml is manual after a job once held this runner for 3h12m, so what an image build costs here is part of deciding whether building on every merge is tenable at all.

The trivial image is built and pushed before the real one on purpose. It separates "can this runner build and push anything" from "does our Dockerfile work here", so a later failure still says which half is broken.

workflow_dispatch only, with no push or pull_request trigger, so merging this changes nothing until somebody presses the button. Bounded by timeout-minutes on the same reasoning backend-integration.yml already documents.

Ref #237
2026-08-29 18:32:27 -05:00
bermudalamb 71f8a21e45 Merge pull request 'fix(build): stop the version stamp from breaking every Portainer deploy (#235)' (#236) from fix/235-remove-git-copy into main
Linting / lint (push) Successful in 2m13s
SonarQube Analysis / sonarqube (push) Failing after 21m38s
Reviewed-on: #236
2026-08-29 17:07:36 -05:00
bermudalamb 1c69ac71f6 fix(build): stop the version stamp from breaking every Portainer deploy (#235)
Linting / lint (pull_request) Successful in 2m13s
SonarQube Analysis / sonarqube (pull_request) Failing after 21m57s
`COPY .git ./.git`, added in #233, fails with `"/.git": not found` in Portainer's build context, so every stack deploy died before anything else ran.

This is the exact outcome #233 set out to prevent. That issue states that a version stamp must never be the thing that stops a deploy, and the resolution code honours it — every unreadable-.git path returns "unknown" and warns. The guard was simply in the wrong layer: COPY fails at image-build time, long before any of that code executes. Graceful degradation in the application buys nothing once the Dockerfile has refused to build.

The assumption came from the local build context, where there is no .dockerignore and .git is therefore present. It was checked with a local `docker build`, which passed, and never against the only environment that actually deploys.

Verified properly this time, by building from `git archive HEAD` — a context containing exactly the tracked files and no history, which is what a clean checkout gives. That build now succeeds and stamps `commit: "unknown"` with a real `builtAt`. The failing case is reproduced and fixed rather than reasoned about.

`commit` will read "unknown" wherever Portainer builds. `builtAt` is still real, and is the half that matters most: Portainer already reports which commit it cloned, but cannot tell you whether the running container is that build. A build time can, and a stale one is exactly what the QA incident earlier today would have shown. Locally nothing changes — writeBuildInfo reads ../.git directly and still resolves a real commit.

Sourcing the real commit inside a Portainer build needs a different mechanism, and #235 records the three candidates rather than guessing at a fourth.

Closes #235
2026-08-29 15:51:52 -05:00
bermudalamb a685229527 Merge pull request 'feat(admin): show the deployed commit and build time in the admin (#233)' (#234) from feature/233-admin-version-stamp into main
Linting / lint (push) Successful in 2m14s
SonarQube Analysis / sonarqube (push) Failing after 21m19s
Reviewed-on: #234
2026-08-29 15:45:54 -05:00
bermudalamb 44328d0b5c feat(admin): show the deployed commit and build time in the admin (#233)
Linting / lint (pull_request) Successful in 2m38s
SonarQube Analysis / sonarqube (pull_request) Failing after 29m40s
There was no way to tell which build an environment was running. That is not hypothetical: minutes after #232 merged, `npm run backfill:images` in QA failed with `tsx: not found` because the container was still serving the pre-merge image, and the only thing that revealed it was npm echoing the 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.

The header now reads something like `a5076cc · built 29 Aug 20:36`. The commit answers "is this the code I expect"; the build time answers "did my redeploy actually rebuild", which is a different question and the one that would have caught the case above.

The commit is read out of `.git` directly rather than by shelling out, because node:20-bookworm-slim has no git binary and adding an apt layer so the image can print seven characters is a poor trade. `.git` is copied into the build stage only — verified absent from the final image — so no repository history reaches a deployed container.

Resolution is pure and separately tested across every shape that actually occurs: a detached HEAD holding the object name, which is what a checkout of a ref produces; a symbolic HEAD followed to a loose ref file; the same followed to packed-refs, which is what a fresh clone commonly has; peeled `^` tag lines ignored so an annotated tag cannot yield the wrong commit; and every failure path returning `unknown`. That last part is the one that matters most — this runs during a Docker build, and a version stamp must never be the thing that stops a deploy.

Served from a gated /api/admin/version rather than folded into /api/config. That endpoint is public, and a commit hash there would tell any storefront visitor exactly which revision of a public repository is deployed. An integration test asserts the gate and asserts the public config does not carry it, because the boundary is the whole point rather than an implementation detail.

Verified in the built image rather than argued: the stamp inside it reads a5076cc, matching `git rev-parse --short HEAD`, and a running container serves it from /api/admin/version while /api/config returns only what it did before.

Backend: 296 unit, 263 integration, tsc clean, lint unchanged at six pre-existing warnings. Frontend builds clean with its two pre-existing warnings untouched.

Closes #233
2026-08-29 15:37:12 -05:00
bermudalamb a5076cc217 Merge pull request 'fix(uploads): ship the image backfill script in the container image (#231)' (#232) from fix/231-ship-backfill-script into main
Linting / lint (push) Successful in 2m54s
SonarQube Analysis / sonarqube (push) Failing after 20m52s
Reviewed-on: #232
2026-08-29 15:10:06 -05:00
bermudalamb 167c8ad97c fix(uploads): ship the image backfill script in the container image (#231)
Linting / lint (pull_request) Successful in 3m48s
SonarQube Analysis / sonarqube (pull_request) Failing after 21m6s
`npm run backfill:images` could not run in QA or production. Three reasons, each sufficient alone: tsconfig includes only `src`, so `scripts/` was never compiled; the Dockerfile copies `dist`, `migrate.js` and `migrations` and never `scripts/`; and `tsx`, which the npm script invoked, is a devDependency that `npm install --omit=dev` strips from the final stage. The half of #226 that closes the exposure on already-stored photos had no way to run where the photos are.

Moved to `src/backfillImageReencode.ts` so it compiles into `dist` and ships. Both of its runtime dependencies, sharp and pg, were already production dependencies, so the image needs nothing else. `scripts/bench-hash-latency.ts` was the pattern followed originally, and it is a development tool that never needs to run deployed; this one is an operational task that can only be useful where the images are, which makes `migrate.js` the right precedent instead.

The npm script now runs the compiled output rather than tsx, so one command behaves identically on a laptop and inside a container. The entry point is guarded with `require.main === module`: putting a catalogue-wide irreversible rewrite in the same directory the server imports at boot means an accidental import would otherwise run it, and nothing should depend on people continuing not to write that import.

Proven in the built production image rather than argued. `node_modules/.bin/tsx` and `scripts/` are both absent from it, and `npm run backfill:images` still runs: report mode found the planted file, `--apply` rewrote it 35760 to 16019 bytes, a second `--apply` reported skipped 1 processed 0, and on the mounted volume the EXIF was gone with the image bounded to 2000x1333 and still JPEG. That is the exact scenario the previous version would have failed.

Backend: 285 unit, 260 integration, tsc clean, lint unchanged at six warnings, all six pre-existing.

Closes #231
2026-08-29 15:03:44 -05:00
bermudalamb 846646f7ec Merge pull request 'fix(uploads): re-encode uploaded images to strip EXIF and cut stored bytes (#226)' (#229) from feature/226-strip-exif into main
Linting / lint (push) Successful in 2m15s
SonarQube Analysis / sonarqube (push) Failing after 21m16s
Reviewed-on: #229
2026-08-29 14:17:44 -05:00
bermudalamb 32c1f23379 Merge main into feature/226-strip-exif
Linting / lint (pull_request) Successful in 2m27s
SonarQube Analysis / sonarqube (pull_request) Failing after 22m53s
main gained the Drizzle spike (#216) after this branch was cut, and both changes add a production dependency, so `backend/package-lock.json` conflicted. `backend/package.json` merged cleanly and carries both `drizzle-orm` and `sharp`.

The lockfile was regenerated rather than hand-merged: main's version taken as the base, then `npm install` re-resolved it. That install was deliberately run under Node 24 rather than the machine's default 18.16.1, because sharp's platform binaries are optional dependencies that npm silently omits when the engine check fails — regenerating this file on Node 18 would have quietly dropped every `@img/sharp-*` entry and produced a lockfile that installs a sharp which cannot load. Verified afterwards that linux-x64, linux-arm64 and win32-x64 are all present and that drizzle-orm survived.

Backend: 285 unit tests pass, tsc clean. Lint reports six warnings rather than three; the three new ones are in src/db-drizzle from the spike, not from this branch.

Ref #226
2026-08-29 14:10:17 -05:00
bermudalamb fe8f2aed90 Merge pull request 'spike(db): evaluate Drizzle and Tinqer against the hardest query we have (#216)' (#230) from feature/216-drizzle-spike into main
Linting / lint (push) Successful in 2m30s
SonarQube Analysis / sonarqube (push) Failing after 22m42s
Reviewed-on: #230
2026-08-29 14:04:45 -05:00
bermudalambandClaude Opus 5 664a0c30ed spike(db): evaluate Drizzle and Tinqer against the hardest query we have (#216)
Both libraries converted the same target — `buildItemFilterSql`, six clauses composed at run time, a recursive CTE for the category subtree, an `ANY(...::int[])` tag match with a count equality. Nothing in `src/routes` or `src/itemFilters.ts` is touched; this branch only adds spike artifacts alongside them.

Drizzle cleared the blocker the issue named first. `backend/tsconfig.json` is `module: commonjs` and Drizzle is ESM-first, but it compiles under the existing config and requires at runtime, so no ESM migration is hiding inside this one.

`drizzle-kit pull` introspected all sixteen tables plus `pgmigrations`, 104 columns, 8 indexes and 20 foreign keys, and got the hard parts right: the self-referencing `categories.parent_id`, and both partial unique indexes with `lower(name)` and their `WHERE` predicates.

The converted filter produces byte-equivalent results. Five filter combinations run against the dev database return identical id lists to the current implementation, including the recursive subtree — 1805, 2145, 4, 1918 and 2145 rows respectively.

The injection question the issue asked about is answered yes, and it is stronger than expected. In a Drizzle `sql` template `${value}` emits a bind parameter, not text, so there is no way to spell "interpolate this as SQL" by accident. Feeding `"1); DROP TABLE items; --"` as a status produced it in the parameter array and nowhere in the query text. That is the #202 invariant enforced by the type system rather than by a comment and two tests.

Two Drizzle findings worth having before committing to 187 call sites. Arrays do not bind the way the raw driver does: `${array}` expands into a placeholder list, so `ANY(($1, $2)::int[])` type-checks, reads correctly, and fails at run time as invalid Postgres. `sql.param()` is required, and nothing warns. And the first generated migration after a pull carried spurious drops and recreations of the three expression indexes; re-running with no schema change reports nothing to migrate, so it settles rather than recurring, but that first migration would need hand-editing.

Tinqer is genuinely LINQ-to-SQL — it parses the lambda with OXC at run time and compiles a real expression tree — and it cannot express this query. Compound conditions and array membership work. A ternary fails. A block body with an `if` fails. Those are the only two ways to make a clause optional inside the lambda, and there is no raw-SQL escape hatch in its API, so six independent optional clauses would mean 64 hand-written plans or neutral sentinels that do not exist for the category and tag clauses.

Its failure mode compounds that: `defineSelect` parses eagerly and throws, so an unsupported query type-checks cleanly and crashes when the module is first required. `src/db-tinqer/probe.ts` wraps every case in a function for that reason.

It is also `0.0.27` with 24 stars, and its Postgres support is a `pg-promise` adapter rather than the `pg` driver already in use.

I was wrong earlier to say LINQ-to-SQL is impossible in TypeScript because it needs C# expression trees. Tinqer reconstructs the tree by parsing the lambda source. The claim should have been that it is possible and rare, and the constraint is what the parser accepts.

Verified: backend build clean, 280 unit tests and 255 integration tests pass, unchanged by this branch.

Refs #216

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 14:04:45 -05:00
bermudalamb 502d56d9fd feat(uploads): backfill re-encoding over already-stored photos (#226)
Linting / lint (pull_request) Successful in 1m56s
SonarQube Analysis / sonarqube (pull_request) Failing after 16m51s
Stripping new uploads does nothing for the catalogue that is already on the storefront, which is where the exposure actually lives today. This is the other half.

Reports by default and rewrites nothing without --apply, because the transform is lossy and there is no undo. Idempotency comes from `needsProcessing` rather than from a marker or a schema change: a file with no EXIF already inside the bounds is already in its final state, so a second run skips it instead of putting it through another lossy pass. Proven rather than assumed — a second --apply immediately after the first reports skipped 1, processed 0.

Verified end to end against a real row and a real file. 3000x2000 carrying GPS EXIF became 2000x1333 with the metadata gone, 35760 bytes down to 16019, the format preserved, no temporary file left behind, and `item_images.image_path` untouched. That last part is what preserving the format bought: the backfill rewrites bytes and writes nothing to the database, so there is no window where a row points at a file that no longer exists.

Both degenerate branches are exercised too, since a script that dies partway through a catalogue leaves the rest of it exposed: a row pointing at a missing file and a row with an extension the application would refuse to serve are each reported and counted, and the run continues.

`handleRow` is split out of `run` for cognitive complexity, and while doing that a miscount was introduced and caught — incrementing `processed` before the rewrite meant a file that threw would have been counted as both processed and failed, which makes the summary unreadable at the moment it matters most.

Ref #226
2026-08-29 13:51:26 -05:00
bermudalamb aecccef418 feat(uploads): strip metadata from every accepted upload (#226)
Hooked into uploadImages rather than into the routes. That middleware is where verifyUploadedImages already runs and is the single choke point every upload path passes through, so the admin create and update routes are both covered and the intake route from #222 will inherit it rather than having to remember. The same reasoning discardUnlessAccepted already gives for being a hook instead of a call.

Runs after verification, deliberately: re-encoding a file whose bytes do not match its declared type would be work on something already refused, and sharp's error would replace the clearer message that check produces. A re-encode failure refuses the upload rather than storing the original, because the one case where a photo keeps the coordinates it was taken at should not be the case nobody was told about.

The test builds a JPEG carrying GPS tags rather than committing a binary fixture, so what it contains is readable, and it asserts the fixture really carries EXIF before asserting the stored file does not — otherwise the test would pass while proving nothing. GPS tags go in IFD3, which is the GPS IFD as libvips names it; sharp's Exif type has no separate GPS key, and putting them in IFD0 would have produced EXIF without producing the tags this issue is about.

Backend suites: 285 unit, 260 integration, lint clean, build clean.

One caveat worth recording. Across three full integration runs, `uploadValidation` failed once on "removes the upload when the request is refused for its other fields". It is a pre-existing race rather than a regression: discardUnlessAccepted cleans up in an unawaited `void discardUploads(...)` inside a `res.on('close')` handler, so a test asserting on the directory immediately after the response has always been able to observe the state before the unlink lands. Re-encoding adds enough libvips work to lose that race occasionally where it previously did not. The property still holds in production, where the process keeps running and the unlink completes. Filed separately rather than fixed here.

Ref #226
2026-08-29 11:54:34 -05:00
bermudalamb e85be0f970 feat(uploads): re-encode images to strip metadata and bound dimensions (#226)
Re-encoding rather than deleting tags. Deleting requires knowing every tag that could carry something sensitive, across formats and camera makers, indefinitely; rebuilding the file from decoded pixels leaves nothing that could have been missed. The same reasoning that makes uploadTypes.ts an allowlist rather than a denylist.

`needsProcessing` is pure and separately tested because it is the whole of the backfill's idempotency argument: a file with no EXIF already inside the bounds is already in its final state, so a second run skips it instead of putting it through another lossy pass. Being wrong there degrades every image a little more on every run. Anything sharp cannot describe is processed rather than skipped, since a file we understand least is not one to assume is safe.

Verified end to end on a real image before wiring anything up: 3000x2000 with EXIF present became 2000x1333 with EXIF absent, and no temporary file was left behind.

Corrects something this README claimed an hour ago. Installing under a Node below 20.9.0 does produce a broken sharp, because npm skips the optional platform binary when the engine check fails and still reports success. But once that binary is present sharp loads and runs fine on 18.16.1 — `engines` is enforced at install time, not at require time. The README said the runtime was blocked, which would have sent someone switching Node versions to fix a problem that only the install created.

Ref #226
2026-08-29 10:55:33 -05:00
bermudalamb 7d45b69305 build(uploads): add sharp for image re-encoding (#226)
Verified where it actually has to run rather than only here: the production image builds and `require('sharp')` succeeds inside it on Node v20.20.2, linux/x64, with libvips 8.18.6 and `withExif` available, needing no build toolchain. The architecture question is already settled by this same node:20-bookworm-slim base running in production today, and sharp ships glibc prebuilds for both linux-x64 and linux-arm64, so it adds no constraint that deployment did not already satisfy.

Installing it locally found a trap worth recording. sharp requires Node >=20.9.0 and its platform binary is an *optional* dependency, so npm skips it when the engine check fails and still reports success. Installed under this machine's default 18.16.1 the result is a node_modules that looks complete and throws `Could not load the "sharp" module using the win32-x64 runtime` at require time — which reads as a broken package rather than as a wrong Node version. The fix is `npm install --include=optional sharp` under Node 20+, and the prevention is using start-local.ps1 or run-tests.ps1, which switch first.

`engines` is now declared so npm at least warns, and the README's existing Node 20 section says what the failure looks like, since the error message names a runtime rather than a version and points nowhere useful.

The lockfile carries every platform variant including linux-x64 and linux-arm64, so a build on another platform resolves correctly. The Dockerfile does not copy the lockfile at all and installs fresh, so this matters for contributors rather than for the image.

Ref #226
2026-08-29 10:45:01 -05:00
bermudalamb 5a3c0db0bc Merge pull request 'docs(intake): design and implementation plans for the intake pipeline (#220)' (#221) from feature/220-intake-pipeline-design into main
Linting / lint (push) Successful in 2m0s
SonarQube Analysis / sonarqube (push) Failing after 16m53s
Reviewed-on: #221
2026-08-29 10:32:47 -05:00
bermudalamb 65f9d00785 docs(uploads): plan the EXIF stripping and re-encode (#226)
Linting / lint (pull_request) Successful in 1m59s
SonarQube Analysis / sonarqube (pull_request) Failing after 16m30s
Five tasks: prove sharp installs where it actually runs, the re-encode policy as a pure module, wiring it into the single middleware every upload path already passes through, the backfill over already-stored photos, and the deployment sequence.

Re-encoding rather than deleting tags. Deleting requires knowing every tag that could carry something sensitive, across formats and camera makers, indefinitely; rebuilding the file from decoded pixels leaves nothing that could have been missed. Same reasoning that makes uploadTypes.ts an allowlist.

Format is preserved rather than normalised to WebP. Converting would compress better but changes every stored extension, and therefore item_images.image_path, turning the backfill into a rename with a window where rows point at files that no longer exist. A privacy fix does not need that risk, and the backfill consequently touches no database rows at all.

The backfill is lossy and irreversible, so it reports by default and needs --apply, writes to a temporary file and renames so an interruption cannot leave a half-written image being served, and is idempotent by construction: a file already stripped and already within bounds is skipped rather than put through a second lossy pass. That property is a pure function with its own unit test, because being wrong about it degrades every image a little more on every run.

Two traps the plan handles that the issue only named. An animated WebP read without the animated flag decodes to a single frame and is silently written back as a still, so the flag is set for WebP and only WebP — it changes how resize reads height, which would be wrong for the other types. And sharp before 0.33 has no withExif, which the tests need to build their fixture; on an older version they fail as though the stripping were broken.

Ref #226
2026-08-29 10:24:20 -05:00
bermudalamb c76828122d docs(intake): guard the uploads volume and bound a link by default (#220)
Two tasks added to the slice-1 plan, closing the half of the upload gap the middleware ordering does not.

The ordering fix stops a caller with a bad token writing anything. A caller with a working one can still send six eight-megabyte files per request against a limiter that allows twenty requests a window, and nothing checks whether the volume can take it. That volume is shared with the admin upload path, so intake filling it is a shop outage rather than an intake outage.

Task 8 refuses an upload when less than a gigabyte remains, on the admin item routes as well as intake, failing closed because a volume that cannot be measured is not one to assume is empty. Task 9 makes an absent cap mean the bounded default of twenty-five rather than unlimited: the router as written treated omission as "no limit", so the ordinary act of creating a link produced an unbounded one, and a cap that has to be remembered is not a control.

Two larger findings are filed rather than folded in. Re-encoding uploads to strip EXIF and cut stored bytes (#226) touches the shared pipeline and adds a native dependency; a global ceiling with an abuse alert (#227) needs its own state and an email. The EXIF one is worth stating plainly: nothing strips metadata today, so an uploaded phone photo publishes the coordinates it was taken at, at a public URL. That is already true of the admin path and is not introduced here, but this slice widens who can put such a file there.

Ref #220
2026-08-29 09:03:33 -05:00
bermudalamb 783d10abc4 docs(intake): plan the upload-link and submission-page slice (#220)
The first of four slices from the intake design, and the only one that is worth planning in detail yet — the later slices' shape depends on what this one actually produces.

Seven tasks: the schema, extracting the validated image-upload pipeline out of routes/admin.ts so the public endpoint reuses it rather than growing a near-copy of it, token generation and hashing, the admin API for issuing and revoking links, the public submission endpoint, the submission page, and the admin screen.

Three things the plan settles that the design left open or got wrong. The image caps become the constants already in the codebase rather than the 10-photo and 10 MB figures the design invented, because two different caps on one pipeline is a defect waiting to happen. The feature flag is dropped from this slice: nothing is reachable until a link exists, and the flag earns its keep in slice 2 where a paid API call appears. And the link is resolved before multer runs, so a stranger holding a bad token cannot cause a byte to be written to the uploads volume — cleanup afterwards would leave an unauthenticated caller in control of disk churn, and leans on an unlink that a crash between write and delete would skip. That ordering is asserted by a test, so a later reordering fails loudly rather than silently.

Ref #220
2026-08-29 08:09:39 -05:00
bermudalamb 722bade383 docs(intake): price arriving items rather than leaving them unpriced (#220)
Linting / lint (pull_request) Successful in 1m59s
SonarQube Analysis / sonarqube (pull_request) Failing after 16m51s
`price_cents` stays NOT NULL and gains a default of 80.00. Where the model suggests a price the worker writes it onto the item; where it does not, the default stands.

This is cheaper to build than the nullable design it replaces — the column's type is unchanged, so the fifteen files that read `price_cents`, the cart and the checkout among them, keep working untouched, and the migration adds a default and nothing else.

It also gives up a guarantee. The storefront can now be reached by a price the admin never chose, so the protection moves out of the schema and into the review queue, where it is weaker. 80.00 is a plausible number rather than an obvious sentinel, so a default left unnoticed sells the item instead of announcing itself the way "$0.00" would. `price_source` is added to make that legible: the queue labels a price as coming from the model, the default, or the admin, and marks anything unconfirmed as such at the point of publishing. Publishing unconfirmed remains allowed — that is the decision taken — but it is stated rather than silent.

Publishing still happens only from the queue, and the notification email still carries no publish button.

Ref #220
2026-08-29 07:52:09 -05:00
bermudalamb dbe6a0cf8f docs(intake): design the upload-link, AI-draft and review-queue pipeline (#220)
Linting / lint (pull_request) Successful in 1m52s
SonarQube Analysis / sonarqube (pull_request) Failing after 16m43s
A named, revocable link lets someone without an account send in photos of one item plus a note; a background worker drafts the listing; the admin is emailed and publishes it deliberately from a review queue.

Most of the lifecycle already exists and is reused rather than rebuilt: `pending` has been the unpublished state since #90 and is already excluded from every public query, the upload path already validates magic bytes against a three-type allowlist, mail already has editable templates and an allowlist guard, and node-cron is already the background-work pattern. What is new is a way in for someone with no admin account, the first LLM integration in this codebase, and somewhere to review a draft.

The design turns on one invariant: nothing reaches the storefront at a price a model guessed. The suggested price lives on the draft and never on the item, the email carries no publish button, and the publish path refuses an item with no price. That is also why `price_cents` becomes nullable rather than defaulting to zero — a sentinel that formats as "$0.00" is the same class of quiet failure as `DEMO_MODE` once being "demo unless the value is exactly false", and nullability makes the compiler enumerate all fifteen call sites instead.

Ref #220
2026-08-29 07:43:47 -05:00
bermudalamb e933cd19f9 Merge pull request 'fix(cart): say what the demo button does rather than what the shop does (#203)' (#214) from feature/203-demo-notice-wording into main
Linting / lint (push) Successful in 2m3s
SonarQube Analysis / sonarqube (push) Failing after 17m56s
Reviewed-on: #214
2026-08-28 14:05:21 -05:00
bermudalambandClaude Opus 5 b897e99363 fix(cart): say what the demo button does rather than what the shop does (#203)
#195 added a notice reading "Demonstration only — This shop is not taking payments at the moment", gated on `demoMode` alone. That claim is false in a configuration the ops runbook actively steers towards.

`demoMode` and `paypalClientId` are independent. `checkDemoMode` and `checkPayPal` only make the PayPal secrets *required* when `DEMO_MODE=false`; nothing forbids them while it is `true`. And `production-stack-cutover.md:65` says flipping to `false` without all three crash-loops the container — so the only safe order is to populate the secrets while demo mode is still on, verify, then flip. In that window `Cart.tsx` renders live PayPal buttons directly beneath a banner telling the customer the shop takes no payments, and it is precisely the window in which someone is clicking around production checking their work.

That is the same failure #195 fixed, pointed the other way: silent where a warning was needed, then confidently wrong where a customer can actually be charged. Telling someone nothing will be shipped above a live PayPal button is worse than saying nothing.

The notice now describes the button instead of the shop, which is true in both configurations and stays visible in the one with two controls that do different things — where a customer most needs to be told they differ. Gating it on `!paypalClientId` would also have removed the false claim, by hiding the notice exactly there, which is the worse trade.

The test for it was also not testing it. "Says so before the customer commits" seeded an address with `isDefault: true`, and the cart auto-selects the default on load — so an address was already selected and the button already rendered when it asserted. It would have passed with the notice moved inside the `selectedAddressId` guard, which is the regression it exists to catch. It now seeds no address and asserts the notice is up while the checkout button is absent, which states the property directly.

Mutation-tested rather than assumed: moving the Alert inside that guard fails the new test, and would not have failed the old one.

Verified: 3 end-to-end tests pass against a browser, frontend build clean, lint 0 errors (2 pre-existing warnings in `src/filters.ts`).

Closes #203
Refs #195

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 14:05:21 -05:00
bermudalamb ec08a73ebc Merge pull request 'docs(security): put the SQL injection invariant where it is enforced (#202)' (#212) from feature/202-sql-invariant into main
Linting / lint (push) Successful in 2m6s
SonarQube Analysis / sonarqube (push) Failing after 17m7s
Reviewed-on: #212
2026-08-28 14:04:21 -05:00
bermudalambandClaude Opus 5 c704c07b89 docs(security): put the SQL injection invariant where it is enforced (#202)
#180 cleared the three `typescript:S2077` hotspots and marked them Reviewed/Safe on the dashboard, but the repository half never reached main — the branch carrying it was deleted before merge, so the markers are cleared, the issue is closed, and nothing in the code said why. That is the exact state #180 set out to avoid: "the justification has to live in the repository, not only in SonarQube's UI".

The three call-site comments are restored, with two corrections a review of the original found.

They were in the wrong file. `buildItemFilterSql` is where the rule actually lives: both callers splice its clauses straight into query text, so only a placeholder index may ever be interpolated into one and every value must go onto `params`. That function's header said nothing about it, and it is where a seventh clause would be added.

This matters more than ordinary comment placement because of how a cleared hotspot behaves. Reviewed/Safe stays marked and does not re-raise when a *different* file changes, so the one edit that would break this — interpolating a filter value in `itemFilters.ts` — was the one edit that would have got neither a warning nor a fresh marker.

`items.ts` gets the same note. It builds `${PUBLIC_ITEM_SELECT} WHERE ${where}` from the identical construct and is reachable without signing in, but SonarQube never flagged it, so the higher-exposure copy was the undocumented one. It also records why joining with AND cannot weaken `EXCLUDE_PENDING`: no fragment carries a top-level OR for the join to re-associate against.

The wording was slightly false. "The single interpolation is `$${next}`" — the tags clause also interpolates `$${next + 1}`. Same category, so the argument is untouched, but a reader checking it literally finds a counter-example immediately, and a comment asserting safety cannot afford that.

Two tests make the invariant fail a build rather than depend on being read. One feeds values built by hand rather than parsed — `"1); DROP TABLE items; --"` in every field — and asserts none of it reaches the clause text, which states directly that these literals are safe with no parser at all. The other asserts two disjoint filter sets produce byte-identical SQL, which catches a value that happens not to look hostile.

Both were mutation-tested rather than assumed: interpolating `filters.minPriceCents` into the price clause — the precise edit the comment forbids — fails both, and one pre-existing test besides. Reverted, and the diff against main for `itemFilters.ts` is comment-only.

Verified: backend build clean, 280 unit tests pass.

Closes #202
Refs #180

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 14:04:21 -05:00
bermudalamb 7421676568 Merge pull request 'fix(email): stop a demo purchase telling real customers an item sold (#206)' (#211) from feature/206-no-demo-sale-emails into main
Linting / lint (push) Successful in 2m12s
SonarQube Analysis / sonarqube (push) Failing after 16m59s
Reviewed-on: #211
2026-08-28 14:02:07 -05:00
bermudalambandClaude Opus 5 41840d4890 fix(email): stop a demo purchase telling real customers an item sold (#206)
A demo purchase called `notifyFavoritersOfSale`, which mails everyone who favorited the item through production's configured SMTP: "An item you favorited has been sold to another customer, so it is no longer available… this one will not be restocked."

Nobody bought it and nobody is shipping anything, so both halves are false. It is also the only outbound consequence a demo purchase has — everything #195 and #203 fixed is on screen, in front of the person who clicked and who has now been told it is a demo. These recipients never saw the cart. They just get told something they cared about is gone, and while production runs the demo interim (#191) they are real customers on real SMTP.

The demo route no longer notifies. The PayPal capture and webhook paths are untouched, because those are sales.

The item is still marked `sold`, so the storefront stays truthful about availability and the favoriter who goes looking finds what the database says. Only the claim that somebody bought it goes away. That a demo purchase permanently consumes real production inventory is a larger question than this issue and is left alone.

Removing the call broke two tests and quietly hollowed out three more, which is the more interesting half of this change. Five tests in `favorites.integration.test.ts` used the demo purchase as a convenient way to make a sale happen; with the notification gone, the two asserting mail *is* sent failed, and the three asserting it is *not* sent would have passed for the wrong reason for ever.

They were always about who gets told rather than about the demo route, so they now call the notifier the way the PayPal routes do — after the purchase, with the sold ids and the buyer. `buyThenNotify` says so at the point of use. Route-level coverage is unaffected: the admin mark-sold path already had its own test, and the new test asserts the demo route notifies nobody.

Both halves were mutation-tested rather than assumed. The new test fails without the fix. Dropping the buyer exclusion from `collectFavoriteRecipients` fails "does not tell the buyer their own purchase is unavailable" and "emails every opted-in favoriter except the buyer" — so the restored tests are guarding the logic again rather than passing on an empty inbox.

Verified: 255 integration tests pass (the suite needs `--runInBand`; these share one database), 278 unit tests pass, backend build clean.

Closes #206

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 14:02:07 -05:00
bermudalamb a1948a50c9 Merge pull request 'docs(ops): stop the compose header contradicting itself, and name the four variables step 2 dropped (#204)' (#210) from feature/204-ops-doc-accuracy into main
Linting / lint (push) Successful in 2m4s
SonarQube Analysis / sonarqube (push) Failing after 18m26s
Reviewed-on: #210
2026-08-28 14:01:29 -05:00
bermudalambandClaude Opus 5 70d04186b1 docs(ops): stop the compose header contradicting itself, and name the four variables step 2 dropped (#204)
Three corrections a review of #196 found, all in text #196 itself rewrote.

**The compose header asserted something it falsified three lines later.** "All must be set in Portainer for this stack. All are secrets except DEMO_MODE" — but three PayPal entries are unused while `DEMO_MODE` is `true`, three more are marked Optional, and `SMTP_FROM` is not a secret. The runbook sends the operator to that exact block as authoritative, so an operator cutting over during the demo interim reads "all must be set", has no live PayPal credentials — which is the whole reason the interim exists — and either stops or invents a `BACKUP_PASSPHRASE`, which is how you get archives nobody can decrypt. The blanket claim is gone; each entry already says whether it is required and when.

**Step 2 enumerated nine of the thirteen interpolated names.** `USPS_CLIENT_ID`, `USPS_CLIENT_SECRET`, `UPLOADS_BASE_URL` and `BACKUP_PASSPHRASE` were missing. The USPS pair is the one that matters, and it now gets a sentence of its own: losing it is the only silent failure in this step. Address validation is skipped when those are empty rather than failing, so checkout keeps working and quietly stops validating addresses, with no crash loop and nothing in step 7 that would notice. The list also now says to take everything the stack holds rather than working from the list, because an enumeration reads as a checklist however it is introduced.

**The `DB_PASSWORD` failure was described wrongly, and its error names a variable the operator never typed.** It does not fail to authenticate against its data directory — the app never reaches a connection attempt, `checkAlwaysRequired` refuses at boot, and the message says `PGPASSWORD` because the compose file injects it as `PGPASSWORD=${DB_PASSWORD}`. An operator grepping for `DB_PASSWORD` finds nothing. That is now stated.

The crash-loop examples were reordered to match the case the section claims to be about. It headlines "the likeliest outcome of a missed step 2", but the only block shown was the PayPal triple, which cannot occur during the demo interim, and #196 replaced it with a `DEMO_MODE`-only block that is not what a missed step 2 produces either. A wholesale miss is two problems led by `PGPASSWORD`; that is now first, with the `DEMO_MODE`-only and mistyped-value forms after it.

Every quoted line was verified by running the real validator against production's entry set rather than by reading the source — `2 problem(s)` and `1 problem(s)` counts included — and all four now match character for character. The mistyped-value message is quoted in full rather than truncated, which was the point of the complaint that produced it.

`DEMO_MODE` is no longer called "the only one that is not a secret", which was false of `SMTP_FROM` and `UPLOADS_BASE_URL`. It is the only one that is a setting rather than a credential, which is true and a better hook.

Verified: 278 backend unit tests pass, including the compose guard that parses this file.

Closes #204
Refs #196

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 14:01:29 -05:00
bermudalamb 6562208995 Merge pull request 'fix(orders): mark a demo order in the history rather than leaving it to read as real (#205)' (#209) from feature/205-demo-order-history into main
Linting / lint (push) Successful in 2m2s
SonarQube Analysis / sonarqube (push) Failing after 17m13s
Reviewed-on: #209
2026-08-28 14:00:52 -05:00
bermudalambandClaude Opus 5 39c82ff3a4 fix(orders): mark a demo order in the history rather than leaving it to read as real (#205)
Linting / lint (pull_request) Successful in 2m13s
SonarQube Analysis / sonarqube (pull_request) Failing after 16m22s
#195 and #203 made the cart say a demo order is a demo order. That message is an antd toast lasting about three seconds, after which the cart empties and the card unmounts. Order history is what the customer comes back to when they wonder where their item is, and it said nothing.

A demo row was a real row: item name, `$80.00`, status `completed` rendered as a neutral tag because `STATUS_COLORS` has no `completed` key, and `demo` printed raw under a heading reading "Processor". That is not an explanation — a customer has no reason to read `demo` as "this did not happen", and "processor" is not a word they have any reason to know.

Two things now say it, for the same reason the cart needed two. The `demo` cell renders as a tag reading "Demo (not charged)", which marks *which* order. A notice above the table, shown only when there is one, says what that means — a tag reading "Demo" still assumes the reader knows what a demo order is, and what they actually want to know is whether to expect a parcel.

Nothing changes on the backend: `orders.processor = 'demo'` was already written at checkout and already selected for this page. The row is a real row in a real table and stays visible, because hiding it would be its own kind of lie — the customer did do something, and it did have an effect on the catalogue.

Written test-first against a browser: the new case drives a real demo purchase through the cart, opens `/orders`, and failed on both assertions before the change.

Verified: 4 end-to-end tests in this spec pass, the 5 existing order-history tests still pass, frontend build clean, lint 0 errors (2 pre-existing warnings in `src/filters.ts`).

Closes #205

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 12:22:39 -05:00
bermudalamb f0bdc590c9 Merge pull request 'fix(scripts): switch Node to a pinned version rather than asking nvm for latest (#198)' (#201) from feature/198-nvm-node-version into main
Linting / lint (push) Successful in 1m57s
SonarQube Analysis / sonarqube (push) Failing after 18m25s
Reviewed-on: #201
2026-08-27 17:05:48 -05:00
bermudalambandClaude Opus 5 95325c75d4 fix(scripts): switch Node to a pinned version rather than asking nvm for latest (#198)
`start-local.ps1` failed on a machine that had everything it needed, and then blamed the one thing that was definitely not the problem: "the newest version nvm has installed is too old. Install a newer one" — printed on a machine holding 26.7.0 and 24.13.1, both well past the floor.

`nvm use latest` does not mean "the newest version I have installed". nvm-windows resolves `latest` against the remote release list, and `newest` is the alias for the newest installed. The docstring stated the opposite and the code was written against it. Here that resolved to 26.8.1, which is not installed, so nvm reported `activation error: Version not installed`, left v18.16.1 running, and exited 0.

Two things had to change, and fixing either alone leaves it broken.

The version asked for is now pinned in `NODE_VERSION` rather than chosen by alias, so two machines run the same Node instead of whatever each happens to have installed, and there is one line to bump for both entry points. The alias names are recorded in the docstring anyway, because `latest` and `newest` are easy to swap back by accident and the difference is the whole of this bug.

`Use-Node` no longer treats an alias as automatically successful. That special case is why the error was wrong rather than merely unhelpful: it short-circuited on `$Version -eq 'latest'` regardless of what was running, swallowing nvm's `activation error` — which the function had already captured in `$output` for exactly this purpose — and returned success holding v18. The floor check downstream then reported the only explanation left to it. An alias switch is now verified against nvm's own report, so a failure says what nvm said.

Keeping that half matters even with a pinned version, because no caller passes an alias today. The bug was someone reaching for one, and the next person reaching for one gets a truthful failure rather than a confident wrong answer.

The floor check survives as a backstop against pinning `NODE_VERSION` below 20, and its message now says that rather than describing installed versions — a version that is not installed is `Use-Node`'s error to report, and it reports nvm's reason.

Verified from a real v18.16.1 baseline: the pinned switch takes 18.16.1 to 26.7.0; a concrete version that is not installed throws with nvm's reason; `latest` throws instead of silently succeeding. `start-local.ps1` then runs the whole way through — Node switch, migrations, backend build, ready. Parse check clean.

Shared by `start-local.ps1` and `run-tests.ps1`, so this broke both and fixes both.

Closes #198

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:05:48 -05:00
bermudalamb 1b39e354b6 Merge pull request 'fix(cart): say it is a demo where the customer can see it (#195)' (#200) from feature/195-demo-checkout-label into main
Linting / lint (push) Successful in 2m9s
SonarQube Analysis / sonarqube (push) Failing after 17m4s
Reviewed-on: #200
2026-08-27 17:03:55 -05:00
bermudalambandClaude Opus 5 8572e62514 fix(cart): say it is a demo where the customer can see it (#195)
Production runs demo mode with no PayPal credentials — that combination is the whole reason #191 turned it on — and in exactly that configuration the storefront rendered a full-width primary button reading plainly `Checkout`. The `(Demo)` suffix was gated on a PayPal client id being present, so the one configuration that needs the word was the only one that never got it.

The button is not decorative. It posts to `/demo/purchase`, which marks the item `sold`, writes a `completed` row into `orders` at the real price, and emails everyone who favorited it through production's real SMTP. Nobody is charged, which is what the compose banner promises and is true — but a customer cannot tell they have placed a pretend order, the inventory says otherwise, other customers are told it sold, and nobody is expecting to ship anything. #190, #191 and #192 all reason carefully about not charging by accident; none of them consider accepting an order by accident.

Three things now say so, because one of them was never going to be enough:

The label is unconditional. `type` still follows the PayPal client id — secondary when real PayPal buttons sit above it, primary when it is the only way to check out — and that distinction is worth keeping, but it is about prominence rather than about what the order is.

A notice sits above it for the whole of demo mode, before an address is picked and whether or not PayPal is configured. A parenthesis on the control someone has already decided to press is the weakest possible moment to tell them.

The confirmation stopped saying `Order complete!`, which is exactly what a real order says. It now names the two things a customer would otherwise assume: nothing was charged, and nothing will be shipped.

Three end-to-end tests, written first and failing first against a browser — the label assertion failed with `Expected "Checkout (Demo)", Received "Checkout"` on an `ant-btn-primary ant-btn-block` element, which is the defect exactly as reported. The suite already runs `DEMO_MODE=true` with no PayPal credentials, so it reproduces production's configuration without any new fixture.

`CartPage.checkoutButton` matches the label by prefix rather than in full, deliberately: a locator naming the correct label would have gone looking for the right button and found nothing, which is how a test for this can quietly pass by being wrong in the same direction as the bug.

Verified: 3 new tests pass, frontend build clean, lint 0 errors, 25 unit tests pass, and the cart-countdown and orders suites still pass. The favorites and favorites-filter suites fail here, and fail identically with this change stashed — 9 failures without it, 8 with — so they are pre-existing and not from this. Worth their own issue.

Closes #195

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:03:55 -05:00
bermudalamb ff6cbe18c5 Merge pull request 'docs(ops): make the cutover runbook agree with the compose file about DEMO_MODE (#196)' (#199) from feature/196-runbook-demo-mode into main
Linting / lint (push) Successful in 1m56s
SonarQube Analysis / sonarqube (push) Failing after 16m9s
Reviewed-on: #199
2026-08-27 17:02:52 -05:00
bermudalambandClaude Opus 5 2fe3aa055f docs(ops): make the cutover runbook agree with the compose file about DEMO_MODE (#196)
Linting / lint (pull_request) Successful in 1m54s
SonarQube Analysis / sonarqube (pull_request) Failing after 19m47s
#190 turned `DEMO_MODE` from a hardcoded compose value into a Portainer stack variable with no default, and updated the compose header. The runbook that actually creates the stack was not updated with it, so the document and the file it deploys have been disagreeing since — in the three places most likely to be read under pressure.

Step 2's list of stack variables to record did not include `DEMO_MODE`, and step 6 says to add the variables from steps 2 and 3. An operator following this literally creates a stack that cannot boot. It is now in the list, with a paragraph of its own: it is the only one of the nine that is not a secret, which is exactly why it is the easy one to skip past.

The troubleshooting section said `DEMO_MODE` was hardcoded and therefore could not be missing, so its absence from the error list proved nothing. That was the most dangerous sentence in the file — an unset `DEMO_MODE` is now the *first* thing to check rather than something to rule out. The line now says it moved and why.

The crash-loop example was the PayPal triple, which cannot occur while `DEMO_MODE` is `true`. The message an operator will actually see during the demo interim — `DEMO_MODE is required and must be exactly 'true' or 'false'` — appeared nowhere in the runbook. Both forms are shown now, in the order they are likely to be hit.

Added what neither document said: Compose only *warns* about an unset variable and deploys anyway. In Portainer's stack UI that warning is easy to miss, and the container then crash-loops under `restart: unless-stopped` — loud in the log, invisible in a glance at the stack list. The container log is the signal, not the deploy output.

The compose header carried two claims that #190 falsified and did not correct: that the PayPal secrets are required "because DEMO_MODE is false below", and that only secrets are interpolated. Both now describe the file as it is.

This is the drift `composeEnvironment.test.ts` exists to prevent, surfacing in the one place no test can reach — the guard keeps the compose file honest about its own intent and cannot see the runbook beside it.

Verified: 278 backend unit tests pass, including the compose guard that parses this file, and a sweep for the stale claims finds none left.

Closes #196

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 11:35:44 -05:00
bermudalamb fdb5b7a0be Merge pull request 'Feature/192 backup directories' (#193) from feature/192-backup-directories into main
Linting / lint (push) Successful in 3m56s
SonarQube Analysis / sonarqube (push) Failing after 22m23s
Reviewed-on: #193
2026-08-26 10:58:09 -05:00
bermudalamb 1a59edec18 docs(ops): prove the backups during the cutover rather than waiting for a schedule (#192)
Linting / lint (pull_request) Successful in 2m45s
SonarQube Analysis / sonarqube (pull_request) Failing after 30m13s
Step 4b created the directories and step 7 checked the containers were Up. Neither established that a restorable file actually gets written, and those are not the same claim — the database backup does not run until 03:00 and the uploads archive not until Sunday 04:00, so a stack that looks correct at the end of a cutover can be four days from its first evidence.

Both tools take a manual trigger, so the wait is unnecessary. The runbook now forces one run of each, checks the sizes are plausible, and greps the dump for `COPY` lines on the real tables — a dump of an empty database succeeds and looks fine, which is the one way this check could otherwise lie.

It also confirms the healthchecks agree with where the files landed. A check whose `find` path disagrees with where the tool actually writes reports unhealthy forever while the backups are working perfectly, and that is a thing to discover on the day the stack is built rather than a year later.

`starting` corrected to `unhealthy` in the surrounding text: during `start_period` Docker reports `starting`, which is what an operator actually sees and what the previous wording got wrong.

Proven against production during the cutover on 2026-08-26 — a 41K dump and a 15M archive, both landing where the healthchecks look.
2026-08-26 10:40:39 -05:00
bermudalamb 724e9ce19d docs(ops): say that the backup directories have to be created (#192)
The stack gained two backup services in #147 and nothing has ever told anyone to create the directories they mount. `backup-and-restore.md` reads from both paths and the compose file mounts both, but no document creates them — while the README does exactly that for QA's data directories, ownership notes and all. Production's cutover runbook said nothing.

Hit for real during the cutover: both backup containers sat in Created, never started, and `docker logs` on them reported only that nothing matched the filter, because a container that never ran has no output. Portainer showed them beside the healthy ones and the stack looked deployed.

That silence is the reason this is worth a step of its own rather than a footnote. A backup regime that never started is indistinguishable from a working one until someone needs a restore, which is the failure mode the healthchecks in #147 exist to catch — and those healthchecks cannot fire on a container that is not running.

The cutover runbook gains the directory creation before the stack is created, and step 7 now counts containers rather than only checking the app: four, all Up, with Created called out as the thing to look for. Counted from the compose file rather than from memory — the first draft said five.

`backup-and-restore.md` gains the same note where it describes the destinations, since anyone reading that page is already thinking about paths.

No `chown`, deliberately stated: both backup images run as root, unlike the Postgres image whose data directory needs uid 999, and an unnecessary chown instruction is how people learn to run them without thinking.
2026-08-26 10:14:10 -05:00
bermudalamb 085684c1d2 Merge pull request 'Feature/191 production demo mode interim' (#192) from feature/191-production-demo-mode-interim into main
Linting / lint (push) Successful in 3m1s
SonarQube Analysis / sonarqube (push) Failing after 29m38s
Reviewed-on: #192
2026-08-26 09:17:20 -05:00
bermudalamb 6104ebb459 feat(ops): read DEMO_MODE from the stack rather than the compose file (#190)
Reverses the position the previous commit took. Hardcoding it made a value that gets flipped without a code change require a commit and a merge to flip, which is backwards — and it is the operator's call, not the file's.

No default, deliberately. `${DEMO_MODE:-false}` is the obvious form and the wrong one: a default decides whether the shop takes money on the operator's behalf, silently, whichever way it points. Having none is safe rather than fragile because `checkDemoMode` is strict — an unset stack variable substitutes to an empty string, and anything that is not exactly `true` or `false` refuses to boot naming DEMO_MODE. That strictness is the whole reason interpolating this one is defensible, so the line says so.

The compose guard had to learn the difference. It hands each deploying file's entries to the real validator, and a literal `${DEMO_MODE}` is not a value `checkDemoMode` accepts, so both the DEMO_MODE assertion and the validateEnv check failed the moment the file stopped holding a literal. Each deployment now declares the stack variables it supplies, and a bare `${VAR}` named there resolves to the declared value before the file is validated. Every other bare `${VAR}` stays opaque exactly as before — those are secrets, and what is checked of them is that the line exists.

Be clear about what that guard can prove. It cannot see Portainer, so it does not verify the stack actually holds `true`; nothing in this repository can. What it does is keep the intent beside the file and make the pair inseparable — hardcode the compose line and the registry disagrees, change the registry and it no longer describes the file. The runtime half is the boot check, which fails loudly rather than falling back. Verified by mutation: hardcoding `false` fails the DEMO_MODE assertion, and deleting the line fails that and `validateEnv`.

Restoring real payments is now two Portainer values and a redeploy, with no commit — which is what #190 asks for.
2026-08-26 09:17:20 -05:00
bermudalamb 12b2f09d79 docs(ops): point the demo-mode notes at the right issue (#190)
The tracking issue was filed as #190; the compose banner and the guard test both said #191, guessing the number before it existed. A note that points at the wrong issue is worse than no note when the thing it tracks is production not taking money.
2026-08-26 09:17:20 -05:00
bermudalamb 0a830cad1f feat(ops): put production in demo mode to complete the cutover (#191)
Production could not boot during the cutover to the committed compose file: `DEMO_MODE` is false there, which makes the three PayPal secrets required, and they were not available. Demo mode is the interim the compose file's own header sanctions for exactly this — the whole cart and checkout flow works and nobody is ever charged.

Two things made this cost more than it should have, and both are now written down rather than left to be rediscovered.

`DEMO_MODE` is hardcoded rather than interpolated, so setting a `DEMO_MODE` stack variable in Portainer does nothing at all — there is no `${...}` for it to substitute into and the file's value wins silently. That hardcoding is right: the one value deciding whether the shop takes money should not be flippable from a web UI without a commit anybody can read. But the failure mode reads as "I set it and it ignored me", so the line now says so.

Declaring `PAYPAL_CLIENT_ID=` with an empty value is identical to not declaring it. `isPresent` rejects a blank string deliberately, because set-to-nothing is a mistake rather than a value.

The state is loud in both places that can see it. The compose file leads with a banner saying production is taking no money, and `composeEnvironment.test.ts` asserts `DEMO_MODE` is `true` — that assertion is the guard, not a formality: it fails the moment the file and the expectation disagree, in either direction, so this cannot be switched back quietly and cannot be left on unnoticed.

#191 restores it.
2026-08-26 09:17:20 -05:00
bermudalamb 5f21d97178 Merge pull request 'docs(ops): name the crash loop the cutover runbook was most likely to cause (#175)' (#191) from feature/190-cutover-runbook-secrets into main
Linting / lint (push) Successful in 4m21s
SonarQube Analysis / sonarqube (push) Failing after 41m53s
Reviewed-on: #191
2026-08-26 08:41:27 -05:00
bermudalamb 0cec9b3c1f docs(ops): name the crash loop the cutover runbook was most likely to cause (#175)
Linting / lint (pull_request) Successful in 3m7s
SonarQube Analysis / sonarqube (pull_request) Failing after 27m56s
Step 7 listed a missing `ADMIN_GATE_SECRET` — a warning the container starts through — and said nothing about the three PayPal secrets, which are a hard error that crash-loops it. That is backwards: the secrets are the likeliest thing to be missing after a stack replacement, because Portainer stack variables belong to the stack and are discarded with it, and `DEMO_MODE` is false in production so the app refuses to start without them. Hit for real following the runbook.

The verify section now shows the actual log block, says what it means, and gives the way to tell a missing variable from a misnamed one: a hardcoded value cannot be missing, so its absence from the error list proves nothing, while an interpolated variable that stays quiet while others complain proves substitution works and those others are simply unset. That is the reading that turns the log into a diagnosis instead of a list.

It also says the loop is harmless while the values are fetched — the container refuses before it serves anything or touches data — and where the values live if the old stack is already gone, since the webhook id in particular is readable rather than only recreatable.

Step 2 now names every interpolated variable rather than describing them in general, and states the consequence of each class going missing. A general instruction to record the environment is easy to read as already done.
2026-08-26 08:35:01 -05:00
bermudalamb f5e3ec3e99 Merge pull request 'Feature/188 filter dimensions' (#189) from feature/188-filter-dimensions into main
Linting / lint (push) Successful in 2m15s
SonarQube Analysis / sonarqube (push) Failing after 22m45s
Reviewed-on: #189
2026-08-25 16:25:21 -05:00
bermudalambandClaude Opus 5 3242018782 fix(filters): address the final review findings (#188)
Linting / lint (pull_request) Successful in 2m7s
SonarQube Analysis / sonarqube (pull_request) Failing after 20m36s
Restores the escape hatch for a status list matching no preset. filtersFromSearchParams accepts any non-empty subset of {available, reserved, sold}, and only three of those seven lists are presets; the other four reported as the not-sold fallback and produced no chip, so ?status=reserved was an empty grid reading "No items yet - check back soon" with no Clear filters button and no way out but editing the URL. hasActiveFilters covered all seven before this branch, so that was a regression against main. availabilityDimension now emits a chip for any status that is not the not-sold preset, labelled from SALE_STATE_LABELS when the list matches one and from statusLabel when it does not. The Segmented still reads "Not sold" beside such a chip, which is a cosmetic wart and the cheaper half of the trade.

Runs the frontend unit suite in CI. The 22 tests added over the dimensions were invoked by nothing: the workflow's only frontend steps were the build and the end-to-end run, and its test:unit:cov step is the backend's. The new step is guarded and named in the gate like every other suite, per the invariant workflowGate.test.ts asserts.

Restores the comment explaining why availabilityDimension's render and chips pass different fallbacks to saleStateFromStatuses. The control must read "All" while sold favorites are on screen; chips must stay silent because the customer never chose it. Unifying them would give every signed-in favorites view a phantom All chip and a tally of 2, and nothing said so after the old markup was deleted.

Derives the storefront's "is anything filtered" and FilterBar's tally through one exported chipsFor rather than two expressions over two identically-built contexts. They agreed only by convention, which is the exact defect this branch exists to remove.

Documents that statusDimension and availabilityDimension are alternatives over one field, since composing both type-checks and would render two controls that double-count it, and asserts the price chip's label text, which was the only chip label nothing checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:15:15 -05:00
bermudalamb 8771deeaca test(filters): cover the tally and availability chip behaviour changes (#188) 2026-08-25 15:48:09 -05:00
bermudalamb 147e280f88 refactor(filters): compose the storefront from dimensions and delete the old components (#188)
Replaces App.tsx's hand-written Segmented/Filters-button/ActiveFilterChips
region with a single FilterBar composed from five dimensions, and removes
the FilterDrawer and ActiveFilterChips components along with the
activeFilterCount and hasActiveFilters helpers they were the only callers
of. Catalogue now receives a filtered boolean computed the same way
FilterBar computes its own chip tally, rather than the ItemFilters object
it only ever used for that one check.
2026-08-25 15:39:18 -05:00
bermudalamb 2f0da9afe1 refactor(admin): compose the inventory filters from dimensions (#188) 2026-08-25 15:30:27 -05:00
bermudalamb 50d048a1d6 feat(filters): add FilterBar, one component both screens compose (#188) 2026-08-25 15:26:21 -05:00
bermudalamb 918d6eeab9 refactor(filters): extract the chip row from ActiveFilterChips (#188) 2026-08-25 15:23:25 -05:00
bermudalamb 1d7f928c48 feat(filters): move the availability preset into a bar dimension (#188) 2026-08-25 15:18:23 -05:00
bermudalamb eb5799dd17 feat(filters): add favorites and status dimensions (#188) 2026-08-25 15:12:35 -05:00
bermudalamb 60b76cae82 docs(plan): correct the cumulative unit test counts (#188)
Task 1 leaves 4 tests and Task 2 adds 7, so the running total is 11 rather than 12, and the same off-by-one carried into Tasks 3, 4 and the completion criteria. Caught by the Task 2 implementer, which flagged the mismatch rather than inventing a test to reach the stated number.
2026-08-25 15:07:13 -05:00
bermudalamb abd3dd4e2e feat(filters): add tag and price dimensions (#188) 2026-08-25 15:06:15 -05:00
bermudalamb 5d0b66a982 test(filters): add a unit runner and the filter dimension contract (#188) 2026-08-25 14:58:21 -05:00
bermudalamb e695d91670 docs(plan): implementation plan for filter dimensions (#188)
Nine tasks, each ending in something independently testable, in an order where every task leaves both screens working. The dimensions are built and unit-tested first, the shell after them, and the two screens are wired last — so the old components stay in place until the thing replacing them is proven.

The two behaviour changes the design accepted are each covered twice: a unit test on the chips that produce them, and an end-to-end assertion on what a person sees. The admin tally reading three for three statuses, and a non-default availability producing a removable chip.

Task 8 carries the deletions, deliberately last. Removing `activeFilterCount` and `hasActiveFilters` turns every remaining caller into a compile error, which is the cheapest way to find them.

Two defects found reviewing the plan against the code rather than against itself: the test fixture omitted `Category.item_count` and would not have compiled, and Task 9 added a page object method that nothing used — `chooseAvailability` and `filterChip` already exist.

The plan also records what must not be read as a regression. Two assertions in the storefront specs fail on every branch because the unpaginated grid cannot render 1,600+ development rows inside Playwright's default timeout, which is #186 and predates this work.

Refs #188
2026-08-25 14:46:06 -05:00
bermudalamb 20a38b66bd docs(design): filter dimensions, one composable component both screens extend (#188)
#169 made the filter drawer shared, which was the right first move and not the finish. Per-screen differences are booleans, the bar around the drawer was never shared at all, and the storefront's availability preset sits outside the system because the shared component cannot express "this belongs in the bar, not the drawer".

The design replaces the flags with composition: a screen contributes a list of filter dimensions, each declaring where it renders, how to render it, and what chips it contributes. A screen-specific control becomes an ordinary dimension, appearing in the chip row and counting toward the tally without the shared code knowing what it is.

Dimensions are plain data rather than components or context, for a concrete reason rather than a stylistic one. The drawer sets destroyOnHidden, so its sections are unmounted whenever it is closed — exactly when the chip row matters most. Anything that registers on mount would lose those chips the moment the drawer closed, which rules out the otherwise-idiomatic context-and-children approach.

The tally becomes the number of chips, so the count and the chip row cannot disagree — today they are computed by two routes and agree by coincidence, which the admin already has to correct by hand. Three visible behaviours change as a result, recorded in the spec rather than left to be discovered.

Adding vitest is in scope. The design's value rests on chips() being pure, and the frontend has no unit runner at all, so without one the core of it would ship covered only indirectly and expensively through Playwright.

The spec also records what is deliberately untouched: the filter state, the URL serialisation, the backend, and the two e2e assertions already failing on #186 — which must not be read as regressions from this work.

Refs #188
2026-08-25 14:36:51 -05:00
bermudalamb 2a19238109 Merge pull request 'feat(filters): show a tag's own colour on its active filter chip (#185)' (#187) from feature/185-tag-chip-colour into main
Linting / lint (push) Successful in 1m51s
SonarQube Analysis / sonarqube (push) Failing after 22m43s
Reviewed-on: #187
2026-08-25 14:05:34 -05:00
bermudalamb d6e0942487 feat(filters): show a tag's own colour on its active filter chip (#185)
A tag carries a colour, and every place a tag appears shows it — a product card, the filter drawer's control, the admin taxonomy screen — except the removable chips beside the Filters button, which rendered every filter as a default grey. Picking `vintage` from a control that showed it in red produced a grey chip of the same name right next to it.

Only tags get a colour, because only tags have one. Category, price, favorites and status keep the default, and that asymmetry is the point: in a row mixing four kinds of filter, colour now means "this is a tag". Nothing depends on it — every chip still carries its label — so this reads the same to anyone who cannot distinguish the colours.

The close control inherits the tag's text colour, so a coloured chip gets a matching cross rather than a grey one on a coloured ground. A tag not yet in the loaded options has no colour to use and keeps the default, which is the same window the existing `Tag {id}` label fallback covers.

The test asserts the chip's colour equals the same tag's colour on a product card, rather than asserting it is red. The colour is derived from the tag's name and free to change; what must hold is that a tag looks like itself wherever it appears, and comparing the two places says that directly. Confirmed to fail without the change — the old grey chip sets no colour class at all.

Verified visually as well as by assertion: three tags selected together render in the drawer, in the chip row and on the card in the same colours.

Closes #185
2026-08-25 14:05:34 -05:00
bermudalamb 3cffcf772c Merge pull request 'refactor: remove the duplicated blocks SonarQube found (#182)' (#184) from feature/182-remove-duplication into main
Linting / lint (push) Successful in 1m59s
SonarQube Analysis / sonarqube (push) Failing after 21m43s
Reviewed-on: #184
2026-08-25 13:13:28 -05:00
bermudalamb 61c12fd438 refactor: remove the duplicated blocks SonarQube found (#182)
Linting / lint (pull_request) Successful in 1m58s
SonarQube Analysis / sonarqube (pull_request) Failing after 22m40s
Three of the four candidates were real. The fourth was my mistake in the issue.

**The category tree adapter**, duplicated verbatim between `CategoryTreeSelect.tsx` and `FilterDrawer.tsx`. This one was mine: #139 moved the storefront filter to a `TreeSelect` and copied the admin's adapter rather than sharing it, with a comment saying the shape "matches the admin's CategoryTreeSelect so the two stay comparable" — an argument for one implementation that instead produced two. It now lives in `filters.ts` beside `buildCategoryTree`, which was already shared for exactly the same reason: one meaning, one implementation.

`Categories.tsx` keeps its own. It builds a different shape for a real antd `Tree`, keyed rather than valued, with a title that is a React node carrying that screen's buttons. Genuinely different, and folding it in would mean a parameterised adapter that serves neither case clearly.

**The `item_images` insert loop**, written separately by create and update and differing only in where the id came from and where the sort order started. Both are parameters now, which also means the `/uploads/` prefix is written once — #103 made that the value `uploadUrl` joins an origin onto, so it is a contract rather than a string.

Extracting it turned up two things the inline versions hid. Create indexed `files[i]?.filename ?? ''`, so a missing element would have stored a path pointing at the uploads directory itself; iterating by entry removes the possibility rather than defending against it. And the helper's typed `itemId` surfaced that `req.params.id` is `string | undefined` under `noUncheckedIndexedAccess`, which the old inline `unknown[]` swallowed — now `Number()`, as the `setItemTags` call two lines above already did.

**The optional-field guards**, eight identical lines opening both routes. The distinction worth preserving 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. That is what makes it more than a null check and worth stating once.

**`TAG_COLORS` was not a duplication.** The issue listed four files on the strength of a grep that also matched `STATUS_TAG_COLORS` in `Admin.tsx` — a status-to-colour map for the inventory table, unrelated to the tag palette. What remains is one definition in `backend/src/utils.ts` and one mirror in `frontend/src/admin/Tags.tsx`, already carrying a comment pointing at the other, which is the same treatment `ALLOWED_IMAGE_TYPES` gets and is correct: there is no shared package, and creating one for a colour list would cost more than it saves.

Verified beyond the type checker, since three of these are pure moves that compile either way: 278 unit and 254 integration tests, and the end-to-end specs covering both consumers of the shared adapter — the storefront drawer and the admin item form's category picker, including inline category creation.

Closes #182
2026-08-25 13:08:58 -05:00
bermudalamb 76797ee6f6 Merge pull request 'fix(security): stop refused uploads accumulating on the volume, and record the hotspot review (#180)' (#183) from feature/180-security-hotspots into main
Linting / lint (push) Successful in 2m10s
SonarQube Analysis / sonarqube (push) Failing after 23m56s
Reviewed-on: #183
2026-08-25 11:50:28 -05:00
bermudalamb b616b9f0ab fix(security): stop refused uploads accumulating on the volume, and record the hotspot review (#180)
SonarQube reported three security hotspots, all in `routes/admin.ts`. A hotspot is not a defect — it marks code that touches something security-sensitive and needs a human decision — so the work is a recorded review, with a change only where the review finds a real gap. It found one.

The gap: multer writes every file to disk before any route logic runs, and multer's own cleanup only covers errors it raised itself. Everything after that left the bytes behind with nothing referencing them. A request carrying a perfectly valid photograph and a malformed `category_id` is refused with a 400 after the write, and the file stays on the volume permanently — no database row to find it by, and no bound on how many can accumulate. The same held for a malformed `tags` field, for a database error rolling the transaction back, and for `readHead` itself throwing, which returned no message and so cleaned up nothing.

That is the substance of the limits the first hotspot points at. Bounding one request to 8 MB across six files does nothing if every refused request keeps its bytes for ever, and the admin API is the one surface where that is reachable.

The fix is a hook rather than a call at each `return`, registered the moment multer succeeds. A route added later inherits it instead of having to remember it, which matters because the failure being prevented is precisely someone adding a fourth early return. It listens on `close` rather than `finish` so an aborted connection is covered, and checks `writableEnded` so a response that never completed is not mistaken for a success whatever its status code reads.

`verifyUploadedImages` goes back to checking only. Removing the files there as well would unlink twice and log an ENOENT for every refused upload, and the single mechanism covers the case it used to miss.

The other two hotspots are safe, and now say why in the file rather than only in SonarQube's UI — following the precedent of the existing comment that names S5693 by rule number. The upload path is not caller-controlled despite arriving from a request: multer composes it from a server constant and a `randomUUID()` plus an extension looked up from the validated content type, so the caller's `originalname` never reaches the filesystem. That reasoning belongs next to the `fs.open` that depends on it.

Three tests, written first and failing first: a refused sibling field, a refused tags field, and the accepted case, which must not be swept up by the same cleanup. 254 integration and 278 unit tests pass.

Refs #180
2026-08-25 11:50:28 -05:00
bermudalamb e3514e8ef4 Merge pull request 'fix(ci): stop the test summarisers failing the job on an unreadable results file (#178)' (#179) from feature/178-resilient-summarisers into main
Linting / lint (push) Successful in 2m10s
SonarQube Analysis / sonarqube (push) Failing after 17m58s
Reviewed-on: #179
2026-08-25 11:49:11 -05:00
bermudalamb 5d427bc1a1 fix(ci): stop the test summarisers failing the job on an unreadable results file (#178)
Linting / lint (pull_request) Successful in 2m0s
SonarQube Analysis / sonarqube (pull_request) Failing after 21m21s
Run 525 was the first with #174's graceful failure, and it worked: the integration suite failed, the end-to-end suite ran again, and `SonarQube Scan` succeeded for the first time since #154 started. But `Summarize integration tests` failed, and that step should not be able to.

Both scripts already carried the principle in a comment — report plainly and exit 0, because the job fails on the real step and a stack trace here would only bury it — and both only implemented it for the file being absent. A file that exists and cannot be read crashed them.

Two ways to reach that, both reproduced. `--forceExit`, which the integration script passes to paper over a post-run hang, can end the process around the write and leave partial JSON. And a suite that fails to *run* rather than to assert arrives without the array the failure renderer walks, which is exactly the shape this suite has been producing under #154.

Reading is now guarded as thoroughly as `summarize-playwright.js` already guarded its traversal, and that traversal's `|| []` discipline is extended to the jest renderer. `summarize-playwright.js` had the same hole by the narrower path of an unguarded `JSON.parse`.

The reason reaches the log rather than being swallowed. "Could not read the results file" with the parse error is diagnostic; a silent empty summary is not.

This matters beyond tidiness because of where the failure lands. A crash here reports the job as failing at a step named for summarising rather than for testing, which is the misdirection #142 fixed once already — and a summariser whose job is to make a failing run readable should not crash on the output of the worst failures, which is the moment it is most needed.

Verified against a truncated file, a suite entry with no `testResults`, an absent file, and a real 278-test run: the first three now exit 0 naming the reason, the absent case is unchanged, and the happy path still reports its counts.

Closes #178
2026-08-25 09:20:34 -05:00
bermudalamb ed81de3b06 Merge pull request 'docs(ops): write down how to cut production over to the committed compose file (#175)' (#177) from feature/175-prod-cutover-runbook into main
Linting / lint (push) Canceled after 0s
SonarQube Analysis / sonarqube (push) Canceled after 0s
Reviewed-on: #177
2026-08-25 09:16:15 -05:00
bermudalamb cf9003bc2d docs(ops): write down how to cut production over to the committed compose file (#175)
#118 put `docker-compose.prod.yml` in the repository and argued at length for why it belongs there. It never produced the procedure for getting production from where it is — a stack living in Portainer's web editor — to where that file expects it to be.

What existed was scattered and none of it was a procedure. The compose header described the destination: name the stack this, deploy it as a git repository stack with these settings, rename any variables whose names differ. The README's deployment section is a good runbook for the routine redeploy, and assumes the stack is already in the right form. So the riskiest deployment operation this project has was the one with no steps written down.

`docs/ops/production-stack-cutover.md` is those steps, in the order that matters, with the two facts that decide whether the operation is safe stated up front rather than left to be inferred from the volumes block.

The first is that all persistent data is bind-mounted from the NAS filesystem rather than held in Docker-managed volumes, so deleting the stack cannot lose the database or the product images. That is what makes the cutover recoverable at all, and step 2 verifies it rather than trusting it — the `docker inspect` there discriminates `bind` from `volume` and says to stop on `volume`, because this runbook does not cover that case.

The second is that both services set an explicit `container_name`, so the old containers must be gone before the new stack starts or the deploy fails on a collision that reads like a Portainer bug rather than a sequencing mistake.

It also captures what is simply lost if not recorded first. Portainer stack variables belong to the stack and are discarded with it; they are all secrets, and a stack brought back up with a different `DB_PASSWORD` than the data directory was initialised with cannot authenticate against its own database. Recording them is step 2 and it is what makes the rollback credible.

The verification section separates the boot warnings that are correct in production — no mail allowlist, no uploads origin yet — from the one that means a variable never arrived. That distinction is the whole failure mode #118 was about: a stack variable whose name matches nothing in the file is substituted nowhere and never reaches the container, and the failure reads as "I set it and it says it is not set".

Every diagnostic command in it was run rather than written from memory. The mount inspection was checked against a container that genuinely uses a named volume, to confirm it reports the difference the step depends on.

The compose header now points at the runbook for the procedure and keeps the rationale that belongs at the point of use — why there is no `build:`, why most values are hardcoded, why `NODE_ENV` is absent. The README says plainly that its section is the routine deploy and links to the other one.

Closes #175
2026-08-25 09:16:15 -05:00
bermudalamb 050420858f Merge pull request 'ci: fail at the end rather than part way through, so the scan still runs (#174)' (#176) from feature/174-graceful-sonarqube-failure into main
Linting / lint (push) Successful in 2m10s
SonarQube Analysis / sonarqube (push) Canceled after 13m15s
Reviewed-on: #176
2026-08-25 09:15:26 -05:00
bermudalamb e3842b1a4c ci: fail at the end rather than part way through, so the scan still runs (#174)
Linting / lint (pull_request) Successful in 2m6s
SonarQube Analysis / sonarqube (pull_request) Failing after 19m53s
`sonarqube.yml` already had a documented design for this: run every suite, produce coverage, scan, summarise, then fail at the end from recorded step outcomes. The comments on the gate spell it out and #142 fixed it once already. The integration suite was never wired into it — no `id`, no `continue-on-error`, and absent from the gate, where the unit and end-to-end suites had all three.

So it was the one suite whose failure aborted the job. On every push since #154 started, step 9 failed and steps 10 through 15 were skipped, which means there has been no SonarQube analysis at all for the duration — not a degraded one, none. The end-to-end suite has not run in CI either, which is separately why #116 cannot be verified: the step that would demonstrate its fix is skipped rather than failing.

Fixing only that step would have left the same shape in four other places, so every step from the first suite to the scan is now guarded and named in the gate: starting the backend, installing browsers, merging frontend coverage, and the scan itself, which until now took the summaries down with it. The preconditions before the suites — checkout, installs, build checks, migrations — still fail hard, because when they fail there is genuinely nothing to analyse.

The job still fails. It fails at the end, having produced everything it could.

The integration suite also gains the summary the other two already had. It was the only suite without one, so the failure this workflow has been stuck on presented as 36 assertion errors about categories and price filters rather than as a count — #154 records how expensive that misdirection was to read. `summarize-jest.js` already takes a label, so this is reuse.

`tests/unit/workflowGate.test.ts` asserts the pairing that makes the design work: a step carries `continue-on-error` so it cannot abort the job, and the gate names it so it can still fail the job. Both halves are needed and nothing connected them, which is how this happened — and the other direction is worse, since a step guarded but unnamed cannot fail the job at all. The test checks the invariant rather than a list of names, for the same reason `composeEnvironment.test.ts` reads the real list rather than a copy. Verified by mutation: dropping the integration suite from the gate, un-guarding it, and removing `always()` each fail it.

Confirmed by running the new flag combination rather than assuming it: the integration suite writes `integration-results.json`, the summariser reads it and exits 0, and both that file and `coverage/integration/lcov.info` are still written when the suite fails — which is what makes scanning with a failing suite produce real coverage rather than a fabricated regression.

Closes #174
2026-08-25 08:47:42 -05:00
bermudalamb 667a40c4eb Merge pull request 'feat(security): stop the app origin serving anything it does not recognise, and make the uploads origin configurable (#103)' (#173) from feature/103-uploads-origin into main
Linting / lint (push) Successful in 1m58s
SonarQube Analysis / sonarqube (push) Failing after 6m43s
Reviewed-on: #173
2026-08-24 17:38:55 -05:00
bermudalamb cf1680dbfb feat(security): stop the app origin serving anything it does not recognise, and make the uploads origin configurable (#103)
The uploads directory is the only place in this application where content someone else authored is served over HTTP. #95 stopped 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, a restore, or a file written before that validation existed.

Two halves, complementary rather than alternative.

The app's own origin now serves uploads defensively. An allowlist of the three extensions the upload path can produce, so a `.html` or a `.svg` on disk is simply not a file this application hands out — 404, the same answer as a file that is not there, so the response cannot be used to learn which paths exist. An allowlist rather than a denylist because a denylist has to anticipate every type a browser might execute, which is a moving target across browsers and years, while this only has to know three. The content type is stated explicitly from that same list rather than sniffed or guessed from a name someone else chose, paired with `nosniff`. `default-src 'none'; sandbox` gives a directly-navigated file no capabilities at all, which is the only way one of these can do harm — an `<img>` embed does not execute script. Writes get 405 rather than falling through to a 404 that suggests the path is wrong.

The other half is the separate origin, which is the real fix, because the origin is the whole unit of trust in a browser. That needs a hostname and a certificate, which live outside this repository, so what is here is the switch: `UPLOADS_BASE_URL`, sent to the frontend at runtime through `/api/config` and joined onto stored paths by `uploadUrl`. Empty means the app's own origin, which is the default and what local development has, so nothing changes until it is pointed somewhere.

Stored paths stay site-relative. A stored value outlives any hostname baked into it, and rewriting them would be a migration to undo the day the hostname changes.

Runtime rather than built in, so one image serves every environment — the same reason `paypalClientId` and `demoMode` are already there. `UPLOADS_BASE_URL` has a line in `docker-compose.prod.yml` while still empty, deliberately: a Portainer stack variable with no line there is substituted into the file and never reaches the container, which is exactly how `UPLOADS_DIR` went missing on 2026-08-23.

Unset warns at boot, in the same shape as the admin gate — a working configuration with one defence switched off is worth saying out loud. Set without a scheme is refused outright, because a bare hostname joins onto a stored path as if it were relative and breaks every image on the site rather than failing visibly.

The compose guard now resolves `${VAR:-default}` to its default, which is what the container actually receives when the stack variable behind it is unset. A bare `${VAR}` is still left opaque, so a required variable referenced that way goes on counting as present — that check is about the line existing, not about the stack being filled in.

Closes #103
2026-08-24 17:38:55 -05:00
bermudalamb 5d72c1c88b Merge pull request 'test(perf): measure what concurrent hashing actually costs a bystander request (#163)' (#172) from feature/163-measure-hash-latency into main
Linting / lint (push) Successful in 1m59s
SonarQube Analysis / sonarqube (push) Failing after 4m56s
Reviewed-on: #172
2026-08-24 17:02:52 -05:00
bermudalamb 313b48f582 test(perf): measure what concurrent hashing actually costs a bystander request (#163)
Linting / lint (pull_request) Successful in 2m5s
SonarQube Analysis / sonarqube (pull_request) Failing after 5m9s
#163 claimed `bcryptjs` blocks the event loop and that every login stalls every other request in flight. That was asserted without measurement and is wrong: the asynchronous API chunks its work and yields between rounds, and all six call sites in `routes/customers.ts` use it — there is no `hashSync` or `compareSync` anywhere in `src`.

A smaller effect is real, though, and this measures it instead of arguing about it. The probe is `GET /api/customers/me` with no cookie, chosen for doing almost nothing: it rejects before touching the database, so nearly all of its latency is time spent waiting for the event loop rather than work of its own. The load is real registrations against the real route, because the question is what a deployed server does rather than what bcrypt does on a bench.

Measured on the dev machine, Node 24, cost 12:

| Concurrent registrations | Each registration (p50) | Bystander p95 | Bystander worst case |
| --- | --- | --- | --- |
| idle | — | 0.3 ms | 5.4 ms |
| 1 | 213 ms | 0.6 ms | 102 ms |
| 4 | 803 ms | 101.9 ms | 405 ms |
| 8 | 1626 ms | 15.6 ms | 808 ms |

Both columns are linear in the number of queued hashes. Registration is roughly 200 ms times the concurrency, because the hashes serialize onto the one thread. The bystander's worst case is roughly 100 ms times the concurrency, which matches the coarseness of the chunks measured earlier — about 100 ms of un-yielding time per hash.

The median stays under a millisecond throughout, so this is a tail-latency characteristic and not the stall the issue described.

The benchmark creates real customers and deletes them again, because it is normally pointed at a development database that nothing truncates.

Lint now covers `scripts` as well as `src`, so the one file in it is held to the same standard as the rest.
2026-08-24 16:59:33 -05:00
bermudalamb 1ba1cac1db Merge pull request 'Feature/169 admin filter flyout' (#171) from feature/169-admin-filter-flyout into main
Linting / lint (push) Successful in 2m28s
SonarQube Analysis / sonarqube (push) Failing after 5m31s
Reviewed-on: #171
2026-08-24 16:46:47 -05:00
bermudalamb 9db3c6d94c feat(admin): filter inventory through the same flyout the storefront uses (#169)
Linting / lint (pull_request) Successful in 2m1s
SonarQube Analysis / sonarqube (pull_request) Failing after 5m14s
The two screens asked the same questions through different UI. The storefront had searchable multi-selects in a flyout; the admin still had an always-visible row of controls with a single-select category that held a list of at most one, which is what #139 left behind so the shared filter type would not have to change shape twice.

The drawer is now one component with the sections that differ driven by props rather than a second copy that would drift. Favorites is storefront-only. Status is admin-only, since pending is excluded from every public read and Published or Unpublished are not distinctions a customer can draw — the storefront keeps its three-way preset outside the drawer. The price slider needs real catalogue-wide bounds to be honest about where the prices are, and the admin has none, so there it is the two number inputs alone.

What is shared is not only the markup but the phrasing: that categories are OR and tags are AND has to read the same on both screens or it stops being one rule.

This reverses a decision `InventoryFilters.tsx` argued for in a comment — that hiding controls above a data table costs more than the space it saves, and that a drawer overlays the very rows being filtered. Both are true and both are traded for consistency between the panels. The active-filter chips are what makes the trade bearable: the current filter stays readable beside the button without opening anything, which is the part the always-visible row was really protecting. Status gets chips too, since it is now behind the button and is the filter most likely to empty a table.

`STATUS_OPTIONS` moves beside the filter type, because the drawer and the chips both need to turn a status into a label and a second copy is a second place for a new status to be forgotten.

The admin page object opens the flyout, acts, and closes it again — closing matters, because the drawer overlays the table every assertion in those specs is about.

Closes #169
2026-08-24 16:41:24 -05:00
bermudalamb 856d8c4511 fix(filters): give the category tree selectable values, and drive both controls by search (#139)
Linting / lint (pull_request) Successful in 2m10s
SonarQube Analysis / sonarqube (pull_request) Failing after 5m5s
Three things the e2e run found.

`toTreeData` still emitted `key`, which is how an antd `Tree` identifies a node and not how a `TreeSelect` selects one. Nothing could be picked, and `treeNodeFilterProp="title"` had nothing to filter against. It now emits `value`, matching the admin's CategoryTreeSelect.

The page object typed the name before clicking it, in both controls. Not for realism: the option lists are virtualized, so against a database holding hundreds of categories the wanted row never renders until a search narrows to it, and scrolling to it would be testing the virtual list rather than the filter.

Tag options are matched by class rather than by role, for the reason AdminInventory.toggleStatus already records — antd renders an invisible role="listbox" shim beside the real list, so getByRole('option') resolves to something zero-sized that can never be clicked. The drawer's own title is a plain element rather than a heading, so the click that closes an option list lands on the Favorites heading instead.

The multi-category URL assertion accepts the comma either percent-encoded or literal. URLSearchParams encodes it, which is how the `tags` parameter has always looked, and both spellings parse.
2026-08-24 16:26:40 -05:00
bermudalamb faf38be91a feat(filters): make the storefront filter panel searchable and multi-select (#139)
The filter drawer did not scale with the taxonomy behind it. Categories were a bare antd `Tree` rendered at whatever depth it had grown to, with no search and single selection, and tags were a wall of every tag in the system. Neither said what was selected except through highlighting and chip colour.

Both are now searchable multi-selects. Categories keep their hierarchy in a `TreeSelect`, matching the admin's `CategoryTreeSelect` so the two screens behave alike; tags become a multiple `Select` whose selected pills keep their colours, which is the only place a tag's colour was load-bearing.

Several categories combine as OR. A customer picking Furniture and Decor wants both, not the empty intersection, and each selected id still expands to its descendants, so the answer is the union of the subtrees. That is deliberately the opposite of the tag rule, which stays AND, and both headings now state their rule rather than leaving it to be discovered.

`ItemFilters.categoryId` becomes `categoryIds` end to end. The recursive CTE is seeded with `= ANY($n::int[])` rather than one id, which walks every selected root in one recursion and gives the OR for free; matching on `IN` keeps it a set test, so an item under two selected branches still appears once. The query parameter keeps its singular name and becomes comma-separated, the shape `tags` and `status` already use, so every `?category=1` link written before this still parses as a list of one. A list containing anything unreadable is still a 400, per decision 9 — honouring the readable half would answer a narrower question than the one asked and look indistinguishable from a filter that worked.

The admin's inventory filter stays single-select, since it asks what is in a category rather than in any of several, but reads and writes a list of at most one so there is one shared filter type rather than two that drift.

Closes #139
2026-08-24 16:02:33 -05:00
bermudalamb 70cc3056e7 Merge pull request 'feat(admin): make the placeholder chips insert at the cursor (#143)' (#168) from feature/143-clickable-placeholders into main
Linting / lint (push) Successful in 1m57s
SonarQube Analysis / sonarqube (push) Failing after 5m2s
Reviewed-on: #168
2026-08-24 15:48:41 -05:00
bermudalamb 7edc08e6eb feat(admin): make the placeholder chips insert at the cursor (#143)
Linting / lint (pull_request) Successful in 2m4s
SonarQube Analysis / sonarqube (pull_request) Failing after 4m52s
The chips above each email editor named the placeholders and left an admin to retype `{{holdDuration}}` by hand, getting the braces and the spelling right unaided. A typo did not announce itself either: a misspelled placeholder is not a required one, so the save succeeded and the email shipped with a literal `{{holdDuraton}}` in it.

Clicking one now inserts its tag at the caret in whichever field was last focused, replacing any selection.

Four things this needed that a click handler alone would not have given.

The field has to be remembered rather than read. Clicking a chip blurs whichever of the subject or body had focus, so `lastFocused` is tracked on focus instead. It starts on the body, because that is where placeholders almost always go and because a chip clicked on arrival should do something predictable rather than nothing.

The insert goes through setState, not the DOM. Writing into the element's `value` would appear to work and would not: both fields are controlled, so the next keystroke re-renders from state and the insert vanishes.

The caret has to be put back. A controlled re-render leaves it at the end, so the new position is stashed in a ref and applied in an effect once the value has landed — just past what was inserted, with focus retained, so typing carries on from there.

And the chips had to become buttons. An antd Tag renders a span, so a keyboard user could neither reach one nor activate it. The button carries the semantics and the Tag the appearance, which makes enter and space work with no key handling of our own.

The textarea is found by querying the wrapper rather than through MDEditor's ref, which exposes an internal store that is not part of its API. The editor renders exactly one.

Five end-to-end tests, checked against a naive implementation rather than only against the finished one: reverted to append-and-forget, four of the five fail. The one that still passes is the plain insert-into-empty case, which appending also satisfies — worth knowing, since on its own it would have proved nothing.

Two mistakes worth recording, because both were mine and both were caught by running things rather than reading them. The spec first drove the passwordReset template, which email-templates.spec.ts already owns; stored templates are global per database, so the two files raced across Playwright's workers. Moved to the verification template — different key, no race. And its last assertion claimed the body did not contain `{{greeting}}`, which is false for that template before any click, since its default body already has one. Comparing the body against its own earlier value is what was actually meant.

Verified: tsc clean over src and tests, lint unchanged, build clean, and the three email specs pass 18/18 together.

Closes #143
2026-08-24 15:45:36 -05:00
bermudalamb 77565723d6 Merge pull request 'refactor: turn on noUncheckedIndexedAccess in both workspaces (#101)' (#167) from feature/101-unchecked-indexed-access into main
Linting / lint (push) Successful in 1m59s
SonarQube Analysis / sonarqube (push) Failing after 5m28s
Reviewed-on: #167
2026-08-24 15:30:28 -05:00
bermudalamb f32913ef51 refactor: turn on noUncheckedIndexedAccess in both workspaces (#101)
Linting / lint (pull_request) Successful in 1m57s
SonarQube Analysis / sonarqube (pull_request) Failing after 5m3s
Indexing an array now yields `T | undefined`, which is what it always did — the compiler simply says so. Enabled in backend/tsconfig.json and frontend/tsconfig.json, and in tsconfig.sonar.json alongside it so the drift guard stays satisfied.

The sequencing this issue insisted on was right, and the numbers show why. Before #159 typed the query results, `rows[0]` was `any` and `any` indexes to `any`: the flag would have found close to nothing and the count would have changed completely afterwards. With the rows typed it finds 57 — 50 in the backend, 4 in the frontend, 3 in the Playwright suite — and they fall into three kinds.

Thirty are `rows[0]` after a `rows.length` guard. TypeScript cannot connect the two, and rewriting them as `const [row] = rows; if (!row) …` makes the guard and the use the same check, which is better code independently of the flag.

Ten are rows a statement guarantees — `INSERT … RETURNING`, or a lookup for an id the session middleware has already matched. These get `requireRow(rows, what)`, a new helper in db.ts that throws naming the query. A thrown error rather than a non-null assertion: if the assumption is ever wrong, an assertion hands `undefined` to the next line and fails somewhere unrelated, whereas this fails at the query and says which. asyncRoute turns it into a 500, which is the right answer for "the database did not do what the statement says it does". It also states the assumption once instead of ten times.

The rest is ordinary indexing the compiler cannot prove: a regex capture group that the pattern guarantees, `split('+')[0]`, a modulo kept in range, `hasOwnProperty` failing to narrow an index signature, and Express typing route params as an index signature so `req.params.itemId` is `string | undefined` on a route that cannot match without it.

One correction to this issue's premise, which matters for what it was expected to find. The body says "in a handful it does not guard at all", and the unguarded-500 risk it describes was not found. Every `rows[0]` either sits behind a length check or behind a statement that guarantees a row. What the flag actually bought was the ten places where that guarantee was real but unstated, and those now say so.

Two changes worth calling out because they are not mechanical. TAG_COLORS is typed `[string, ...string[]]` in both copies rather than `as const` — the first attempt used `as const`, which narrowed the elements to literals and broke adminTags, so the annotation keeps `string` while telling the compiler index 0 exists. And the filter drawer's slider falls back to the bounds it was given rather than to null, because null there reads as "no filter" and would widen the results rather than leave them unchanged.

Test files needed changes too, since ts-jest compiles them against the same config: a regex destructure in the compose guard, and ten `mock.calls[0][0]` reads where the surrounding assertions already establish the call happened.

Verified: tsc clean over backend, frontend src and the Playwright suite; unit 254/254; integration 238/238; frontend build clean; lint unchanged in both workspaces.

Closes #101
2026-08-24 15:25:09 -05:00
bermudalamb 5d0b14d6ad Merge pull request 'refactor(backend): type the remaining query results (#159)' (#165) from feature/159-type-remaining-queries into main
Linting / lint (push) Successful in 2m4s
SonarQube Analysis / sonarqube (push) Failing after 5m7s
Reviewed-on: #165
2026-08-24 15:03:48 -05:00
bermudalamb 179cbad225 refactor(backend): type the remaining query results (#159)
Completes the typing. Every `.query(...)` in backend/src whose rows are read now carries a row type: adminCustomers, adminCategories, shippingAddresses, adminTags, adminEmailTemplates, adminSettings, public, server and the auth middleware. Typed sites go from 49 to 78, and there are no untyped reads left anywhere.

Writes and transaction control stay untyped, which is the exemption #159's criteria allow for and the reason is stated in each file: they return nothing anyone reads, and annotating them would bury the ones that matter.

The aggregates needed checking rather than guessing, and the answer was not what the shapes suggest. Postgres returns COUNT as bigint and SUM as numeric, and node-postgres hands both back as strings — only an explicit ::int cast arrives as a number. Probed against the real database: COUNT(*) is a string, COUNT(*)::int is a number, SUM() is a string, MAX(timestamptz) is a Date.

That makes the admin customer list a mixture. order_count and total_spent_cents are strings; reserved_count, which the query casts, is a number. They are typed as what they are.

Which surfaces a mismatch worth knowing about and not fixed here. frontend/src/admin/adminCustomersApi.ts declares both as `number`, and Customers.tsx sorts with `a.order_count - b.order_count` and renders with `(v / 100).toFixed(2)`. Those work, because `-` and `/` coerce a numeric string. The first `+` written against either — a column total, say — will concatenate instead. Nothing is broken today; the types on both sides simply disagree about reality, and one of them is now right. Changing the API to cast would alter the response shape, which is a behaviour change and belongs in its own issue.

Two smaller shapes worth a note. shipping_addresses.usps_standardized is jsonb that is only ever handed to the client, so it is `unknown` rather than a guessed object. And `SELECT 1 ... ` used purely for `.length` has no column name of its own — Postgres calls it `?column?` — so it is an index signature with nothing read out of it rather than a fabricated field.

Verified: tsc clean, unit 254/254, integration 238/238, backend lint unchanged from main.

Closes #159
2026-08-24 15:03:48 -05:00
bermudalamb 1f470c0c02 Merge pull request 'docs(ci): correct the #154 hypothesis — the database is wiped before the tests start (#154)' (#166) from feature/154-correct-hypothesis into main
Linting / lint (push) Successful in 1m58s
SonarQube Analysis / sonarqube (push) Failing after 6m2s
Reviewed-on: #166
2026-08-24 15:03:08 -05:00
bermudalamb ae20568601 docs(ci): correct the #154 hypothesis — the database is wiped before the tests start (#154)
dmesg came back empty, and the evidence that actually settles it was in the original log the whole time. Step 7 `Run migrations` succeeded, then step 9's globalSetup applied all six migrations again from scratch. Both point at the same database — migrate.js reads PGHOST/PGDATABASE and the job sets those and TEST_PG* to the same service and the same redefined_test — so had step 7 migrated it, globalSetup would have printed "No migrations to run!", which is what a local run prints.

It found an empty database. The wipe was already happening before the tests started, which makes this a database being reset repeatedly rather than a container dying partway through a heavy run, and accounts cleanly for the empty dmesg: a restart is not a kill.

The suspect moves from memory pressure to the runner's handling of `services:`, where act_runner has been uneven across releases. The diagnostics change with it: A3 is dropped because it was designed to catch starvation, A2 is demoted to a fallback, and the new first check needs nothing but the Gitea UI — compare step 7 and step 9's migration output in any failing run.

The superseded hypothesis is kept rather than deleted. A future reader finding memory ruled out is better served by seeing why it was suspected and what refuted it than by a document that never mentions it.

Refs #154
2026-08-24 15:03:08 -05:00
bermudalamb fc3a190ec2 Merge pull request 'refactor(backend): type the admin item queries, and fix the stale status union (#159)' (#164) from feature/159-type-admin-queries into main
Linting / lint (push) Successful in 2m5s
SonarQube Analysis / sonarqube (push) Failing after 4m37s
Reviewed-on: #164
2026-08-24 14:45:29 -05:00
bermudalamb d43e2d5871 refactor(backend): type the admin item queries, and fix the stale status union (#159)
Linting / lint (pull_request) Successful in 2m11s
SonarQube Analysis / sonarqube (pull_request) Failing after 5m2s
admin.ts has no untyped reads left. Typed sites go from 43 to 49.

New ItemRecord in itemSelect.ts for the bare `items` row that `RETURNING *` gives back. Deliberately not AdminItemRow: that describes a select which joins the category and adds images and tags as subqueries, so typing a RETURNING * as it would promise three fields the result does not contain. Three shapes for one table, because three different queries return three different things.

The typing found a real defect on its first run, which is the case for doing this at all.

`ItemStatus` in types.ts was `'available' | 'reserved' | 'sold'`. The database has four values and defaults to 'pending' — items have arrived pending since #90. itemFilters.ts declared its own copy that had all four and was correct. Two declarations of one union with nothing connecting them: one went stale and nothing said so.

It was invisible while query rows were `any`. Typing them turned `if (status === 'pending')` in admin.ts into TS2367, "this comparison appears to be unintentional because the types 'ItemStatus' and '\"pending\"' have no overlap" — a compiler telling us the unpublish route's guard could never be true, against a type that was simply wrong.

Confirmed against the database rather than by picking the more plausible of the two declarations: `SELECT DISTINCT status FROM items` returns pending, available, reserved and sold.

Fixed by removing the duplication rather than by patching both copies. types.ts now holds the only declaration and itemFilters.ts imports it, re-exporting so its existing importers are unaffected. Patching both would have left the next drift free to happen the same way.

Verified: tsc clean, unit 254/254, integration 238/238, and backend lint unchanged — the four warnings it reports are identical to those on main with these changes stashed, so none of them are new.

Refs #159
2026-08-24 14:15:27 -05:00
bermudalamb 36c9fe9227 Merge pull request 'refactor(backend): type the customer query results (#159)' (#162) from feature/159-type-customer-queries into main
Linting / lint (push) Successful in 1m56s
SonarQube Analysis / sonarqube (push) Failing after 6m9s
Reviewed-on: #162
2026-08-24 13:23:29 -05:00
bermudalamb c5fe84fba5 refactor(backend): type the customer query results (#159)
Linting / lint (pull_request) Successful in 1m57s
SonarQube Analysis / sonarqube (pull_request) Failing after 5m3s
The largest file, 43 query sites, now with none of its reads untyped. Typed sites across the backend go from 22 to 43.

CustomerRecord extends the existing CustomerRow rather than restating it, because that is the relationship that actually holds. CustomerRow was already there and is not a table row — it is the subset safe to return to the customer, written that way so adding a column could not silently start being echoed back by a `...c` downstream. The full row read by `SELECT *` is that subset plus seven fields that are deliberately not on it, password_hash among them. Extending keeps the two connected: adding a column to the table means adding it to CustomerRecord and deciding at that moment whether it belongs in CustomerRow, which is exactly the decision the older comment is about.

The column list came from the live schema rather than from reading migrations, since the migrations are additive and reconstructing the current shape from six files invites getting a nullability wrong.

Typing the data export surfaced something worth a decision, and it is recorded in the code rather than quietly changed. `GET /me/export` runs `SELECT * FROM orders` and sends every column verbatim, including raw_event — the processor's entire capture payload. That is defensible for a GDPR export, since it is the customer's own transaction, but it is a decision rather than an accident, and it is now visible in a type instead of hidden behind `any`. The order-history route two functions above deliberately selects six named columns instead, which is the contrast that makes the export's behaviour worth confirming. No behaviour changed here; #159 is about types.

Nullability follows the schema rather than optimism: orders.amount_cents, status, item_id, customer_id and checkout_id are all nullable in Postgres, and customers.first_name and last_name are nullable despite registration requiring them, because customers who registered while the field was optional genuinely have none.

Verified: tsc clean, and the full integration suite passes 238/238 across 17 suites.

Refs #159
2026-08-24 13:19:18 -05:00
bermudalamb bd30d20c20 Merge pull request 'refactor(backend): type the cart and checkout query results (#159)' (#161) from feature/159-type-checkout-queries into main
Linting / lint (push) Successful in 1m55s
SonarQube Analysis / sonarqube (push) Failing after 5m3s
Reviewed-on: #161
2026-08-24 13:13:12 -05:00
bermudalamb 72c49719fc refactor(backend): type the cart and checkout query results (#159)
Linting / lint (pull_request) Successful in 2m5s
SonarQube Analysis / sonarqube (pull_request) Failing after 4m49s
The transaction paths, taken before the larger files because this is where `any` is most expensive: these are the queries that lock rows, move money and mark items sold, and where a mistyped field reaches a customer as a wrong price rather than a broken page.

Typed query sites go from 6 to 22.

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. The convention is stated once in each file rather than implied, since #159's acceptance criteria say every call is typed "or explicitly exempted with a reason" and this is that reason.

Two hand-written annotations are gone as a direct consequence. `items.reduce((sum: number, it: CartItem) => …)` and `checkoutItems.map((ci: { item_id: number }) => …)` existed only because `rows` was `any` and inference had nothing to work from. With the query typed, both infer, and the second one is the more interesting of the two: it was a structural type written inline that duplicated the real row shape and could have drifted from it silently.

CART_ITEM_SELECT's type records something the SQL states and no reader would otherwise know: the images aggregate selects only id and image_path, so it is `Pick<ItemImage, 'id' | 'image_path'>[]` rather than `ItemImage[]`. Typing it as the full shape would have promised a sort_order that is not in the projection.

The same hand-kept caveat as the item selects applies and is written into both files: `query<T>` asserts a shape rather than checking it, because TypeScript never reads the SQL. The integration suite is what catches a select and its type disagreeing.

Verified: tsc clean, and the suites covering these paths pass — cart, favorites and adminInventory 53/53, then cart and soldFilter 19/19.

Refs #159
2026-08-24 13:07:47 -05:00
bermudalamb d99cf28e18 Merge pull request 'refactor(backend): type the item query results (#159)' (#160) from feature/159-type-query-results into main
Linting / lint (push) Successful in 2m11s
SonarQube Analysis / sonarqube (push) Failing after 4m43s
Reviewed-on: #160
2026-08-24 13:01:48 -05:00
bermudalamb a75d9fe155 refactor(backend): type the item query results (#159)
First stage of typing the query results, and the one that sets the pattern. `pg` types `rows` as `any[]`, so every row this application reads entered a strict codebase as `any` — 1 of roughly 184 query sites carried a type before this.

The row types live in itemSelect.ts, beside the selects that produce them, rather than in types.ts. They describe a projection rather than a table, and the two projections differ on purpose: ADMIN_ITEM_SELECT takes `i.*` while PUBLIC_ITEM_SELECT 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 select was written to exclude, so PublicItemRow and AdminItemRow share a base and the admin one adds the three fields it is allowed.

types.ts gains ItemTag, which the tags subquery has always built and nothing had named.

What this buys, demonstrated rather than claimed: introducing `rows[0].price_cent` at a read site now fails the build with "Property 'price_cent' does not exist on type 'ItemRowBase'. Did you mean 'price_cents'?". Before this it compiled, returned undefined, and reached the customer as an empty price.

What it does not buy is written into itemSelect.ts rather than left for the next reader to assume. `pool.query<T>` asserts a shape; it does not check the SQL, which TypeScript never reads. Dropping a column from a select without dropping it from its type compiles cleanly and every read goes on type-checking while being undefined at runtime. The selects and their types are kept in step by hand, and the integration suite is the only thing that catches them disagreeing, because it runs the real queries against a real schema. The acceptance criteria on #159 originally claimed the compiler would catch that; it will not, and the issue has been corrected.

Verified: tsc clean, and the four integration suites that exercise these selects pass 87/87.

Refs #159
2026-08-24 13:01:48 -05:00
bermudalamb 9a953b060f Merge pull request 'refactor(frontend): triage the setState-in-effect sites (#99)' (#158) from feature/99-setstate-triage into main
Linting / lint (push) Successful in 1m52s
SonarQube Analysis / sonarqube (push) Failing after 4m36s
Reviewed-on: #158
2026-08-24 12:19:38 -05:00
bermudalamb 45f1c77160 refactor(frontend): triage the setState-in-effect sites (#99)
Linting / lint (pull_request) Successful in 2m5s
SonarQube Analysis / sonarqube (pull_request) Failing after 4m56s
Eleven warnings that looked alike and were not. This is a decision per site rather than eleven fixes, which is what the issue asked for — some of these would be made worse by "fixing" them.

One was a real defect. VerifyEmail routed a fact through an effect that was already knowable during render: whether the link carries a token comes from the URL. The component therefore rendered once as a spinner in a state that was never true — a link with no token was never "verifying". Both pieces of state now derive their initial value from the token, and the effect's missing-token branch becomes an early return, so the failure is what the first render shows.

Two are a defensible reset. CartProvider and FavoritesProvider clear their collection when the customer becomes null, which is synchronisation with the session rather than derived state. Deriving instead would push "signed out" onto every consumer of those contexts, and remounting on a `key` is more indirection than the problem deserves. Decided and written down rather than left for the next reader to re-investigate.

Eight are legitimate and flagged conservatively. Six are a pending flag before a fetch — the rule cannot tell a spinner from a value that was already known. Cart's lapsed-item refetch is synchronisation with a server-side release the client cannot observe. useNow subscribes to the clock, which is the case the rule's own documentation names as correct.

Each of the ten that stay carries the reason and a targeted disable, so lint drops from thirteen warnings to two — and the two left are the unrelated no-alphabetical-sort pair. Suppressing per site rather than switching the rule off keeps it live for new code, which is where the next VerifyEmail would be caught.

The trap #60 recorded caught this, in a variant it does not describe. Placing the disable above `useEffect(` works only for a single-line effect: where the effect spans several lines the flagged line is the setState inside the body, so the directive covered nothing and produced both an unused-disable warning and the original one. Three sites were wrong that way on the first attempt. Confirmed fixed by the absence of "Unused eslint-disable directive" from the output — a disable that covers nothing reports itself, which is what makes this checkable rather than assumed.

Verified: tsc clean over src and tests, and the specs covering the changed behaviour pass — verify-email, auth, favorites, orders, cart-countdown, resend-verification, 32 of 33 with the one failure passing 10/10 in a serial re-run.

Closes #99
2026-08-24 12:11:11 -05:00
bermudalamb 88dc627a58 Merge pull request 'refactor(frontend): declare props read-only, and drop the deprecated antd prop (#100)' (#157) from feature/100-readonly-props into main
Linting / lint (push) Successful in 2m11s
SonarQube Analysis / sonarqube (push) Failing after 5m4s
Reviewed-on: #157
2026-08-24 12:01:54 -05:00
bermudalamb 8de261538b refactor(frontend): declare props read-only, and drop the deprecated antd prop (#100)
Linting / lint (pull_request) Successful in 2m4s
SonarQube Analysis / sonarqube (pull_request) Failing after 4m51s
Seventeen components declared props the compiler was free to assume were mutable, and one antd prop had gone stale. Both mechanical, neither with any behaviour attached.

React never writes to props, and `Readonly<>` says so to the compiler rather than only to the reader. This finishes a pattern the codebase had already chosen rather than introducing one: AccountDetails and EmailTemplateEditor were already written as `type Props = Readonly<{…}>`, so the thirteen named prop interfaces are converted to that same shape and the four context providers, which annotate `{ children }` inline, get `Readonly<{ children: React.ReactNode }>`.

Cart.tsx was the last place passing `destroyOnClose`, deprecated in antd 5.20. Twelve other call sites across the admin screens, the filter drawer and four customer modals already use `destroyOnHidden`, so this one was simply stale. Deprecated props keep working until they do not, and the failure then arrives as an antd upgrade breaking something unrelated to the change being made.

Counted rather than assumed, which the issue specifically asks for, because a `Readonly<>` in the wrong position type-checks and fixes nothing: lint goes from 31 warnings to 13, a drop of exactly eighteen, and both rules disappear from the breakdown entirely rather than merely thinning out.

What that leaves is the point of doing it. The remaining thirteen are eleven `set-state-in-effect` and two `no-alphabetical-sort` — so the frontend's warnings are now only the ones that need a decision, which is what makes #99 tractable. It had grown from the eight in that issue's title to eleven, two of them added by #97's clock tick and lapsed-cart refetch.

No behaviour change intended, so the bar was the end-to-end suite. Full run: 121 passed, 8 failed; all eight pass in a 45/45 serial re-run, which is the shared-database and event-loop flakiness this suite has had throughout.

Closes #100
2026-08-24 11:43:10 -05:00
bermudalamb e926ad23b9 Merge pull request 'feat(ops): schedule database and uploads backups, and document the restore (#147)' (#156) from feature/147-scheduled-backups into main
Linting / lint (push) Successful in 2m28s
SonarQube Analysis / sonarqube (push) Failing after 5m11s
Reviewed-on: #156
2026-08-24 11:30:37 -05:00
bermudalamb ebefcbb76b feat(ops): schedule database and uploads backups, and document the restore (#147)
Linting / lint (pull_request) Successful in 2m9s
SonarQube Analysis / sonarqube (pull_request) Failing after 4m52s
The only copy of every customer, order and one-of-a-kind item was the live Postgres data directory, plus whatever the deploy checklist's manual pg_dump happened to have caught. That dump is good and stays, but it only runs when someone deploys: a quiet week meant the newest copy of real customer data was a week old, and nothing bounded the gap.

Two services rather than one, because they are different jobs. The database is small, changes constantly and wants a logical dump — daily, gzipped, 7/4/6 daily-weekly-monthly retention. Uploads are large and append-mostly and want an archive — weekly, 56 days, mounted read-only so a backup process cannot damage the thing it is backing up. Forcing both through one tool would serve one of them badly.

The dumper is pinned to postgres-backup-local:16 to match the server. pg_dump refuses to dump a server newer than itself, so a floating tag is a backup that stops working the day Postgres is upgraded — silently, because nothing reads a dump until it is needed. It depends_on the database's existing pg_isready healthcheck, which is the constraint that shaped this: a dumper is a client, and without that the first run after a NAS reboot races Postgres coming up.

Both carry a staleness healthcheck rather than trusting the schedule. A regime that stopped a month ago is indistinguishable from a working one until a restore is attempted, and `find -mmin` is the cheapest thing that tells them apart. It surfaces in Portainer beside the app rather than somewhere separate to remember to look. Windows are the interval plus grace — 26 hours daily, 9 days weekly — so a late run is not a failure, and start_period covers the first cycle when nothing has been written yet.

Verified rather than assumed. Both images were pulled and checked to have a shell and `find`, since a CMD-SHELL healthcheck against an image without one reports unhealthy forever. postgres-backup-local:16 ships pg_dump 16.10 against the postgres:16 server. The healthcheck expression was exercised three ways in the image itself — empty directory, fresh artifact, and one aged three days — and returns unhealthy, healthy, unhealthy. The compose file parses and the #118 drift guard still passes over it.

Three things these deliberately do not cover, written into the compose file and the doc rather than left to be discovered:

They run while the stack runs, so they cannot protect the stack's own teardown. Deleting the Portainer stack deletes them too. That is why the deploy checklist's manual dump stays, and README now says so where the checklist is.

They write to the same volume as the data they protect. That survives a bad migration, a dropped table, a bad deploy and a stack deletion, and not the disk. Getting a copy off /volume1 is a Synology-side job and is what turns this from a convenience into a guarantee.

Daily database against weekly uploads leaves a window where a restore pairs the two from different moments. An orphaned image is harmless; a row without its image is a broken thumbnail on one recent item, usually still on the admin's machine. Neither is data loss, and a synchronised snapshot is not worth the complexity to avoid it.

The restore procedure leads with practising on a throwaway database, because an untested backup is a file of unknown validity and a truncated dump looks exactly like a good one until it matters. It checks row counts and the pgmigrations head — the migration check being the one most easily skipped and most likely to bite, since a dump older than the code restores a schema the app will fail against.

Both open items are listed as unticked in the doc: no off-volume copy exists yet, and no restore has been performed. Until the second is done this documents an untested procedure, and it says so.

Refs #147
2026-08-24 11:23:28 -05:00
bermudalamb 949734d1e1 docs(ci): add the working document for the #154 schema-loss investigation (#154)
Linting / lint (push) Successful in 2m8s
SonarQube Analysis / sonarqube (push) Failing after 5m18s
The diagnosis so far, what has been ruled out, and the three read-only checks that would confirm or refute it — with slots to paste the output into and a note against each saying what the answer means.

Written as a working document rather than a summary because the decisive check has a timing constraint that is easy to miss: the Postgres service container is deleted when the job finishes, so watching it has to happen during the run. Discovering that after the fact costs another full run.

It also records the negative result deliberately. If the container is healthy throughout, the hypothesis is wrong and the document says which suspect is next, rather than leaving an abandoned theory for the next reader to re-derive.

Refs #154
2026-08-24 09:52:27 -05:00
bermudalamb c55b2b3a25 Merge pull request 'ci: let the summarisers summarise and the gate do the failing (#142)' (#155) from feature/142-ci-failure-reporting into main
Linting / lint (push) Successful in 2m9s
SonarQube Analysis / sonarqube (push) Failing after 5m24s
Reviewed-on: #155
2026-08-24 09:51:51 -05:00
bermudalamb 491c2652f3 ci: let the summarisers summarise and the gate do the failing (#142)
Linting / lint (pull_request) Successful in 2m10s
SonarQube Analysis / sonarqube (pull_request) Failing after 5m53s
A failing end-to-end run reported itself like this:

    Run node scripts/summarize-playwright.js frontend/playwright-results.json
        Failure - Main Summarize end-to-end tests
    exitcode '1': failure

which reads as a broken summary script. It was not — it was the summariser correctly reporting that tests had failed, with nothing said about it.

Two things combined to produce that.

The summariser doubled as the gate. It ended with `process.exit(stats.unexpected > 0 ? 1 : 0)`, so it failed the job itself. The workflow already has a step written for exactly that — `Fail if either suite failed`, whose comment explains it exists so `continue-on-error` on the suites cannot turn a failing suite into a passing job. That step was being skipped while its condition was true, because a step whose `if:` omits `always()` still implicitly requires its predecessors to have succeeded, and the summariser had already failed the job one step earlier. The gate written to be the place the job fails was dead code.

And the explanation went where the log is not. `report()` writes to GITEA_STEP_SUMMARY when it is set, which under Actions is always, so the counts and the list of failing tests landed in the Summary tab while the log showed a bare exit with no output at all.

So both scripts now exit 0 whatever they find, both print one line of counts to stdout as well as the markdown to the summary, and the gate carries `always() &&` so it actually runs. A failing suite now fails at a step named for what failed, and the log says how many.

Verified by executing both scripts against crafted results rather than by reading them: failing counts, passing counts, and a missing results file, each with and without GITEA_STEP_SUMMARY set. All four exit 0, the headline appears on stdout in every case, and the step summary still receives the full table and the failure list.

Not covered: nothing in this repository runs the scripts under `scripts/`. Jest's testMatch is scoped to backend/tests/unit, so a permanent regression test would need a runner these files do not have. Worth its own issue rather than widening the backend suite's roots to reach the repository root.

Closes #142
2026-08-24 09:46:54 -05:00
bermudalamb d4e1b3ac51 Merge pull request 'refactor(storefront): extract the catalogue machine from App.tsx as useCatalogue (#98)' (#153) from feature/98-use-catalogue into main
Linting / lint (push) Successful in 2m3s
SonarQube Analysis / sonarqube (push) Failing after 4m45s
Reviewed-on: #153
2026-08-24 09:05:07 -05:00
bermudalamb 461ab01d4c refactor(storefront): extract the catalogue machine from App.tsx as useCatalogue (#98)
App.tsx was 361 lines, and roughly sixty of them were one cohesive concern with nothing to do with laying out a page: fetching the catalogue for the current filters, debouncing it, and negotiating with the session before it could ask. Six pieces of state, four callbacks and two effects, sharing scope with the header, footer, filter drawer and auth modal that make up the rest of the file.

This is the same shape #81 dealt with once. That change took out the rendering half by extracting Catalogue and brought the file under the cognitive-complexity limit. The state half stayed.

App keeps the URL as the source of filter truth, because that genuinely belongs to the page: a reload, a shared link and the back button all have to restore the same view. What moves is the request, the debounce, and the auth negotiation — and that last one is the reason this is worth doing. The rule the comments explain at length, that firing before the session resolves would 401 and show an outage banner to someone who is in fact signed in, now has somewhere to live rather than being a pair of derived booleans in a page component.

The hook decides when the favorites filter needs a session; the page decides what to do about it, through an onAuthRequired callback, because the prompt is a modal the page owns.

The serialise-then-reparse of the filters is kept, with the reason written down rather than left to be rediscovered. Depending on a string is what keeps `load` referentially stable while the filters are value-equal, and `load` is what the debounce effect depends on — depending on the object would give a new `load` every render, restart the debounce each time, and fire a request per keystroke. The alternatives considered were a ref written during render and threading the key through the page, and both are worse than one honest comment.

filterKey is returned rather than recomputed by the caller: the page needs exactly that value to reset the catalogue's error boundary, so a crashed grid gets another chance when the filters change.

App.tsx is 300 lines. No behaviour change is intended, so the bar is the end-to-end suite unchanged — the storefront listing, the filters, the favorites-requires-sign-in prompt, the failure banner and the boundary reset all pass.

Also removes what the extraction left dead in App.tsx: the useEffect import, the debounce constant, and `authLoading`, which existed only to decide whether to fire the request.

Refs #98
2026-08-24 09:05:07 -05:00
bermudalamb c779b5a180 Merge pull request 'fix(cart): make the reservation countdown tick, and warn against the real hold (#97)' (#152) from feature/97-cart-countdown into main
Linting / lint (push) Successful in 2m3s
SonarQube Analysis / sonarqube (push) Failing after 5m14s
Reviewed-on: #152
2026-08-24 09:03:23 -05:00
bermudalamb 5ef97bef21 fix(cart): make the reservation countdown tick, and warn against the real hold (#97)
Linting / lint (pull_request) Successful in 2m10s
SonarQube Analysis / sonarqube (pull_request) Failing after 4m53s
The cart showed "2h 15m left" and turned it red in the final hour. Neither updated. Both readings happened during render from the wall clock, and nothing scheduled a re-render — no setInterval anywhere in the file — so the number a customer read was whatever it was when the page loaded, and the warning colour could only appear by accident, because the component had already rendered before the final stretch began.

That matters more here than in most shops: every item is one of a kind, so a lapsed reservation is not "buy it later", it is someone else buying the only one.

New useNow hook returns the time as state rather than merely forcing a re-render, and that is the point. A component reading Date.now() while rendering produces output that depends on the clock, which React is entitled to assume it does not — react-hooks/purity says so, and this was the only instance in the codebase precisely because it was the only place doing it. Reading `now` from state makes render a function of its inputs again, so the rule is satisfied rather than suppressed. It ticks every 30s, which matches the display's one-minute resolution, and only while the cart holds something, so an empty cart is not waking React forever.

A second defect the issue did not mention. The red warning was hardcoded to the final hour, but the hold became admin-configurable in #136 and accepts values as low as half an hour — so on any setting below an hour every item was red from the moment it was reserved, and a warning that is always on is not a warning. It now keys off the last tenth of the item's own added_at-to-expires_at span. Reading it from the item rather than from the setting also means an admin changing the value does not retroactively relabel a reservation granted under the old one.

At zero the row keeps saying "expiring…" and the page refetches on each tick while anything is lapsed, so it clears within one interval of the server's sweep actually releasing it. The client cannot know when that lands — the sweep runs every few minutes — so the wording claims imminence, which is true, rather than completion, which is not ours to say. The header badge is refreshed alongside, since it counts held items and goes stale the same way.

Verified against the unfixed component, not just the fixed one: two of the three new tests fail on the old code. The third documents the "expiring…" wording rather than the fix, and passes either way — worth having, but it is not evidence.

The tests use Playwright's clock control rather than waiting in real time, which also makes them deterministic: without it, "the text changed" would depend on where in the minute the run happened to start.

Refs #97
2026-08-24 08:56:45 -05:00
bermudalamb 3b3bd06bbe test(e2e): convert the remaining admin specs, completing the POM refactor (#137)
Linting / lint (push) Successful in 1m53s
SonarQube Analysis / sonarqube (push) Successful in 17m12s
The last nine: admin-taxonomy, admin-save-failures, admin-theme, admin-inline-category, admin-item-preview, admin-disable-customer, admin-reserved-items, admin-inventory-filters, email-templates and admin-email-settings.

Every spec in tests/e2e now goes through page objects. Measured rather than asserted:

- raw CSS and antd-internal locators in spec files: 0 (was 13)
- local `register` helpers: 0 (was 9)
- hardcoded http://localhost:5173: 0 (was 3)

The antd knowledge that was spread across seven spec files is now in four page objects, each with the reason written next to it. Three of those are things no reader could have guessed from the locator:

Segmented hides its real radio behind a styled label, so the input is found by role and cannot be clicked — the title attribute is the handle.

The status multi-select renders an invisible role="listbox" shim beside the real list, so getByRole('option') finds something zero-sized; and a selected status renders again as a tag carrying the same title, so an unscoped getByTitle is ambiguous. Matching the visible option class avoids both. The dropdown is also opened only when closed, because antd keeps it open after a selection in multiple mode.

Select popups render into a portal at the end of <body>, outside the tab panel they belong to, so a dropdown cannot be found by scoping to the panel.

AdminPage.tab gained an `exact` flag for one specific collision: the Emails tab contains a rail that also renders tabs, and "Email verification" contains "Email". Without exact matching, opening the Emails tab is ambiguous with the template inside it.

Two helpers stayed local rather than moving into page objects, because they belong to their file's subject rather than to a surface: favorites-filter's `favorite()`, which settles the opt-in modal and waits for its fade before the wrapper stops intercepting pointer events, and admin-theme's `luminance()`, which is a WCAG calculation and not a locator. Both now take page objects as parameters instead of reaching for locators themselves.

Verified: 26/26 spec files converted, tsc clean over the whole tree, lint at the 30-warning src baseline with nothing added. Full suite 123 passed / 5 failed; all five pass in a 33/33 serial re-run, which is the load-related flakiness this suite has had throughout and not a change here — the backend hashes passwords with bcryptjs, a pure-JS implementation that blocks the event loop for every request while it runs.

Closes #137
2026-08-23 19:54:57 -05:00
bermudalamb 549a08038e test(e2e): convert the favorites, availability and orders specs (#137)
favorites, favorites-filter, sold-filter, orders and pending-publish. Five more copies of "register a customer" and three more of the hardcoded base URL go with them.

Two locators that were hiding real knowledge are now named. `gridCell` is the item's whole antd column rather than its card, needed because the SOLD ribbon renders outside the card — three specs reached for `.ant-col` directly to get at it. And `chooseAvailability` goes through the title attribute because antd's Segmented hides the real radio behind a styled label, so the input is found by role and cannot be clicked; that fact was written out twice in comments and is now written once in code.

The favorite control is located page-wide rather than within a card. The storefront paginates as items accumulate and the control is named for its item anyway, so scoping to a card bought nothing and broke whenever the card was on another page.

favorites-filter keeps its local `favorite()` helper. It is genuinely local — decline the opt-in, wait for the fading modal to stop intercepting pointer events, confirm the heart flipped — and belongs to that file's subject rather than to the storefront. It now takes page objects as parameters instead of reaching for locators itself, which is what a spec-level helper should look like.

Two specs still drive the registration form rather than taking the `customer` fixture, and deliberately. Both are about a signed-out visitor being interrupted mid-action — favoriting an item, or switching on the favorites filter — and the claim is that the thing they asked for survives the interruption. Replacing the interruption with an API call would delete the test.

Verified: favorites 6/6, favorites-filter 7/7, sold-filter 6/6, orders and pending-publish 8/8. Notably favorites-filter's "keeps showing a favorite after it sells" passes, which had been failing on a strict-mode violation from two items sharing a name across runs.

Refs #137
2026-08-23 19:43:45 -05:00
bermudalamb 507c56bbb8 Merge pull request 'Feature/137 convert account specs' (#151) from feature/137-convert-account-specs into main
Linting / lint (push) Successful in 2m20s
SonarQube Analysis / sonarqube (push) Successful in 17m22s
Reviewed-on: #151
2026-08-23 19:36:17 -05:00
bermudalamb 9b3a03d1bf test(e2e): convert the storefront and filter specs onto page objects (#137)
Linting / lint (pull_request) Successful in 2m2s
SonarQube Analysis / sonarqube (pull_request) Successful in 17m17s
storefront, storefront-errors, theme, error-boundary and filters.

filters.spec.ts carried the last hand-rolled copies of createCategory, createTag and createItem, and the hardcoded `http://localhost:5173` that meant changing the port in the config would have moved every test except this one. Seeding now goes through support/api, and the host it needs lives in one constant. It has to be a constant rather than the config's baseURL: beforeAll runs with worker-scoped fixtures only and cannot read a test-scoped option, which is why the URL was inlined there in the first place.

New FilterDrawer object. The two rules it encodes are the ones the tests exist to pin down and neither is guessable from a locator: categories are a tree because the filter matches a node and everything filed beneath it, and tags combine with AND rather than OR, so selecting two means "must have both".

The active-filter chips go on StorefrontPage rather than the drawer, because that is where they render — and the scoping matters, since the drawer carries a "Clear all" of its own that an unscoped locator also matches.

StorefrontPage gains the three things the catalogue says instead of listing items. They are named together deliberately: the distinction between "No items yet" and "Couldn't load items" is the point, and several tests assert one is showing while the other is not, because telling a customer the shop is empty when the server is broken hides the outage.

The theme switch and the attribute it writes are both on Header now. The switch is in the header and `data-theme` lands on <body>, so a spec previously had to know about `body` to observe the control it had just clicked.

Verified: 12/12 across the four small specs, 8/8 on filters, tsc clean, lint unchanged at the 30-warning src baseline.

Refs #137
2026-08-23 17:50:59 -05:00
bermudalamb 4b0c01068f test(e2e): convert the account specs onto page objects (#137)
account-modal and account-details, taking two more copies of "register a customer" and two more of the `accountModal(page)` helper that every account-touching spec had rewritten.

Both files had grown their own vocabulary for the same dialog. account-details reached for `modal.getByLabel('Current password', { exact: true })` and its five siblings inline in each test, so a change to the account form meant editing six places in one file and more in the next. Those are named locators now, and the three multi-step operations — saving a name, changing a password, changing an email — are actions, because each is a disclosure to open and three fields to fill before the button does anything.

AccountModal.open() gains the 20s timeout the header already had, and for the same reason. Arriving at /account means booting the app and resolving the session against the server. The old specs never noticed because they registered through the form first, which loaded the app and confirmed the session before navigating; taking the API-registered `customer` fixture arrives cold, and the 5s default is comfortably beaten on an idle machine and missed on a loaded one.

Verified: 18/18 across three serial repeats, and 12/13 in parallel. The one failure is `changes the email address and marks it unverified again`, which fails identically on the unconverted file and passes whenever the suite is not saturated — the same load-related flakiness as the rest of the family, not something this commit introduced.

Refs #137
2026-08-23 17:46:45 -05:00
bermudalamb ed679986de Merge pull request 'test(e2e): convert the auth specs onto page objects (#137)' (#150) from feature/137-convert-auth-specs into main
Linting / lint (push) Successful in 2m1s
SonarQube Analysis / sonarqube (push) Failing after 17m36s
Reviewed-on: #150
2026-08-23 17:36:28 -05:00
bermudalamb abcc684447 test(e2e): convert the auth specs onto page objects (#137)
Linting / lint (pull_request) Successful in 2m9s
SonarQube Analysis / sonarqube (pull_request) Successful in 18m4s
First conversion batch: auth, password-reset, resend-verification. verify-email is left alone — it already used semantic locators and duplicated nothing, and rewriting it to prove a point would be churn.

Four of the nine copies of "register a customer" go here. auth.spec.ts and password-reset.spec.ts each carried their own `register`, and resend-verification.spec.ts its own `registerCustomer`, all three re-explaining the same bcrypt wait and the same "the header, not the URL, proves the session exists" reasoning in slightly different words. Two also carried their own `logout`, and two their own `uniqueEmail` with different prefixes.

Most of those tests were not about registering. They needed an account to exist so they could test logging out, resetting a password, or resending a verification email, and they paid for a bcrypt round-trip through the form to get one. Those now take the `customer` fixture, which registers through the API. The three tests that genuinely are about the registration form still drive it, because the thing under test has to be the thing exercised.

The batch drops from 47s to 26s as a side effect, which is the cost of that round-trip made visible.

password-reset.spec.ts loses its inline pg.Client. The reasoning for reading the database directly is unchanged and still right — an endpoint returning a reset token for an arbitrary address is account takeover if it is ever reachable — but it now lives in support/db.ts where it cannot be copied into the next spec wanting a shortcut. It also stops defaulting to port 55432, which is the integration suite's disposable Postgres rather than the database the app under test is connected to, and is Hyper-V-reserved on at least one machine here. Both tests in that file previously failed with a bare ECONNREFUSED unless TEST_PGPORT was set by hand; they now pass with no environment at all.

New PasswordResetPages object covers both halves of recovery — requesting a link, and using one — because they are one flow and a test usually crosses between them.

One lint decision worth recording. Requesting a Playwright fixture IS using it: destructuring `customer` is what makes the account exist, whether or not the body then reads the address. The linter cannot see that side effect and reports every such fixture as an unused variable. The first attempt at appeasing it was a `void customer;` line per test, which is noise standing in for a comment — and sonarjs flags that too, so it traded one warning for another. `no-unused-vars` is now configured with `args: 'none'` for tests only, with the reason written next to it. Variables are still checked; only parameters are exempt.

Verified: 26/26 in the converted batch, and 127 passed in the full suite with four failures — three in the known #116 flaky family, and resend-verification's rate-limit test, which passes 9/9 across three repeats in isolation and is timing-sensitive under parallel load rather than changed by this commit.

Refs #137
2026-08-23 17:30:49 -05:00
bermudalamb 882f42447b Merge pull request 'test(e2e): add page objects, fixtures and a typed test build (#137)' (#149) from feature/137-playwright-page-objects into main
Linting / lint (push) Successful in 2m50s
SonarQube Analysis / sonarqube (push) Failing after 18m50s
Reviewed-on: #149
2026-08-23 17:19:49 -05:00
bermudalamb 5c907fcf9a test(e2e): add page objects, fixtures and a typed test build (#137)
Linting / lint (pull_request) Successful in 2m13s
SonarQube Analysis / sonarqube (pull_request) Successful in 17m49s
Foundation only. No spec is converted in this commit, so the suite behaves exactly as before — the conversions follow in themed batches, each leaving the suite green.

The suite had grown by copy-paste. Registering a customer was implemented nine times, as `register` in five files and `registerCustomer` in four more, each carrying its own re-explanation of the same bcrypt wait and the same "the header, not the URL, proves the session exists" reasoning. `uniqueEmail` was reinvented per file with a different prefix and a different encoding each time. Thirteen locators reached into antd's internals — `.ant-tabs-tab-active .ant-tabs-tab-btn`, `.ant-select-item-option[title=...]`, `.ant-col` — spread across seven files, so an antd upgrade breaks tests that have nothing to do with it.

Page objects hold named locators and the actions that operate on them; assertions stay in the specs, so a test reads as its own statement of what it verifies. The exception is an action waiting for its own completion — registering waits for the account button, opening an admin tab waits for its panel — because that wait is the action's contract, and pushing it to callers would recreate the duplication being removed.

Fixtures carry the setup rather than the specs. `customer` registers through the API rather than the form: nine specs drove the registration form purely to arrive at a signed-in session, so a broken form failed a hundred tests that were not about it, and each paid for a bcrypt round-trip through the UI. `page.request` shares the browser context's cookie jar, so the session belongs to `page`. The specs that are genuinely about registration drive the form properly through `authModal`.

`adminApi` takes its base URL from the Playwright config. One spec built its own request context against a hardcoded http://localhost:5173, so changing the port in the config would have moved every test except that one.

The inline pg.Client in password-reset.spec.ts moves to support/db.ts. The reasoning for reading the database directly is unchanged and still right — an endpoint that returns a reset token for an arbitrary address is account takeover if it is ever reachable, and an environment gate is a thin thing to stand between that and production — but it no longer sits in a spec where it can be copied into the next one wanting a shortcut. Its default port becomes 55500, the local stack's, rather than 55432: that is the integration suite's disposable Postgres, a different database with different credentials that the app under test is not connected to, and it is Hyper-V-reserved on at least one machine here, so the spec failed with a bare ECONNREFUSED naming a port nobody had chosen.

tsconfig.test.json type-checks the tree and runs as part of `npm run build`. It is separate from tsconfig.json rather than widening its `include`, because scripts/check-sonar-tsconfig.js compares the two configs' include arrays, and pulling the Playwright suite into SonarQube's analysis program is a different decision from type-checking it. The whole existing suite type-checks clean on the first run.

Lint now covers tests/ with `project` rather than `projectService` — the service resolves a 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. no-floating-promises is an error here: Playwright's API is almost entirely promises, and a missing await on an assertion does not fail, it passes having asserted nothing.

Four rule families are switched off for tests rather than left as warnings. Bringing these files in scope added 45, of which none were defects, and #60's argument is that a gate nobody reads is not a gate. There is no React in this directory, and the hooks rules fire on ordinary functions whose parameter is named `use` — which Playwright fixtures are, by its own API. Test credentials are the point of a test and the project's own rule is that they live only in test paths, which is here. Math.random builds unique fixture names so parallel workers do not collide, and a cryptographic generator would say something untrue about what the value is for. The count is back to the 30 that src carried before.

Refs #137
2026-08-23 17:11:03 -05:00
bermudalamb b2afa40800 Merge pull request 'fix(deploy): run the image QA reviewed rather than rebuilding production (#146)' (#148) from feature/146-promote-tested-image into main
Linting / lint (push) Successful in 1m58s
SonarQube Analysis / sonarqube (push) Successful in 17m37s
Reviewed-on: #148
2026-08-23 16:52:25 -05:00
bermudalamb e48c7f585b fix(deploy): run the image QA reviewed rather than rebuilding production (#146)
Linting / lint (pull_request) Successful in 1m53s
SonarQube Analysis / sonarqube (pull_request) Successful in 18m27s
The production compose committed in #145 carried `build:` and `pull_policy: build`, copied from the QA stack without thinking about what they mean there. QA builds from this repository because QA is where a change is first assembled and reviewed. Production is not that; production is where the reviewed thing runs.

Worse, it contradicted the deploy this repository already documents. README's production steps promote the exact image QA reviewed — `docker tag redefined-designs:qa redefined-designs:latest` — with the stated reason that what ships is what was tested. A compose file that rebuilds instead quietly overrode that, and the two would have disagreed at the moment it mattered.

Rebuilding would be a defensible shortcut if a rebuild of the same commit produced the same image. It does not. The Dockerfile copies package.json without package-lock.json and installs with `npm install`, so both lockfiles in this repository are ignored in every build stage and every dependency range is resolved afresh. Two builds of one commit, minutes apart, can differ in any transitive dependency that published in between. "Same git ref" is therefore not "same image", and the reviewed bytes are the only thing that is.

So the service now declares `image: redefined-designs:latest` and nothing else. The image has to exist before the stack starts; a first deploy or a pruned NAS fails with "image not found" rather than silently building something new. That is the intended behaviour and is written into the file rather than left to be discovered.

The header records what removing `pull_policy: build` costs, because it is not free. That option exists in QA to stop a redeploy reusing a stale tag and appearing to succeed while running old code. Production reintroduces the same risk by a different route — a redeploy that reuses the previous `latest` because nobody re-tagged — so the promotion is load-bearing rather than a convenience, and the deploy has to say which image it is promoting.

Also corrects the README's claim that production runs from a stack outside this repository and so cannot be checked. That was true when it was written and stopped being true in #118; leaving it would have taught the next reader that the environment which just failed to boot is the one nothing watches.

The remaining half of #146 — copying the lockfiles and installing with `npm ci` so any rebuild means something — is not in this commit. It changes what CI and QA build as well as production, and belongs with its own verification that an unchanged commit produces an unchanged dependency tree.

Refs #146
2026-08-23 16:41:08 -05:00
bermudalamb 50c9b0dd39 Merge pull request 'fix(deploy): commit production's compose and bring it under the drift guard (#118)' (#145) from feature/118-production-compose into main
Linting / lint (push) Successful in 1m52s
SonarQube Analysis / sonarqube (push) Successful in 18m37s
Reviewed-on: #145
2026-08-23 16:15:14 -05:00
bermudalamb 3fde6fc6bf fix(deploy): commit production's compose and bring it under the drift guard (#118)
Linting / lint (pull_request) Successful in 1m54s
SonarQube Analysis / sonarqube (pull_request) Successful in 18m11s
Production refused to boot with "UPLOADS_DIR is required and is not set" while UPLOADS_DIR was set in Portainer's stack variables. Both statements were true at once. Portainer substitutes stack variables into the compose file rather than handing them to the container, so a variable with no line in the file never reaches the app — behaviour the QA compose already warns about at its ADMIN_GATE_SECRET entry, hit in production where nothing was watching for it.

Nothing could have caught it. The drift guard reads docker-compose.qa.yml, and production ran from a Portainer stack outside the repository that no test could see. That is worse than an even gap: UPLOADS_DIR is in ALWAYS_REQUIRED and the test was green, so the natural reading was that the deploying environments set it. QA did. Production did not.

So production's compose is now a file in the repository, deployed as a git repository stack rather than pasted into the web editor — otherwise the committed copy and the running copy drift apart again, which is the whole problem.

Values are hardcoded rather than interpolated wherever they are not secrets. Only a secret has a reason to stay out of the repository, and every interpolation is another chance for the failure above. UPLOADS_DIR in particular has to agree with the volume mapping, and splitting it across two files is how they drift.

The guard now runs over every deployment rather than QA alone, and checks each by handing its parsed entries to validateEnv itself rather than restating the rules. A restatement is one more copy to drift; running the real validator means the file is checked against exactly what the container checks at boot. Interpolated ${SECRET} values count as present, which is right — what is being guarded is that the line exists, since that is what decides whether the value reaches the container.

Environments differ on purpose, so the expectations are registered per file rather than shared: QA is demo mode with a mail allowlist and no PayPal credentials, production is the reverse of all three. A root-level compose file that is not registered fails the last test, so adding an environment forces the decision instead of silently inheriting whatever the loop asserted.

Verified by removing the UPLOADS_DIR line from the production file and confirming three tests fail, one of them reproducing the exact boot error. A guard of this kind that has never been seen to fire is indistinguishable from one that cannot.

Two things found while writing this and deliberately not changed here. RESERVATION_MINUTES is set in QA's compose and read nowhere in the code — drift in the opposite direction, which #118's scan half should catch. And production publishes 32750 on every interface exactly as QA does; that is #117, and the reasoning is recorded in a comment at the ports block rather than acted on, since changing it needs the proxy host entry repointed in the same pass.

Refs #118
2026-08-23 10:02:38 -05:00
bermudalamb c798ba08d7 Merge pull request 'feat(scripts): switch Node automatically, and add a test runner (#140)' (#144) from feature/140-node-scripts into main
Linting / lint (push) Successful in 1m53s
SonarQube Analysis / sonarqube (push) Successful in 17m35s
Reviewed-on: #144
2026-08-23 09:36:40 -05:00
bermudalamb 9ac2fa3ba1 feat(scripts): switch Node automatically, and add a test runner (#140)
start-local.ps1 knew exactly what was wrong when Node was too old and then made you fix it by hand. Assert-NodeVersion read node --version, found a major below 20, and threw a message telling you to run `nvm use 24.13.1` and start again in a new shell. A good error for a problem the script could simply solve — and since nvm's default here is 18.16.1, it was hit on every fresh shell.

It now runs `nvm use latest` itself and -Stop puts the machine back to 18.16.1. The revert also runs when a start fails partway: without that, a run dying in migrations leaves the machine switched with nothing started, and the -Stop that would restore it is never reached.

nvm rewrites a machine-global symlink rather than changing one shell, so this changes the Node version for every terminal on the machine while a script runs. That is the intent — the point is to work in whatever shell is already open — but it is announced every time rather than done quietly.

The switch is verified rather than trusted. nvm-windows exits 0 for switches that did not happen: a version it cannot find, a symlink it cannot rewrite without elevation, and — observed here — a rewrite issued immediately after another one, where the directory symlink is briefly still the old target. That last case turned up while testing this change: `nvm use latest` reported success and left Node on 18.16.1. So the result is read back and retried once, and nvm's own output is captured rather than discarded, because suppressing it hid the only message that explained the failure.

run-tests.ps1 runs the suites: -Suite unit|integration|e2e|all. One script with a parameter rather than three, because the version switch, the database bring-up and the TEST_PGPORT handling are shared and three copies would drift. The integration suite gets its own throwaway Postgres started and stopped around it, in a finally so a failing suite still tidies up. The e2e suite checks the backend is answering first and says what to start, rather than leaving twenty-five specs to fail on a refused connection that names nothing.

`all` runs cheapest and most isolated first, so a break several suites would show is reported by the one that localises it best.

The version switching lives in scripts/NodeVersion.ps1, dot-sourced by both, since two copies of it would drift and the half that drifts is the half nobody runs.

Closes #140
2026-08-23 09:36:40 -05:00
bermudalamb 792daccafb Merge pull request 'feat(admin): make the token lifetimes, cart hold and greeting configurable (#136)' (#141) from feature/136-configurable-token-lifetimes into main
Linting / lint (push) Successful in 1m58s
SonarQube Analysis / sonarqube (push) Successful in 17m23s
Reviewed-on: #141
2026-08-23 08:55:53 -05:00
bermudalamb 2f7268704a feat(admin): make the token lifetimes, cart hold and greeting configurable (#136)
Giving the customer emails a tab of their own (#135) made a family of related holes visible: an admin could edit the wording of every customer email, but not the facts that wording asserted, and two templates could not address the customer at all.

Both token lifetimes were hardcoded twice. VERIFY_TOKEN_TTL_MS sat in routes/customers.ts while the verification body separately said "This link expires in 24 hours", and RESET_TOKEN_TTL_MS sat beside a body separately saying "one hour". The prose was not derived from the constant, it was a second hand-written copy of the same fact — so making the constants configurable without addressing that would have made things worse, not better: the setting moves to two hours and the email keeps confidently promising one. Both are now settings, and both templates state their lifetime through an {{expiresIn}} placeholder rendered from the setting.

The cart reminder gains {{holdDuration}} for the same reason. Per-item deadlines were already inside {{itemList}}, but there was no way to write a sentence about the hold itself without hardcoding a number the cart expiry setting could change underneath the author.

All three durations render through one formatDuration(), 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, because "0.5 hours" reads badly and "1.5 hours" reads worse in a sentence a customer is meant to act on.

Every template now offers greeting, firstName and lastName. favoriteSold and favoriteWithdrawn previously offered only itemName and siteUrl and could not address anyone — the query behind them never selected a name, so it does now.

The greeting itself is two settings, a format and a fallback, rather than the wording baked into greeting(). The fallback is separate rather than the format with the name edited out: that editing is guesswork that has to be right every time, and getting it wrong ships "Hi ," to everyone who registered while first names were still optional (#106). Those customers exist, which is why greeting() guarded the case in the first place.

The admin preview renders the durations and the greeting from the live settings rather than from a static sample. The preview exists so an admin sees the email that will be sent, and a sample reading "one hour" while the setting says two is the precise failure these placeholders were added to remove.

Settings are read through a new adminSettings accessor. cart_expiry_hours was previously read by an inline query in two places, each with its own `|| '24'`; with five settings and read sites across three routes and the cron job, a default written twice is a default that will eventually disagree with itself. Values are stored as text, so each definition declares how to read it back — numbers were the only kind until the greeting format arrived.

The new placeholders are available but never required, so every template an admin has already saved keeps rendering and keeps sending.

Also fixes updateAdminSettings announcing success for a save the server refused: it returned the 400 body as though it were the saved settings, so the form reported "Settings saved" either way.

Closes #136
2026-08-23 08:55:53 -05:00
bermudalamb 6c837725bd Merge pull request 'feat(admin): give the customer emails a tab of their own (#135)' (#138) from feature/135-emails-tab into main
Linting / lint (push) Successful in 1m54s
SonarQube Analysis / sonarqube (push) Successful in 19m0s
Reviewed-on: #138
2026-08-23 08:21:39 -05:00
bermudalamb 7df897c0fd feat(admin): give the customer emails a tab of their own (#135)
Linting / lint (pull_request) Successful in 1m49s
SonarQube Analysis / sonarqube (pull_request) Failing after 17m57s
The six email templates lived at the bottom of the Settings tab, under the cart-expiry card and inside a 720px wrapper. Finding them took knowing they were there — "Settings" reads as app configuration and the only thing visible on that tab was a 480px card about cart expiry. Reaching them, the editor was then crushed: EmailTemplateEditor splits a markdown pane and a rendered preview side by side, and 720px left each half under 350px, so the preview showed the email at a width nothing like how it will be read and the markdown toolbar wrapped.

Emails is now its own tab, between Customers and Settings, with no width cap. Within it the email types are a left vertical rail rather than a strip across the top: six labels wrapped on narrower displays, and stacking them is what leaves the editor the width the split needs. Settings keeps the cart-expiry card and nothing else.

The Default/Customised tag comes off the labels. Six antd tags stacked down a rail stop it being scannable, so a customised template gets a dot and the state in full moves into the editor beside the Restore default button that acts on it. The dot carries aria-label="Customised" so the word stays in the tab's accessible name and the state is not conveyed by a mark alone.

Emails owns the fetch it inherited from Settings, and adds a Spin over it. templates starts empty, so the gap before the request lands would otherwise render an empty rail that reads as "there are no emails to edit".

Closes #135
2026-08-23 08:01:42 -05:00
bermudalamb 717a32c923 Merge pull request 'feat(admin): filter inventory by status directly, so Published and Unpublished are reachable (#132)' (#134) from feature/132-admin-status-multiselect into main
Linting / lint (push) Successful in 1m52s
SonarQube Analysis / sonarqube (push) Failing after 17m38s
Reviewed-on: #134
2026-08-23 07:22:13 -05:00
bermudalambandClaude Opus 5 4de1c9b34d feat(admin): filter inventory by status directly, so Published and Unpublished are reachable (#132)
There was no way to find unpublished items. Every item has arrived pending since #90 and has to be published, so "what is waiting for me to publish" is a routine question the inventory could not answer.

#105 replaced the four-way status dropdown with a Sold / Not sold / All preset and recorded at the time that this gave up isolating a single status, that the pending workflow was the likeliest thing to miss it, and that the fix would be to restore the ability rather than remove the preset. That turned out to be right, and sooner than expected.

The admin now selects statuses directly - Pending, Available, Reserved, Sold - rather than choosing among presets over them. The API has accepted several statuses since #105, so this exposes the dimension itself. Everything becomes expressible in one control: Unpublished is Pending, Published is the other three, Sold and Not sold are the sets they always were, and Reserved on its own is reachable again.

Two alternatives were rejected. Growing the preset list to five would have kept one click per answer while leaving Reserved unreachable and growing again at the next new question. A second control for publication beside the one for availability would have read more naturally and reintroduced exactly what #105 was built to avoid: Sold and Unpublished is an impossible pair, since a sold item is necessarily published, and two dimensions have to either give that a meaning or block it. One dimension cannot contradict itself.

The storefront keeps its three-way preset unchanged. Pending is excluded from every public read, so Published and Unpublished are not distinctions a customer can draw, and the simpler control is the right one there.

An empty selection means no filter rather than no statuses, or clearing the box would empty the table.

Verification: the admin filter spec is rewritten rather than deleted, and now asserts what the preset could not - Pending alone finds the staged fixture and hides the published ones, and Available plus Reserved plus Sold finds the published ones and hides the staged one. That second case is the one a preset would have had to be invented for. All 7 admin filter tests pass, along with 122 of the suite.

Two locator details cost time and are written into the spec so they do not have to be rediscovered: antd renders an invisible role="listbox" shim beside the real option list, so getByRole('option') resolves something zero-sized that cannot be clicked; and a selected status renders as a tag carrying the same title as its option, so an unscoped getByTitle becomes ambiguous once anything is chosen.

Beyond the two pre-existing password-reset failures that need a database on port 55432, two storefront specs failed under the full concurrent run and pass six-for-six in isolation, twice. That is the shared-database contention filed as #116, not a regression here: this change touches the admin only.

Closes #132
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 07:22:13 -05:00
bermudalamb 7798c847c1 Merge pull request 'feat(admin): use the item description's markdown editor for email templates (#131)' (#133) from feature/131-md-editor-for-emails into main
Linting / lint (push) Successful in 1m42s
SonarQube Analysis / sonarqube (push) Failing after 16m10s
Reviewed-on: #133
2026-08-23 07:21:29 -05:00
bermudalambandClaude Opus 5 2841d978b9 feat(admin): use the item description's markdown editor for email templates (#131)
Linting / lint (pull_request) Successful in 1m50s
SonarQube Analysis / sonarqube (pull_request) Failing after 16m7s
Two places in the admin edited markdown and neither looked like the other. The item description has had MDEditor's toolbar since it was added; the email bodies were a bare monospace textarea. The email bodies are the worse place for that, since a markdown mistake there goes to customers rather than onto a product page.

They now use the same editor, with the same data-color-mode wrapper so it follows the admin's dark mode exactly as the item form does.

One thing was deliberately not copied. The item form uses preview="live", which gives MDEditor its own preview pane. This one uses preview="edit". MDEditor's preview renders with a different markdown implementation, would show a literal {{resetUrl}} rather than a sample value, and would omit the consent footer the server appends to the two favorite templates. The pane on the right is the server's rendering of the actual email, produced by the same renderer the mailer uses; putting a second, less accurate preview beside it would leave the admin two answers and no way to tell which one the customer gets.

The aria-label moves to textareaProps. Input.TextArea carried it directly, MDEditor owns its textarea, and without it every email template test loses its handle on the field along with the only thing naming it for a screen reader.

Verification: the eight end-to-end tests from #119 pass unchanged, which is the check that matters here - not one of them was edited to accommodate the swap, so the label, the save path and the server preview all still work through the new editor. tsc, ESLint and the production build are clean.

Worth recording, because it wasted a diagnosis: the first run of those tests failed on the preview, and the cause was a stale backend build with no preview route rather than anything in this change. The iframe was empty from the start, before any typing, which is what gave it away - a broken editor would have shown the default copy and failed to update it.

Closes #131
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:07:40 -05:00
bermudalamb 7ca07bd4a0 Merge pull request 'feat: let a customer resend their own verification email (#110)' (#130) from feature/110-resend-verification into main
Linting / lint (push) Successful in 2m0s
SonarQube Analysis / sonarqube (push) Failing after 16m38s
Reviewed-on: #130
2026-08-22 12:59:09 -05:00
bermudalambandClaude Opus 5 b8549e9c72 feat: let a customer resend their own verification email (#110)
A verification email was sent once, at registration. If it was lost, filtered, or arrived after the 24-hour token had expired, the customer had no route back except registering again with a different address.

POST /api/customers/resend-verification mints a fresh token and sends the mail, and the account page grows a "Send it again" button beside the warning that was already there. The button only exists while the address is unverified: on a verified account it would be a control whose only outcome is a refusal, and the endpoint refuses that case rather than sending a pointless email.

The mint-token-and-send sequence now lives in one place. Registration and the email change already did the same three steps - supersede any outstanding token, mint a new one, send it - and this would have been a third copy. The step most likely to be dropped in a copy is the supersede, and it is the one that matters: without it an older message still sitting in the inbox goes on verifying.

Anything that makes the server send mail on request is an abuse vector, so this is rate limited to three an hour, keyed on the customer id. That is tighter than either existing limiter and sidesteps #84's IPv6 problem entirely, since a signed-in caller has an identity better than an address to count against and cannot escape the bucket by moving within a delegated prefix. The refusal says the mail probably did send and to check the spam folder, which is both more useful and more honest than a bare 429.

The claim that keying on customer id also solved test isolation was wrong, and the tests caught it. resetDb truncates with RESTART IDENTITY, so every integration test's first customer is id 1: three tests that each sent once left the fourth starting at its limit, and two tests failed on a 429 they never asked for. A "fresh customer per test" is a fresh row with a recycled identity. The limiter now has an explicit exported store the suite clears between tests, and the comment that claimed otherwise has been corrected rather than left to mislead the next reader.

Verification: seven integration tests covering the unauthenticated refusal, the send, the new token invalidating the old one - asserted through the endpoint that would honour the stale link rather than by counting rows - the new link actually verifying, the already-verified refusal, the allowance stopping the fourth send rather than merely reporting it, and one customer's exhausted allowance leaving another's intact. Three end-to-end tests for the button, its confirmation and the message on the fourth click. The 33 integration tests across the three suites this touched all pass, as do the 199 backend unit tests. tsc clean on both sides, ESLint no errors.

Closes #110
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 12:59:09 -05:00
bermudalamb 748c42628b Merge pull request 'feat: filter by Sold / Not sold / All on the storefront and the admin (#105)' (#129) from feature/105-sold-filter into main
Linting / lint (push) Successful in 1m51s
SonarQube Analysis / sonarqube (push) Successful in 15m41s
Reviewed-on: #129
2026-08-22 12:58:33 -05:00
bermudalambandClaude Opus 5 32b3f616d9 feat: filter by Sold / Not sold / All on the storefront and the admin (#105)
Three decisions were taken before any code, and are recorded on the issue.

The status filter is generalised to accept several values rather than gaining a second `sold` dimension beside it. "Not sold" is not a status: it is available-or-reserved on the storefront and includes pending in the admin, neither of which is one value. `?status=available,reserved` and `i.status = ANY($n::text[])` express that with one concept, so there is no way to write a contradiction like `?status=sold&sold=no`. A single status still parses to a list of one, which is how the admin's existing `?status=sold` keeps working untouched.

The storefront now defaults to Not sold. That is a change in what every customer sees, not just a new control: the black SOLD ribbons leave the default view on a catalogue where they were evidence the shop sells things, and every storefront link shared so far quietly changes meaning. Accepted deliberately, with the default named in STOREFRONT_DEFAULT_STATUSES rather than implied by the absence of a parameter.

The admin's four-way status dropdown is replaced rather than joined. That gives up isolating a single status: there is no longer a way to view only Reserved, or only Pending, and Not sold folds pending in with the rest. The pending workflow from #90 is the likeliest thing to miss it, and if it does, the fix is to put isolation back beside the preset rather than to remove the preset. The e2e test that covered "which is how Reserved is reached" is renamed and narrowed to what survives, rather than deleted.

One thing the issue did not anticipate, found by a test rather than by reading. The favorites view deliberately showed sold favorites - "a favorite that has just sold is often exactly what the customer came to look at", and they have just been emailed to say so. Defaulting the storefront to Not sold reversed that silently and broke the test asserting it. Favorites therefore keep their own default of everything, on the server and in the control's displayed position, while an explicit ?status= still wins. That interaction is the kind a single-feature change quietly breaks, and it was caught only because the previous decision had been written down as an assertion.

"All" still means different things in the two places, as the issue set out: available + reserved + sold on the storefront, all four in the admin. Pending remains unreachable from every public read - the storefront's unconditional exclusion clause is untouched - and the pending guard now checks every requested status rather than a single one, so `?status=available,pending` is refused for naming pending at all rather than accepted because the first name happened to be allowed.

The control sits in the filter bar rather than in the drawer, since the default now hides sold pieces and a customer who never opens the drawer would otherwise have no way to know they exist. It is consequently excluded from the "Filters (N)" count, which describes the drawer, while still counting toward hasActiveFilters so that an empty result reads as "no items match these filters" with a way out rather than as an empty shop.

Verification: 13 integration tests covering the default, each preset, the favorites exception and its override, and pending's unreachability under every accepted combination; 38 parser unit tests including multi-value parsing, an unknown name in a list being refused rather than dropped, and a list that names nothing; 6 new end-to-end tests for the storefront control, its URL round-trip, and the default staying out of the URL. 204 backend unit tests and 76 integration tests across the four affected suites pass. Two full end-to-end runs: 110 and 111 passing against the same 3 pre-existing failures, one run also showing a pending-publish failure that passes in isolation and did not recur - the cross-suite database contention filed as #116. tsc, ESLint and the production build are clean.

Closes #105
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 12:58:33 -05:00
bermudalamb e5ff980eae Merge pull request 'feat: tabs and a rendered preview for the email templates (#119)' (#128) from feature/119-email-template-tabs into main
Linting / lint (push) Successful in 1m53s
SonarQube Analysis / sonarqube (push) Failing after 16m5s
Reviewed-on: #128
2026-08-22 12:57:30 -05:00
bermudalambandClaude Opus 5 1fa723bd19 feat: tabs and a rendered preview for the email templates (#119)
Linting / lint (pull_request) Successful in 2m10s
SonarQube Analysis / sonarqube (pull_request) Failing after 15m19s
Follow-up to #92, which shipped the editable templates as a column of stacked cards.

With six templates the cart reminder sat below five editors, so reaching it meant scrolling past all of them and which one you were editing was knowable only from a card title you had already scrolled past. They are tabs now, and the Default/Customised tag moves onto the tab label, so which templates have been changed is visible without opening each one.

The larger gap was that there was no way to see what the email would look like. The editor is a markdown textarea; what gets sent is rendered HTML with placeholders substituted and, for the two favorite templates, a consent footer appended by the server. An admin editing copy could not tell whether the result read correctly.

POST /api/admin/email-templates/:key/preview renders the draft in the editor rather than what is stored, so the effect of an edit is visible before committing to it. It renders on the server deliberately: renderTemplate is the only thing in the system that turns this markdown into HTML, and markdown-it is configured there with html: false, which is the control that stops an admin putting script into a customer's inbox. A renderer in the browser would be a second implementation of both, and a preview that disagreed with the mailer would be worse than none. It does not enforce required placeholders - saving refuses a body that dropped one, and previewing it is how the admin sees what they have done.

The preview renders into a sandboxed iframe rather than through dangerouslySetInnerHTML. The markup is safe by construction, but an email is its own styling context: rendered inline, the admin theme's CSS would change how it looks and the preview would lie about the result.

Sample values live beside the template definitions rather than in the route, so adding a placeholder puts the missing sample next to the change that needs it. A unit test asserts every available placeholder has one, because a missing sample renders a literal {{placeholder}} into the preview and teaches the admin their copy is broken when it is not.

This also fixes a test that has been failing on main. email-templates.spec.ts located the Save button by filtering .ant-card for the template name, which matched an outer card containing every template's Save button - six of them - and died on a strict mode violation, taking two more tests with it as unrun. Only the active tab's editor is mounted now, so the labels are unambiguous and the filter is gone.

Verification: eight end-to-end tests, four for editing and four for the preview, covering the draft being previewed rather than the stored copy, sample values replacing placeholders, raw HTML being escaped exactly as the mailer escapes it, and the consent footer appearing on a favorite template and not on a password reset. The full suite goes from 100 passed / 3 failed / 2 unrun to 112 passed / 2 failed / 0 unrun; the two that remain are the pre-existing password-reset failures that need a database on port 55432 and fail identically on main. 38 backend unit tests pass, tsc and ESLint are clean.

Closes #119
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 11:38:07 -05:00
bermudalamb f2ab4e6565 Merge pull request 'ci: fold the Tests workflow into SonarQube Analysis and delete it (#123)' (#127) from feature/123-consolidate-test-workflows into main
Linting / lint (push) Successful in 1m53s
SonarQube Analysis / sonarqube (push) Failing after 15m31s
Reviewed-on: #127
2026-08-22 11:13:05 -05:00
bermudalambandClaude Opus 5 f7d85736dd ci: fold the Tests workflow into SonarQube Analysis and delete it (#123)
tests.yml and sonarqube.yml had identical triggers and ran the same tests. Where tests.yml ran test:unit:json and playwright test, sonarqube.yml ran test:unit:cov and test:e2e:cov: the same two suites, differing only in reporter and instrumentation. Every pull request therefore installed dependencies twice, migrated twice, built and started the backend twice, installed a Playwright browser twice and ran the whole end-to-end suite twice. With one runner the second copy did not run in parallel, it queued.

Two things in tests.yml were not duplicates and are folded in rather than dropped. The summarize steps, which render pass and fail counts and name the failing tests, where sonarqube.yml offered only raw jest and Playwright output. And the continue-on-error plus fail-at-the-end pattern that lets those summaries render on a failing run at all: sonarqube.yml used to abort at the first failing step, so a broken unit test meant no end-to-end results, no coverage and no scan, which is one fact per run when a run costs many minutes.

The trade-off is fast feedback. A broken unit test used to fail tests.yml in a couple of minutes; now it is reported when the whole pipeline finishes. That is worth it with one runner, where the fast job was queued behind the slow one anyway, and where a single run reporting unit results, end-to-end results, coverage and the scan together beats two runs each reporting a fragment. Worth revisiting if the runner count changes.

Two details that would each have silently broken something:

The coverage scripts do not emit the JSON the summarizers read - test:unit:cov has no --json, and test:e2e:cov does not set the JSON reporter. Both are appended at the step rather than baked into package.json, since coverage and a results file are only wanted together in this one place. The Playwright step uses --reporter=list,json so the log still names the failing test rather than only writing a file.

The "Backend log" step was keyed on failure(). With continue-on-error the job is not in a failed state when it runs, so failure() would never fire and the log explaining an end-to-end failure would have gone unprinted exactly when it was wanted. It is now keyed on the step outcome.

The header comment also records something the old comments obscured: the integration suite runs here on every push and pull request, despite backend-integration.yml describing it as manual. That quarantine only ever applied to tests.yml.

Verification, run rather than assumed: both suites executed locally with the exact commands the workflow now uses. The unit run wrote unit-results.json alongside its coverage and summarize-jest.js rendered 199 passed from it. The end-to-end run wrote playwright-results.json and summarize-playwright.js rendered 100 passed, 3 failed, 2 skipped and named all three failures - a failing run, which is the case if: always() exists for. The three failures are pre-existing and fail identically on main. Parsing the remaining workflows confirms sonarqube.yml declares one job carrying both new step ids, lint.yml and backend-integration.yml are untouched, and tests.yml is gone.

The two results files are added to .gitignore. CI never committed them, but the commands are now documented and runnable locally, which makes an accidental commit a matter of time.

Closes #123
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 11:13:05 -05:00
bermudalamb d82d0a8da3 Merge pull request 'feat(scripts): a PowerShell script to start the local environment (#125)' (#126) from feature/125-local-env-script into main
Linting / lint (push) Successful in 2m9s
SonarQube Analysis / sonarqube (push) Failing after 13m41s
Reviewed-on: #126
2026-08-22 11:12:16 -05:00
bermudalamb 40b483fc30 feat(scripts): a PowerShell script to start the local environment (#125)
Linting / lint (pull_request) Successful in 1m45s
SonarQube Analysis / sonarqube (pull_request) Failing after 13m24s
Bringing the app up locally was seven or eight commands in a particular order: a Postgres container on a port that is not blocked, the six environment variables the backend refuses to boot without, migrations, a TypeScript build, the backend, then Vite. None of it hard, all of it tedious, and documented nowhere outside CI workflows written for a Linux runner.

scripts/start-local.ps1 does the sequence. It reuses an existing container rather than recreating one, skips npm install when node_modules is already there, leaves alone anything already listening on a port it wanted, and waits on pg_isready and a 200 from /api/config rather than sleeping a fixed number of seconds. -Fresh recreates the database, -Stop tears everything down by the process ids it recorded rather than by port, since killing by port would also kill whatever else happened to be listening.

Two things this got wrong first time round, both found by running it rather than by reading it.

$ErrorActionPreference = 'Stop' does not stop a PowerShell script when a native executable exits non-zero, only when a cmdlet throws. Every command here is node, npm or docker, so the first run printed a stack trace from a failed migration, carried straight on, and reported a healthy stack sitting on a database with no tables in it. That is the worst kind of wrong: a green summary over a broken environment. Native calls now go through Invoke-Checked, which tests $LASTEXITCODE and throws.

The migration failed because node on the PATH was v18.16.1. node-pg-migrate pulls in an lru-cache that calls diagnostics_channel.tracingChannel, which does not exist before Node 20, and the failure surfaces as "(0 , U.tracingChannel) is not a function" from a minified file - which says nothing whatsoever about Node versions. The script now checks the major version first and says what to do about it, so the confusing crash becomes one clear line before anything else runs.

The port default is 55500 rather than anything near 55432, which is reserved by Hyper-V on this machine. Docker's message when it cannot bind a reserved port does not mention reservations, so the failure path names the likely cause and prints the netsh command that lists the reserved ranges.

Verification, all observed rather than assumed: the version guard was made to fire on Node 18 and produced the intended message. A -Fresh run on Node 24 applied all six migrations, and psql then showed thirteen tables where the broken run had none. /api/config and the storefront both answer 200. -Stop stopped both tracked processes and the container. A second run with the dev server already up detected it and left it alone rather than failing.

.local/ holds the logs, pids and uploads, and is gitignored.

Closes #125
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 11:07:06 -05:00
bermudalamb 46f88b4bbb Merge pull request 'feat(frontend): give order history a page of its own (#121)' (#124) from feature/121-orders-page-impl into main
Linting / lint (push) Successful in 1m46s
SonarQube Analysis / sonarqube (push) Failing after 13m32s
Reviewed-on: #124
2026-08-22 10:58:02 -05:00
bermudalamb 9bb3cc86b6 feat(frontend): give order history a page of its own (#121)
Linting / lint (pull_request) Successful in 1m37s
SonarQube Analysis / sonarqube (pull_request) Failing after 13m19s
The account modal had accumulated: a profile line, a name form, two collapsed panels for changing email and password, two consent switches, an order table and four controls. The table was the piece that fitted worst, being the only tabular data in a 700px dialog whose body is capped at 70vh. The scroll={{ x: 'max-content' }} already on it was a workaround for being in the wrong container rather than a layout choice.

It moves to /orders, an ordinary page in the same Routes block as /cart and /privacy, rather than another entry in MODAL_ROUTES. Order history is a list you read, like the cart, not a dialog you dismiss. A modal at /account/orders would have been the smaller change and was rejected: it inherits the same width and the same scroll cap, so it moves the table without giving it anything.

The page shell follows Cart.tsx, which is the established shape here: a Layout with a Header carrying Back to Shop and the title, and the same guard sending a signed-out visitor to /login. The account modal keeps a View order history button where the table used to be, because that is where a customer looks for it.

One thing changes rather than moves. The old effect caught a failed load with a toast and left orders as an empty array. The toast faded and the empty table did not, so from then on a customer whose request failed saw exactly what a customer with no orders saw, and the page asserted something false. Loading, failed and empty are now three distinct states, and the failed one carries a Retry: a transient failure would otherwise strand someone on a page that needs a full reload to recover.

OrdersBody sits at module level rather than nested inside Orders(). A function declared inside a component counts toward that component's cognitive complexity, which is what made Customers() hard to bring back under the threshold in #81.

The two assertions in account-modal.spec.ts that looked for the text "Order History" inside the modal are updated to look for the link, not deleted. They were the only coverage that the account view still offers any route to the orders, which is exactly what this change could have silently broken.

Verification, against a real backend and database: five new tests covering the signed-out redirect, the empty state, Back to Shop, the link from My Account, and that the page renders as a page rather than a modal over the storefront - that last one is what would catch /orders being added to MODAL_ROUTES and quietly undoing the change. The full suite goes from 100 to 105 passing with no new failures; the three that fail did so before this branch and fail identically on main. tsc and the production build are clean, ESLint reports no errors.

Closes #121
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 10:53:40 -05:00
bermudalamb 6aa633d109 Merge pull request 'docs: design for moving Order History onto its own page (#121)' (#122) from feature/121-orders-page into main
Linting / lint (push) Successful in 1m43s
SonarQube Analysis / sonarqube (push) Failing after 12m56s
Tests / backend-unit (push) Successful in 42s
Tests / frontend-e2e (push) Failing after 8m47s
Reviewed-on: #122
2026-08-22 10:42:16 -05:00
bermudalambandClaude Opus 5 d65eb7b981 docs: design for moving Order History onto its own page (#121)
Linting / lint (pull_request) Successful in 1m43s
SonarQube Analysis / sonarqube (pull_request) Failing after 12m40s
Tests / backend-unit (pull_request) Successful in 38s
Tests / frontend-e2e (pull_request) Failing after 8m36s
Records why /orders is a page rather than another modal route. The app has both precedents: /account, /login and /register are in MODAL_ROUTES and render over a backdrop, while /cart and /privacy are ordinary pages. Order history is closer to the cart, a list you read rather than a dialog you dismiss.

A modal at /account/orders was the smaller change and was rejected: it inherits the same 700px width and 70vh scroll cap, so it moves the table without giving it anything. Tabs inside the modal were rejected for the same reason, since they fix the scrolling and leave the cramping.

The doc also records the one part of this that is a fix rather than a move. A failed load currently shows a toast and leaves the table empty, and the toast goes away while the empty table does not, so a customer whose request failed sees exactly what a customer with no orders sees. Loading, failed and empty become three distinct states, with a retry on the failed one.

Per-order detail and server-side paging are written down as deliberately out of scope, so that leaving them out reads as a decision rather than an oversight.

Refs #121
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 10:39:01 -05:00
bermudalamb 704aca0b84 Merge pull request 'feat(frontend): let a customer change their own name, password and email (#111)' (#120) from feature/111-account-ui into main
Linting / lint (push) Successful in 1m38s
SonarQube Analysis / sonarqube (push) Failing after 13m18s
Tests / backend-unit (push) Successful in 46s
Tests / frontend-e2e (push) Failing after 8m52s
Reviewed-on: #120
2026-08-22 10:23:50 -05:00
bermudalamb 84db0e7ca2 feat(frontend): let a customer change their own name, password and email (#111)
Linting / lint (pull_request) Successful in 1m57s
SonarQube Analysis / sonarqube (pull_request) Failing after 13m14s
Tests / backend-unit (pull_request) Successful in 43s
Tests / frontend-e2e (pull_request) Failing after 9m1s
The three endpoints have been on main since PR #113 with nothing calling them. This adds the UI, which is what the issue is actually about: its title is that PUT /api/customers/me has no caller.

The name form sits open on the account view. Changing an email address or a password does not, because both are rare and deliberate, and leaving them expanded would push order history and the account controls below the fold for everyone who never uses them. They go in a collapse instead.

Both of those carry a consequence the form cannot show. A new address has to be verified before it can be used to sign in or reset a password, and the old address is told that the change happened. A password change ends every other session. Each is stated above its fields rather than reported afterwards, so the surprise arrives while there is still a chance to back out.

The email form asks for the current password. A live session is not enough to move the address a password reset would be sent to, which is the whole reason the server asks for it too.

Server refusals are shown as they arrive rather than replaced with something generic: the message names which of the two passwords was wrong, or which name was left blank, and that is the only useful thing to say.

The forms live in their own component rather than in Account.tsx. Three forms inline would have roughly doubled that component, and nested JSX bodies count toward the parent's cognitive complexity - the same thing that made Customers() hard to bring back under the threshold in #81.

Verification, all against a real backend and database rather than mocks: six new end-to-end tests covering the name surviving a reload, a blank name being refused, the old password ceasing to work while the new one starts working, a wrong current password being refused for both the password and the email change, and an email change marking the account unverified again. The password test asserts the old credential no longer opens the account rather than that the form said something reassuring. The 21 existing account and auth tests still pass, and tsc and ESLint are clean.

Closes #111
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 10:16:06 -05:00
bermudalamb e234240440 Merge pull request 'ci: give linting its own workflow (#113)' (#114) from feature/113-split-lint-workflow into main
Linting / lint (push) Successful in 1m39s
SonarQube Analysis / sonarqube (push) Failing after 12m29s
Reviewed-on: #114
2026-08-22 09:29:15 -05:00
bermudalambandClaude Opus 5 005146742e ci: give linting its own workflow (#113)
The lint job moves out of tests.yml into lint.yml, unchanged in what it runs. Lint is the fastest check in the pipeline and the one most often broken, and reporting it as one job among the suites made a lint failure and a test failure look alike at a glance.

The split left two comments in the wrong place, both artifacts of the copy rather than of the intent.

tests.yml kept the six-line note explaining the lint policy — that only defect-catching rules fail the build, that everything else warns, and why no --max-warnings flag appears. With the job gone it sat directly above backend-unit, where a reader would fairly take it as describing the unit tests. It has moved to lint.yml, with the job it actually describes.

lint.yml inherited tests.yml's header about backend-integration living in its own manual workflow after it held the runner for three hours. That is worth saying where someone might expect integration tests to run; in a workflow that only runs ESLint it explains the absence of something nobody was looking for. Replaced with why this workflow exists at all.

Verified by parsing both files rather than by reading them: lint.yml declares one job, lint; tests.yml declares backend-unit and frontend-e2e. No job was lost in the move and none is now declared twice, which is the failure a copy-and-delete edit invites.

Refs #113
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 09:29:15 -05:00
bermudalamb edf61abda4 Merge pull request 'feat(backend): let a customer change their own name, password and email (#111)' (#113) from feature/111-manage-account-details into main
SonarQube Analysis / sonarqube (push) Failing after 12m11s
Reviewed-on: #113
2026-08-22 09:27:33 -05:00
bermudalambandClaude Opus 5 7c3463650d feat(backend): let a customer change their own name, password and email (#111)
SonarQube Analysis / sonarqube (pull_request) Failing after 12m11s
Two of the three already existed on the backend and had no caller. PUT /api/customers/me updated the name; POST /api/customers/change-password already demanded the current password and enforced the eight-character minimum. Neither was reachable from the frontend, which is why the gap was easy to miss — the API looked finished.

The name endpoint accepted empty values and wrote nulls, letting a customer clear fields registration refuses to let them skip. That is the same rule disagreeing with itself, so it now refuses each by name exactly as registration does.

Changing a password now ends other sessions and keeps the one making the change. Reset already deleted every session for the customer, on the reasoning that a password is changed precisely when the old one may be known to someone else — change reached the opposite conclusion for no recorded reason, and a session opened with a leaked password outlived the change meant to lock it out. The current session is spared so the change does not eject the person making it.

Changing the email address is new. It asks for the current password, because swapping the address a password reset goes to is how an account is taken over and a live session alone is not enough; that also matches what change-password already required. The address is normalised and validated, an address another account holds is refused with the same 409 as registration, and on success the row is marked unverified and any outstanding verification token superseded — one already sitting in the old inbox must not be able to verify the new address.

Two emails then go out, to different places. Verification to the new address, and a notice to the old one naming what the address was changed to. The notice is the only thing that tells a real owner their account was taken, and one that does not say where the address went is nearly useless to someone checking whether it was them. Both sends happen after the row is written, never before, so a change that failed cannot produce mail saying it succeeded.

That notice is a sixth template in #92's system, which cost a definition and a default body. The unit tests iterate every template, so its defaults were checked against its own required placeholder without writing a new test.

Verified: 199 unit and 208 integration passing. The session test signs in on a second agent, changes the password on the first, and asserts the second is refused while the first still works — the property being claimed rather than the code path being executed. One of my own assertions was wrong on the way: /me answers an unauthenticated caller with 401 and an error body, not an empty one, and the frontend is what turns that into null.

Refs #111
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 09:00:46 -05:00
bermudalamb 51c4f3f813 Merge pull request 'Feature/92 editable email templates' (#112) from feature/92-editable-email-templates into main
SonarQube Analysis / sonarqube (push) Failing after 13m47s
Tests / lint (push) Successful in 1m43s
Tests / backend-unit (push) Successful in 39s
Tests / frontend-e2e (push) Failing after 8m4s
Reviewed-on: #112
2026-08-22 08:35:23 -05:00
bermudalambandClaude Opus 5 6baa769520 feat(frontend): edit the customer emails from Admin → Settings (#92)
SonarQube Analysis / sonarqube (pull_request) Failing after 13m7s
A card per email under the existing settings screen: subject, body, the placeholders it understands, and which of them it cannot lose.

Each card starts from the copy that is actually in use — the stored version if there is one, the built-in default otherwise — rather than an empty box, so editing means changing words rather than writing the email from scratch. A badge distinguishes customised from default, which is why the API reports an unedited template as null rather than as its default text: the two are different states and the screen has to be able to tell them apart.

Restore default is offered only when there is something to restore, so it is never a button that looks like it did something and did not. It removes the stored rows rather than writing the defaults into them, which is what keeps the badge honest afterwards.

The server's refusal is shown verbatim. When a body drops a placeholder it needs, the message names which one, and that message is the entire value of the validation — replacing it with a generic failure would leave an admin guessing at which of five templates and which of three placeholders they broke.

A textarea rather than the markdown editor already used for item descriptions. That editor is a heavy dependency to load into the settings screen for five short bodies, and its preview would render markdown as the browser shows it rather than as the email renderer will — a preview that quietly disagrees with the output is worse than none. Worth revisiting if the copy gets longer.

Two things the end-to-end spec found rather than assumed. The refusal assertion first matched three elements, because the placeholder appears as the required marker, as an available tag, and inside the error — it now asserts the whole sentence. And the four tests raced each other: the suite runs fully parallel and they all edit one shared stored template, so one asserted a template was unset while another had just saved it. That describe block now runs serially, which is the honest fix for tests that mutate shared server state rather than making the assertions vaguer.

Verified: 99 end-to-end tests passing on a freshly created container, up from 95, with the whole suite run rather than the new spec alone — precisely because these tests write state other suites read. Build clean, lint unchanged at 27 warnings.

Refs #92
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:05:29 -05:00
bermudalambandClaude Opus 5 4ed9513ad2 feat(backend): make the five customer emails editable copy (#92)
Every customer email was a template literal in the route that sent it, so changing a word meant a code change, a review and a deploy. All five now render from markdown that an admin can edit: verification, password reset, favorite sold, favorite withdrawn, and the cart reminder. Five, not the four the issue counted — the favorite alerts have separate copy for sold and withdrawn.

markdown-it runs with html disabled, which is its default and the reason for choosing it over marked. Raw HTML in a stored body is escaped rather than passed through, so editing copy cannot put script into a customer's inbox. That is a stronger guarantee than sanitising output, because there is no output to sanitise.

Values are substituted into the markdown before it renders, which means a value that should become a list has to arrive as markdown. The cart reminder previously built li elements by hand; those would now be escaped and shown to the customer as literal angle brackets, so it emits a markdown list instead. The greeting is one placeholder rather than a bare name, so a template author writes {{greeting}} instead of "Hi {{firstName}}," — which reads as "Hi ," for anyone who registered before first names were required.

Saving is refused when a body has dropped a placeholder it needs, naming all of them rather than the first. This is the rule that separates a convenience from a way to break password resets from a settings screen: a reset email with no link still sends, still looks correct in the log, and is useless to everyone who receives it.

The favorite alerts' consent sentence is appended by the server and is not editable. It explains why the customer is receiving the mail, which is a compliance artifact rather than copy, and editing wording should not be able to delete it.

Unset templates fall back to the built-in defaults, so an install that never touches the settings screen behaves exactly as it did. The API reports an uncustomised template as null rather than as its default text, so "never edited" stays distinguishable from "edited to something identical", and DELETE restores the default by forgetting the row rather than writing the default into it.

Two problems surfaced during verification, both worth recording.

Five favorite-alert tests failed with no error and no mail. The cause was not this code: resetDb does not truncate admin_settings, so a subject of "Gone" stored by the new template tests survived into a later suite and changed the mail it was asserting on. Cleaning up inside the template tests would have fixed only that pairing, so resetDb now clears stored templates for every suite — template rows are test data like any other, and one outliving the suite that wrote it makes a failure appear somewhere unrelated.

The withdrawal notification then failed on timing. Loading copy from the database made the sender async, and the removal path was fire-and-forget, so the response could beat the mail out of the door. Dispatch was previously synchronous even though the sends themselves were not awaited; that is now restored by awaiting it.

Verified: 197 unit and 195 integration passing, lint unchanged at 4 warnings. The admin screen for editing these follows in the next commit.

Refs #92
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:57:52 -05:00
bermudalamb 3b3888fd3c Merge pull request 'Feature/106 customer first last name' (#109) from feature/106-customer-first-last-name into main
SonarQube Analysis / sonarqube (push) Failing after 12m3s
Tests / lint (push) Successful in 1m45s
Tests / backend-unit (push) Successful in 46s
Tests / frontend-e2e (push) Failing after 11m15s
Reviewed-on: #109
2026-08-21 18:41:55 -05:00
bermudalambandClaude Opus 5 b287c07747 feat: capture first and last name so emails can greet informally (#106)
Tests / lint (pull_request) Successful in 1m38s
Tests / backend-unit (pull_request) Successful in 1m44s
Tests / frontend-e2e (pull_request) Failing after 9m50s
SonarQube Analysis / sonarqube (pull_request) Failing after 11m46s
Registration collected one optional Name, so every greeting had only a whole name to use: "Hi Thom Lamb," rather than "Hi Thom,". Both parts are now captured, and the cart reminder greets by first name.

Both are required of anyone new, refused individually rather than as a single "name is required" so a form that filled one and not the other is told which.

The columns are nullable even so, and that is deliberate. Marking them NOT NULL would mean backfilling legacy rows with empty strings, which asserts that every customer has a name — untrue of anyone who registered while the field was optional. The table records what is actually the case; the rule that new registrations must supply both lives in the route, where a missing field can produce a message naming it.

The backfill splits on the first space, and it is lossy in a way no version of this avoids. "Thom Lamb" becomes Thom and Lamb; "Mary Jane Smith" gets a last name of "Jane Smith"; names that are not two parts fare worse. It was chosen over leaving the columns empty because nothing currently lets a customer correct their own name — PUT /api/customers/me exists but no frontend calls it — so empty would have meant permanently unpersonalised for every existing customer. The migration says so, so nobody later reads backfilled values as data the customer supplied in that shape.

Verified against a seeded database rather than reasoned about, because this is the part that cannot be covered by the suite: migrations run in globalSetup before any test, and the old column is gone afterwards. Six representative rows through the real migration gave Thom/Lamb, Mary/"Jane Smith", Cher/null, "  Padded  Name  " trimmed to Padded/Name, and null and whitespace-only names left as null on both. The down migration rejoins the parts and returns all six to their original strings.

The old column is dropped rather than kept alongside, so there is one source of truth instead of two that drift.

The admin keeps receiving a single composed display name. It only ever shows one — the list cell and the drawer title — and never edits one, so giving it both parts plus the joining logic would be work for no reader.

Churn was the bulk of this: 14 backend registrations and 10 end-to-end registration forms. A first attempt at the backend fixtures also added names to login and password-reset payloads, which would still have passed since the server ignores unknown fields, but a login test implying login takes a name is a small lie; that was reverted and redone against register calls only.

Verified: 172 unit, 183 integration and 95 end-to-end passing, lint unchanged at 4 backend and 27 frontend warnings.

Not covered: the cart reminder itself, which runs from a cron and had no test before this either. The greeting change is a one-line substitution in that query.

Refs #106
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:23:13 -05:00
bermudalamb f76db6c8fe fix(test): make the compose guard survive a CRLF checkout (#107)
The guard added for #107 finds nothing on a checkout with CRLF line endings, which is every fresh clone on Windows. Splitting on a bare newline leaves a trailing carriage return, the end-of-line anchor in the entry pattern then cannot match, and all ten assertions in the file fail together.

Worth being precise about why this shipped, because the process that was supposed to prevent it ran and did not. That guard was fired deliberately before committing: the UPLOADS_DIR line was removed, two tests failed, the line was restored, ten passed. What the exercise never varied was the file's line endings — and by then the working copy happened to be LF, because the backup-and-restore used to fire the guard had rewritten it that way. So the deliberate firing proved the guard catches a missing variable, on a file shaped exactly as the test run had shaped it, and proved nothing about the shape it meets in a clean clone.

The failure mode is the one the file already worried about: parsing that matches nothing makes every other assertion vacuously true. Here it failed loudly instead only because the "parsed some entries at all" case exists — which is the case that turned a silent pass into a visible failure, and is the reason this was noticed at all rather than sitting green and checking nothing.

Splitting on an optional carriage return fixes it. 172 unit tests pass on the CRLF checkout that was failing.

Found while verifying #106, whose branch could not go green until this was fixed, which is why the fix lands there rather than on its own.

Refs #107
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:21:11 -05:00
bermudalamb 0e9f2a3d82 Merge pull request 'Feature/107 compose required env' (#108) from feature/107-compose-required-env into main
SonarQube Analysis / sonarqube (push) Canceled after 0s
Tests / lint (push) Canceled after 0s
Tests / backend-unit (push) Canceled after 0s
Tests / frontend-e2e (push) Canceled after 0s
Reviewed-on: #108
2026-08-21 17:39:24 -05:00
bermudalamb a2500a9901 test(backend): fail the build when the compose file lacks a required variable (#107)
SonarQube Analysis / sonarqube (pull_request) Canceled after 0s
Tests / lint (pull_request) Canceled after 0s
Tests / backend-unit (pull_request) Canceled after 0s
Tests / frontend-e2e (pull_request) Canceled after 0s
The one-line compose fix in the previous commit unblocks QA. This is the part that stops it happening again, and it is the more useful half.

The failure was not really a missing variable. It was that nothing connected two files: envValidation.ts gained a required variable, docker-compose.qa.yml did not set it, and nothing noticed until a container refused to boot on deploy. CI passed the whole time, because CI supplies its own environment and never reads the compose file — which is exactly why "CI is green" was the wrong evidence to have offered.

So a unit test now reads the compose file and asserts it sets everything the validator demands. It imports ALWAYS_REQUIRED rather than restating it, which is the only version of this test worth having: a copied list would pass forever while the next variable added to the validator went unguarded in precisely the same way.

Two further assertions earn their place. UPLOADS_DIR is hardcoded rather than taken from a stack variable on the grounds that it must agree with the volume mapping, so the test checks it against the mount rather than leaving that a claim in a comment. And ADMIN_GATE_SECRET must be present as an interpolation rather than a literal, since a secret in the repository would defeat the point of having one.

There is also a test guarding the test: a regex that matched nothing would make every other assertion in the file vacuously true, so one case asserts that parsing found entries at all.

Fired deliberately rather than assumed. Removing the UPLOADS_DIR line reproduces the original failure as two failing tests; restoring it returns to ten passing. A guard that has only ever been observed passing is not known to guard anything.

What this cannot do is check production, which runs from a Portainer stack outside this repository. That gap is now written into the README beside the validation rules, along with the reason a variable set only in Portainer's stack UI never reaches the container: stack variables are interpolated into the compose file, not handed to the service.

172 unit tests pass, lint unchanged at 4 warnings.

Refs #107
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:35:17 -05:00
bermudalamb 0710cccac1 fix(qa): give the container the environment variables it now requires (#107)
QA refuses to start: "UPLOADS_DIR is required and is not set."

#64 made UPLOADS_DIR always required, on the reasoning that its fallback of /app/uploads is correct inside the container and wrong everywhere else, so nothing should inherit it silently. That reasoning stands — but docker-compose.qa.yml had never set it either. QA was relying on exactly the fallback that change set out to stop people relying on, so the first deploy after it merged is the first one to fail.

That is an incomplete check, and one that looked confident. #64 verified both CI workflows set every always-required variable and said so. CI is not what deploys. The compose file, which is, was never opened.

ADMIN_GATE_SECRET was missing for a different reason, and the container reported it unset even after it was added to the Portainer stack. That is not a mistake, it is how compose works: stack variables are substituted into this file as ${VAR}, not handed to the container. A service receives exactly what its environment block lists. #87 already wrote that down; this is the first time it has bitten.

The two get different treatment for a reason. UPLOADS_DIR is hardcoded because it is not a secret and because it has to match the right-hand side of the volume mapping — splitting one value across two places in the same file is how they drift apart. ADMIN_GATE_SECRET is interpolated from the stack so the secret itself never enters the repository, and the header comment now lists it among the required stack variables.

Verified by feeding the environment docker compose config actually renders into validateEnv, the same function that was rejecting it: zero errors and zero warnings, the absence of warnings confirming the admin gate is now configured rather than merely quiet.

Production runs from a stack outside this repository with the same history and will refuse to boot on its next rebuild unless UPLOADS_DIR is set there first. This commit does not fix that.

Closes #107
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:24:55 -05:00
bermudalamb afb3182ea7 Merge pull request 'feat(backend): accept only real images in the inventory upload (#95)' (#104) from feature/95-upload-type-validation into main
SonarQube Analysis / sonarqube (push) Canceled after 0s
Tests / lint (push) Canceled after 0s
Tests / backend-unit (push) Canceled after 0s
Tests / frontend-e2e (push) Canceled after 0s
Reviewed-on: #104
2026-08-21 16:45:56 -05:00
bermudalambandClaude Opus 5 35b242a66f feat(backend): accept only real images in the inventory upload (#95)
SonarQube Analysis / sonarqube (pull_request) Canceled after 0s
Tests / lint (pull_request) Canceled after 0s
Tests / backend-unit (pull_request) Canceled after 0s
Tests / frontend-e2e (pull_request) Canceled after 0s
The upload bounded size and count and nothing else: POST /api/admin/items would take a PDF, a zip or an executable and store it as an item image, under an extension copied from whatever the caller named their file. Those files are served by express.static from the application's own origin, so a stored .html came back as text/html and a .svg as image/svg+xml — both able to run script as the site.

Three types are accepted: JPEG, PNG and WebP. SVG is excluded deliberately even though it is an image, because it executes script when navigated to directly, which is 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.

Validation happens twice, because once is not enough. The declared content type is checked in multer's fileFilter, before a byte is written — that catches picking a PDF by accident, which is most of what goes wrong. But file.mimetype is whatever the caller wrote in the multipart headers, so the bytes are checked too: each stored file's leading bytes must match the format it claimed. That is what stops evil.html renamed to photo.jpg and declared image/jpeg, which an allowlist on the declared type alone waves straight through.

The byte check cannot live in fileFilter — that runs before multer has read the stream, so there is nothing to look at yet. It runs after the write instead, and a failure removes every file from the request rather than only the offending one: accepting the good half of a refused upload would leave files on the volume that nothing references. Handles are closed before anything is unlinked, because an open handle makes the unlink fail on Windows.

The stored name now takes its extension from the validated type rather than from path.extname(file.originalname), so the name on disk cannot disagree with what the file is. The random UUID is unchanged — that was already right, and its comment explains why.

The picker offers exactly those three types rather than image/*, so a choice the API will refuse is not on the menu in the first place. That is a convenience, not a control: the operating system's All files option remains, drag-and-drop ignores accept, and anything calling the API directly never sees it. The server is the control.

Nine integration tests, and they are the first in this project to upload real file content — which is why none of this was noticed. They cover a genuine PNG accepted, a PDF refused, SVG refused, HTML wearing image/jpeg refused, nothing left on the volume after a refusal, a mixed request discarding its valid file too, and no item created when the upload fails. Plus 21 unit tests on the pure signature checks, including a RIFF container that is not WebP.

Verified: 162 unit, 178 integration, 94 end-to-end on a fresh container. Backend lint holds at 4 warnings — it caught the now-unused path import, which is exactly what it is for.

Refs #95
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:39:43 -05:00
bermudalamb eda62cf354 Merge pull request 'refactor: standardise antd imports and remove the avoidable any (#65)' (#102) from feature/65-imports-and-any into main
SonarQube Analysis / sonarqube (push) Canceled after 0s
Tests / lint (push) Canceled after 0s
Tests / backend-unit (push) Canceled after 0s
Tests / frontend-e2e (push) Canceled after 0s
Reviewed-on: #102
2026-08-21 16:15:15 -05:00
bermudalambandClaude Opus 5 a700597440 refactor: standardise antd imports and remove the avoidable any (#65)
SonarQube Analysis / sonarqube (pull_request) Canceled after 0s
Tests / lint (pull_request) Canceled after 0s
Tests / backend-unit (pull_request) Canceled after 0s
Tests / frontend-e2e (pull_request) Canceled after 0s
Stage 1 of #65: this issue's original two lists. The type-checked gate it also owns follows in later stages.

Nine files imported antd from the barrel while the rest of the codebase used deep imports from antd/es. Both resolve to the same modules under antd v5 and Vite, so this is not the tree-shaking problem it would have been under v4 — the cost was that a documented convention had two spellings, and nobody reading a file could tell whether its style was deliberate or just old. Eighty-two imports converted, and every antd/es path was checked to exist before generating any of them rather than trusting a name-mangling rule.

The three `client: any` parameters in cartCheckout are now PoolClient. These functions run inside a transaction, and `any` removed exactly the check that would catch a pool-versus-client mix-up — which in this codebase means a query silently running outside the transaction it was meant to be part of, on the path that takes money.

publicCustomer took `any` and now takes a CustomerRow describing what it actually reads. Typed as its own shape rather than the whole table so that adding a column later — a password hash, a token, an internal note — cannot quietly start being echoed back to a customer.

The three `(window as any).paypal` casts are replaced by a declared interface for the injected SDK. It is deliberately narrow: it describes the three things this app calls, not the whole SDK, because a wider guess would be fiction and a wrong shape typed confidently is worse than an honest cast. The property is optional, since the SDK is absent until its script has loaded — which is the check both call sites already make.

Verified: 141 unit, 169 integration and 94 end-to-end passing. The end-to-end run is the one that matters here — an antd import migration can build cleanly and still break at runtime through styles or context, so a green tsc proves less than it appears to.

Lint drops from 8 warnings to 4 in the backend and 30 to 27 in the frontend, all of them the no-explicit-any this change removed. As a side effect the no-unsafe count that later stages exist to clear falls from 259 to 216 in the backend and 73 to 67 in the frontend, measured rather than estimated.

Refs #65
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:48:12 -05:00
bermudalamb 567e8ba650 Merge pull request 'feat(backend): check the environment at boot instead of discovering it later (#64)' (#96) from feature/64-env-validation into main
SonarQube Analysis / sonarqube (push) Canceled after 55s
Tests / lint (push) Successful in 3m6s
Tests / backend-unit (push) Successful in 55s
Tests / frontend-e2e (push) Failing after 24m56s
Reviewed-on: #96
2026-08-21 15:30:10 -05:00
bermudalamb 9c9e9c3ded feat(backend): check the environment at boot instead of discovering it later (#64)
SonarQube Analysis / sonarqube (pull_request) Failing after 16m53s
Tests / lint (pull_request) Successful in 4m19s
Tests / backend-unit (pull_request) Successful in 1m11s
Tests / frontend-e2e (pull_request) Failing after 27m5s
The backend reads environment variables in a couple of dozen places and validated none of them. A missing or misspelled one was undefined until the first line of code that happened to need it, which could be a long time after the container reported healthy — and several of those failures are silent and customer-visible.

DEMO_MODE is the one that mattered most. It was read as "demo unless the value is exactly the string false", so DEMO_MODE=False, DEMO_MODE=0, or any typo meant demo mode stayed on and the shop quietly stopped charging anyone. It is now required and strict: exactly 'true' or 'false', and anything else refuses to start while quoting the value it was given, so the typo is visible in the message rather than inferred.

Two requirements are conditional, and that is what makes them expressible at all. PayPal credentials are demanded only when DEMO_MODE=false, because QA runs with none of them on purpose and an unconditional rule would be simply wrong there. PUBLIC_URL is demanded only when SMTP is configured, because its only job is building links in email — an environment that cannot send mail does not need it, and requiring it everywhere would break every existing local setup to prevent nothing. UPLOADS_DIR gets no such reprieve: its fallback is correct inside the container and wrong everywhere else, so inheriting it writes uploads somewhere nobody is looking.

Every problem is reported at once rather than one per restart, and the process then exits — the same shape as the container refusing to start on a failed migration rather than serving against a schema it does not match. Warnings are printed but do not stop anything: SMTP absent, the admin gate inactive, or an allowlist missing while mail can be sent. That last one is new and earns its place, since SMTP with no allowlist means the environment can reach real customers, which is what #87 exists to prevent. The admin-gate warning moved here from server.ts, so one place says what this container is and is not configured to do.

validateEnv is a pure function of the environment handed to it rather than a reader of process.env, so it is tested exhaustively without booting anything or mutating global state. It is called from server.ts and deliberately not from app.ts: the integration suite imports app directly and would otherwise become a configuration exercise. Its rules are one small function each at module level, because cognitive complexity counts everything declared inside a function and the first version scored 24 against a limit of 15.

Verified as a real process, not only in tests. A missing DEMO_MODE, a DEMO_MODE of 'False', real payments with no PayPal credentials, and half-configured SMTP each exit 1 with the problems listed; a valid environment starts and serves. Note the exit codes were checked without a pipe, because $? after `| head` reports head rather than node and had first suggested a clean exit.

141 unit, 169 integration and 94 end-to-end passing, lint unchanged at 0 errors and 8 warnings. Both CI workflows already set all six always-required variables plus DEMO_MODE, so the pipeline is unaffected.

Refs #64
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:22:42 -05:00
bermudalamb 984d91f00a Merge pull request 'Feature/63 admin gate' (#94) from feature/63-admin-gate into main
SonarQube Analysis / sonarqube (push) Failing after 30m42s
Tests / lint (push) Successful in 5m55s
Tests / backend-unit (push) Successful in 1m36s
Tests / frontend-e2e (push) Failing after 27m50s
Reviewed-on: #94
2026-08-21 13:44:15 -05:00
bermudalamb 89fc7c5c1b feat(backend): add an application-layer gate to the admin API (#63)
Authorization for the admin panel and the admin API has lived entirely in one auth_request regex in an Nginx Proxy Manager config outside this repository. That control is real and it works — nothing is publicly exposed today — but it is invisible from the code, untested here, and not reviewed when this code changes. Three things follow from that, and the first is the one worth the change.

An admin route added at a path the regex does not match is unprotected the moment it is written, and nothing in Express indicates that. Anything reaching the published container port directly bypasses authentik entirely. And locally there is no gate at all, so no developer ever sees the boundary being enforced.

requireAdminGate is attached to each admin router rather than to a path prefix, which is what makes it useful rather than merely redundant with the proxy. An admin router added later at some other path inherits the gate; because the proxy only injects the header on paths its regex matches, that router refuses on its first request instead of being quietly public. A 403 in that situation is the boundary reporting that it has drifted.

The gate is optional, and unset means exactly today's behaviour. That keeps local development and all 113 existing admin test call sites working untouched, and means shipping the image before configuring the proxy cannot take the admin panel down. What it does not do is stay silent about it: the server warns at boot when the gate is inactive, naming what is unprotected. This project has been bitten repeatedly by controls that report success while doing nothing, and an unconfigured gate should be a visible choice rather than an invisible one.

An empty value is treated as unset rather than as a secret, because enforcing an empty secret would admit any caller sending an empty header. Comparison is timing-safe over SHA-256 digests of both sides: timingSafeEqual throws on buffers of unequal length, so comparing raw values would turn a short header into a 500 rather than a 403, and a length check first would leak the secret's length.

Turning it on requires the secret in two places at once — the stack environment and a proxy_set_header line on the gated location in NPM. Setting only one gives 403s until the other catches up. That coupling, and the three consequences above, are now written into the README beside the deployment section, since none of it is visible from the code.

Verified over real HTTP as well as in tests. Booting without the secret logs the warning and serves admin normally; booting with it returns 403 for a missing header, 403 for a wrong one, 200 for the right one, and leaves the public storefront at 200 throughout, with each refusal logged distinguishably and without echoing the value it was sent. 8 new unit tests, 9 new integration tests covering every admin router separately — a correct middleware nobody mounted would pass the unit tests and protect nothing. 106 unit and 153 integration passing, lint 0 errors and 8 warnings unchanged.

Refs #63
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:44:15 -05:00
bermudalamb 105bf141f5 Merge pull request 'feat: stage new items as pending until an admin publishes them (#90)' (#93) from feature/90-pending-status into main
Tests / backend-unit (push) Successful in 3m23s
SonarQube Analysis / sonarqube (push) Failing after 13m12s
Tests / lint (push) Successful in 3m37s
Tests / frontend-e2e (push) Failing after 11m40s
Reviewed-on: #93
2026-08-21 12:46:26 -05:00
bermudalambandClaude Opus 5 ecc2219fa5 feat: stage new items as pending until an admin publishes them (#90)
SonarQube Analysis / sonarqube (pull_request) Failing after 38m41s
Tests / lint (pull_request) Successful in 8m37s
Tests / backend-unit (pull_request) Successful in 1m22s
Tests / frontend-e2e (pull_request) Failing after 30m44s
An item used to be live on the storefront the instant it was created. Now it starts pending, and a customer sees it only once it is published.

The migration changes the column default and nothing else. Backfilling would un-publish the entire live catalogue, which is the one thing it must not do.

Hiding a pending item took four separate changes, not one, and that is the part worth knowing. The storefront's item routes had no status filter at all — sold items are listed and rendered with a Sold badge deliberately — so pending could not be expressed as one more optional filter. GET /api/items now carries an exclusion the caller cannot opt out of; GET /api/items/:id carries the same, because hiding an item from the list while still serving it by id would leave it reachable to anyone who kept a link; and GET /api/filters excludes pending from both aggregates it computes. That last one is the least obvious: a pending item would have inflated its tags' counts, so a customer would read "Rare (1)", filter by it, and be told nothing matches — and its price would have stretched the slider to a range no visible item occupies.

The tag count is computed over the joined items rather than filtered with a WHERE. A WHERE would have dropped the row for a tag whose only item is pending, and the tag would have vanished from the drawer instead of showing zero. There is a test for exactly that, because the first version of this query had that bug.

parseItemFilters is shared by the storefront and admin routes, so 'pending' parses on both. The public route refuses it explicitly rather than answering with an empty list, which would read as "no items match" instead of "you may not ask that". The storefront's URL reader is deliberately left not accepting it either, with a comment saying so, since a request guaranteed to fail is not worth constructing.

Publishing is the existing mark-available: same transition, same UPDATE, so the admin UI labels that button "Publish" when the item is pending rather than adding a second endpoint that does the same thing. Unpublish is new and is not symmetrical — it is refused for a reserved item, which someone is holding in their cart right now, and for a sold one, which is a record of something that happened rather than a draft. Both refusals name their reason, and the buttons are hidden in those states so the refusal is not how you find out.

Changing a column default has reach, and it surfaced eight test fixtures that silently depended on it. Each is now explicit about the status it wants rather than inheriting one — better practice regardless, and immune to the next default change. Two tests also used 'pending' as their example of an *unknown* status; both would have quietly become tautologies, so they now use one that is genuinely unknown.

Verified: 98 unit, 160 integration and 94 end-to-end passing, the last on a freshly created container. One earlier run showed a single failure in favorites.spec.ts; it passes in isolation and on a clean container, and is the cross-spec interference already recorded against the suite rather than anything from this change.

Refs #90
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:40:08 -05:00
bermudalamb adf480faf6 Merge pull request 'feat(frontend): preview an inventory item as a customer sees it (#89)' (#91) from feature/89-item-preview-panel into main
SonarQube Analysis / sonarqube (push) Failing after 1s
Tests / lint (push) Failing after 1s
Tests / frontend-e2e (push) Failing after 22m17s
Tests / backend-unit (push) Successful in 1m37s
Reviewed-on: #91
2026-08-21 11:46:51 -05:00
bermudalambandClaude Opus 5 03f08074d1 feat(frontend): preview an inventory item as a customer sees it (#89)
SonarQube Analysis / sonarqube (pull_request) Failing after 34m39s
Tests / lint (pull_request) Successful in 5m14s
Tests / backend-unit (pull_request) Successful in 1m38s
Tests / frontend-e2e (pull_request) Failing after 23m16s
Clicking an item's name in the admin Inventory opens a drawer rendering the real storefront ItemCard for it, so the way a listing will look can be checked without publishing it and going to see.

The name cell is a link-styled button rather than a clickable cell, so it stays reachable by keyboard and announces itself as an action. Admin already imports Item from ../api, the same type the storefront uses, so the row object goes straight into the card with no adapter and nothing to drift.

The part that needed care is that ItemCard is not a passive component. It wires into the cart and favorites contexts and has working buttons, and both providers wrap the whole app — so a naive preview would have been fully functional, and an admin browsing inventory could have added their own stock to their own cart. On a one-of-a-kind catalogue that reserves the item and takes it off sale.

ItemCard therefore takes an optional preview prop that short-circuits its two click handlers. Those two are the only entry points, so guarding them also covers the shared auth modal and the favorite-alerts consent prompt hanging off them.

Deliberately not `disabled` on the buttons. A disabled antd button renders in a different colour with a different cursor and no hover, and the whole point of this panel is to show what a customer will actually see. The controls keep their normal appearance and their correct state for the item's status; only the handlers stop. The comment on the prop says so, because "simplifying" this to a disabled button would quietly defeat the feature while appearing to implement it.

Four end-to-end tests, two of which are the ones worth having. Clicking Add to Cart in the preview must do nothing — asserted by the sign-in prompt never appearing, which a real click on a signed-out card always raises, so its absence proves the handler stopped before doing any work. And the storefront card must still be live where it is actually used, or this change would have quietly broken buying things.

ItemCard's props are now Readonly, which was an existing lint warning on a file this change already touches: frontend warnings drop from 31 to 30.

Verified: build clean, lint 0 errors, 91 end-to-end tests passing against a freshly created database.

Refs #89
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:41:59 -05:00
bermudalamb e3d5475fa6 Merge pull request 'feat: let QA send real email, guarded by a recipient allowlist (#87)' (#88) from feature/87-qa-mail-allowlist into main
SonarQube Analysis / sonarqube (push) Failing after 14m39s
Tests / lint (push) Successful in 4m38s
Tests / backend-unit (push) Successful in 1m31s
Tests / frontend-e2e (push) Failing after 27m56s
Reviewed-on: #88
2026-08-21 09:40:49 -05:00
bermudalambandClaude Opus 5 0c90e18205 feat: let QA send real email, guarded by a recipient allowlist (#87)
SonarQube Analysis / sonarqube (pull_request) Failing after 13m54s
Tests / lint (pull_request) Successful in 4m27s
Tests / backend-unit (pull_request) Successful in 1m23s
Tests / frontend-e2e (pull_request) Failing after 24m46s
QA has never been able to send mail. The compose file set no SMTP variables and the mailer skips sending when it finds none, which was deliberate — a QA run must not be able to email a real customer if a fixture ever holds a real address. The cost is that four customer-facing flows have never been exercised anywhere but production: verification, password reset, favorite-sold alerts, and the cart-reminder cron that already has a known silent failure mode.

MAIL_ALLOWLIST replaces the blanket mute. Unset means unrestricted, which is production and must stay so. Set means only matching recipients are delivered to; anything else is skipped with a [mail-blocked] warning naming the address and subject. An entry is either a full address, which also covers its plus-suffixed variants, or @domain for every mailbox there — plus-addressing is how these tests get written, and nobody should have to edit an allowlist to invent a new suffix mid-run.

The guard sits in the mailer, not at the four call sites, so every sender is covered by construction and a fifth added later cannot bypass it by forgetting. It skips rather than throws: three callers already swallow send failures into a log, so throwing would mostly be caught anyway while risking a 500 on the signup path. The flow under test finishes and the log says why no mail arrived, which is exactly what was missing when QA was simply muted.

Two details are load-bearing enough to state. Comparison is exact equality on both halves of the address rather than a suffix test, so a lookalike domain ending in an allowed one cannot get through — there is a test for that specifically. And a present-but-empty value refuses everyone rather than allowing everyone: writing MAIL_ALLOWLIST= expresses an intent to restrict, and reading it as "no restriction" would turn a typo into an outbound mail incident.

This inverts the failure mode, so the allowlist is hardcoded in docker-compose.qa.yml rather than read from a stack variable. The safety property must not depend on remembering to set something in Portainer, where an omission would mean unrestricted sending from an environment full of fixtures. The comment says removing the line disables the restriction rather than the mail.

QA points at Brevo, reusing the existing account rather than a separate QA sender — a deliberate choice that puts QA volume behind production's sending reputation and quota, acceptable for now. Host, port and secure are pinned in the compose because the mailer's fallbacks are Gmail's and Brevo needs 587 with STARTTLS; that mismatch fails at send time rather than at boot, which is #64's territory.

Verified: 12 new unit tests on the matching function, which is where a mistake would actually be dangerous — 98 unit and 144 integration passing, lint 0 errors and 8 warnings unchanged, and the compose renders the expected values under docker compose config.

Refs #87
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 09:15:30 -05:00
bermudalamb d4d602da3e Merge pull request 'Feature/84 ipv6 rate limit key' (#86) from feature/84-ipv6-rate-limit-key into main
SonarQube Analysis / sonarqube (push) Failing after 10m46s
Tests / lint (push) Successful in 1m39s
Tests / backend-unit (push) Successful in 34s
Tests / frontend-e2e (push) Failing after 9m38s
Reviewed-on: #86
2026-08-20 18:44:48 -05:00
bermudalamb 2f6e855596 fix(backend): stop IPv6 callers bypassing the password-reset rate limit (#84)
The QA stack has been logging ERR_ERL_KEY_GEN_IPV6 at every boot, and express-rate-limit was right to complain.

keyByCallerAndEmail built its key from req.ip raw. For an IPv4 caller that is one address and the limiter worked as intended. For an IPv6 caller it is the full 128 bits — and a residential IPv6 customer is delegated an entire prefix and can source every request from a different address inside it at no cost. Keyed that way, each request counted as a new caller and the allowance of five per fifteen minutes never bound at all.

That matters more here than it would elsewhere, because of what this limiter is for. Its own comment says it: without one, anyone can make the server send unlimited mail to any address they choose. For IPv6 clients there effectively was no limiter, while the code read as though there were.

The caller half of the key now goes through express-rate-limit's ipKeyGenerator, which groups IPv6 by prefix and returns IPv4 unchanged. The helper's default is /56 rather than /64, and that default is kept deliberately: /56 covers a whole delegated site, so an attacker cannot escape their bucket by moving within their own allocation. It does mean several households behind one delegation share an allowance — acceptable only because the key also carries the email address, so they collide just when targeting the same account. The reasoning sits next to the code, because a future reader tightening it to /64 would silently reopen the hole.

keyByCallerAndEmail is now exported so it can be tested directly. The limiter's allowance is still not asserted anywhere, and should not be: its store is process-wide, so a test that exhausts it leaks into every later test from the same address and fails something unrelated later. The key function is pure, and it is where the bug was.

Verified by firing the guard rather than reasoning about it: building main and loading the module reproduces the ValidationError, and the same load with this change is silent. Seven new unit tests cover an IPv4 caller unchanged, two addresses in one delegation collapsing to a single key, separate delegations staying apart, an IPv4-mapped address keying the same as the plain IPv4 one, email normalisation, a non-string email, and a request with no address at all.

86 unit tests pass, 144 integration, lint 0 errors and 8 warnings — unchanged.

Closes #84
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:44:48 -05:00
bermudalamb cf45b7a8eb Merge pull request 'Feature/62 error boundary' (#85) from feature/62-error-boundary into main
SonarQube Analysis / sonarqube (push) Failing after 11m16s
Tests / lint (push) Successful in 1m39s
Tests / backend-unit (push) Successful in 36s
Tests / frontend-e2e (push) Failing after 7m22s
Reviewed-on: #85
2026-08-20 18:32:27 -05:00
bermudalambandClaude Opus 5 71cbd142c3 fix: address the final review of the error boundaries (#62)
Five findings from a whole-branch review, two of which mattered.

The catalogue boundary could not be recovered by the very controls it exists to keep alive. When the item grid threw, the header, filter chips and Clear filters stayed mounted — that was the point — but clicking Clear filters only changed the URL. A boundary does not reset on a client-side state change, so the fallback went on rendering over a catalogue that would by then have loaded perfectly well, and the only offered action reloaded the same failing URL. The shop read as permanently broken, which is the failure this whole change exists to prevent, reintroduced one level down. ErrorBoundary now takes an optional resetKey and clears itself when it changes; the catalogue boundary passes the filter key. The page and modal boundaries deliberately do not take one, because their escapes are hard navigations that remount the tree already — recorded on the prop so nobody completes the pattern by symmetry.

The client-error endpoint could fill the disk. It is unauthenticated, each accepted report wrote about 8.7 KB, and Docker's default json-file driver has no size cap — so the rate limiter bounded a render loop, as its comment claims, but not a few hundred cheap source addresses. Stack and component stack now truncate at 1000 rather than 4000, which is still around fifteen frames and cuts the worst case to under 3 KB, and the QA compose file caps and rotates the log. Production is a Portainer stack outside this repository and needs the same option applied there; noted in the design doc rather than left implied.

Three smaller things. A falsy thrown value defeated the boundary entirely: throw null is legal, and branching on the error object alone treated it as no error, re-rendered the children, threw again, and would have taken the root down — a blank page, the one outcome this is all here to avoid. The boundary now tracks hasError separately and synthesises a real Error for non-Error throws. The modal fallback announced its title twice to a screen reader, once as the dialog's name and once as the heading inside it, so the redundant dialog title is gone. And the design doc claimed the development-only detail shows the component stack when it only ever showed the message; corrected, with a note that the stack still reaches the server log, which is where it is useful.

Verified after all five: backend lint 0 errors, 144 integration tests, frontend lint 0 errors and 31 warnings, 87 end-to-end tests, all against a freshly created database.

Refs #62
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:32:27 -05:00
bermudalambandClaude Opus 5 557703f86d docs: mark the error-boundary design implemented (#62)
Records the two things the design got wrong. antd's Result renders its title as a plain div, so the design's Result usage and its getByRole('heading') assertions contradicted each other and the tests could never have passed as written — resolved by giving the title real heading semantics rather than by loosening the assertion, because an error page with no heading leaves a screen-reader user navigating by headings nothing to find. And import.meta.env had no ambient declaration anywhere in the app, so the DEV gate did not type-check until vite-env.d.ts was added.

The Vite error overlay risk the design flagged did not materialise.

Refs #62
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:32:27 -05:00
bermudalambandClaude Opus 5 f156cecd87 feat(frontend): mount error boundaries at the root, the item grid and the modals (#62)
Three mount points, so a render error costs the smallest part of the page it can.

The catalogue boundary is the one that earns its keep. The likeliest throw in this app is a component rendering data from the API, and the item grid renders the most of it per page — contained there, the header, cart badge, filters and footer all survive, so a customer can still navigate instead of being handed one dead page.

The modal boundary exists because the modal-route arrangement couples two independent trees. /account, /login and the rest render as modals over the storefront as a backdrop, so without a boundary between them a throw in Account blanks the storefront behind it and a throw in the storefront takes the open modal with it. One boundary separates them in both directions.

Every escape action is a hard navigation rather than a Link. This is worth stating because the obvious implementation is wrong: a boundary does not reset when the route changes, so a Link would change the URL and go on rendering the fallback, which reads as the app being permanently broken.

ErrorFallback changed too, outside this change's original scope and for a reason worth recording. antd's Result renders its title as a plain div with no heading semantics, so a page whose entire content is an error message offered a screen-reader user navigating by headings nothing at all to find. The title is now wrapped in Typography.Title. The tests assert a heading role and were right to; the component was what needed fixing, not the assertion.

DevThrow throws on ?boom=<scope> and is mounted only behind import.meta.env.DEV, so Rollup drops it from a production build. Checked in both directions rather than trusted: the dev server serves it, and a production bundle greps to zero occurrences of its marker. A gate that is silently always-off looks identical to one that works.

Verified: 87 end-to-end tests pass, 4 of them new — each boundary catches rather than blanking, the header survives a catalogue throw, the storefront survives a modal throw, and the report is observed reaching /api/client-errors on the wire rather than assumed. Build clean, lint 0 errors and 31 warnings.

Refs #62
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:32:27 -05:00
bermudalambandClaude Opus 5 3e9d9a57c0 fix(frontend): make the error reporter genuinely unable to throw (#62)
The comment claimed the reporter could not throw and the code did not deliver it. JSON.stringify(report) and the call to fetch both run synchronously, as arguments, before the promise carrying the .catch exists — so a throw from either escaped straight out of componentDidCatch, where nothing remains to catch it. The boundary that exists to stop errors would itself have been the thing that crashed.

Not merely theoretical: React does not guarantee the value handed to componentDidCatch is a real Error despite the parameter's type, because code can throw anything. An object whose message or stack is circular makes JSON.stringify throw.

The body is now wrapped in try/catch for the synchronous part, and the existing .catch still covers rejection once the request is in flight. Neither covers the other, so both are kept, and the comment now says so rather than asserting a guarantee the code did not make.

Verified: build clean, lint 0 errors and 31 warnings, unchanged.

Refs #62
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:32:27 -05:00
bermudalamb 9599387f34 Merge pull request 'docs: design for React error boundaries (#62)' (#83) from feature/62-error-boundary into main
SonarQube Analysis / sonarqube (push) Canceled after 4m10s
Tests / lint (push) Canceled after 0s
Tests / backend-unit (push) Canceled after 0s
Tests / frontend-e2e (push) Canceled after 0s
Reviewed-on: #83
2026-08-20 17:46:23 -05:00
bermudalambandClaude Opus 5 e0ae2dcbcd feat(frontend): add an error boundary, its fallback, and error reporting (#62)
SonarQube Analysis / sonarqube (pull_request) Failing after 5m0s
Tests / lint (pull_request) Failing after 18m19s
Tests / backend-unit (pull_request) Successful in 44s
Tests / frontend-e2e (pull_request) Failing after 7m9s
Three pieces, none of them mounted yet — the next change wires them into the tree.

ErrorBoundary is the only class component in the codebase, because getDerivedStateFromError and componentDidCatch have no hook equivalent. It knows nothing about antd and nothing about how reporting reaches the server: the fallback arrives as a render prop, which is what lets one boundary serve a full page, an inline region and a modal without knowing which it is.

ErrorFallback is the single place that decides whether a customer is shown a stack trace. The detail is gated on import.meta.env.DEV so a developer sees the throw immediately while a production bundle cannot render it at all — one decision in one file rather than the same judgement repeated at three mount points, where they would drift apart.

reportClientError posts to the endpoint added earlier and deliberately swallows its outcome. That is the one place in this feature where swallowing is correct: it runs inside componentDidCatch, so a reporter that rejected would throw from the very thing that exists to stop throws, with nothing left to catch it.

vite-env.d.ts was not in the plan and is needed. Nothing in this app had used import.meta.env before, so there was no ambient declaration for it and tsc rejected the DEV check outright. The standard one-line Vite reference fixes it, adds no dependency, and would have been needed by the next change regardless.

Verified: build clean, lint 0 errors and 31 warnings, unchanged from the branch baseline. No unit tests, because the frontend has no unit suite — that gap belongs to #72, and these components are covered end to end by the next change.

Refs #62
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 17:40:16 -05:00
bermudalambandClaude Opus 5 7d227507b0 fix(backend): stop a client error report forging log lines (#62)
Review of the endpoint found that truncating the report's fields is not enough. It is unauthenticated and reachable without the frontend, so a caller could embed a newline in any field and forge what reads as a second [client-error] record in the shared server log. Every field is now stripped of CR, LF and the other C0 control characters, plus DEL, each replaced by a single space, so one report is always exactly one log record.

Sanitising happens before truncation rather than after. The substitution is 1-for-1, so it cannot change the string's length and clipping the sanitised value still guarantees the stored result never exceeds the limit. An escaping scheme that expanded a control character into several visible ones would need the opposite order to keep that guarantee, so the two are not interchangeable — recorded in a comment next to the code rather than left for someone to rediscover by reversing it.

The check is a numeric code-point comparison rather than a regex over a control-character class. That is not style: the first attempt used one, and the hex escapes were corrupted into raw control bytes on the way into the file. Written this way the source never has to contain an escape sequence or a raw control character at all, and the file is verified free of both.

The review also found the truncation boundary was never exercised — the only test sent 5000 characters against a 500 limit. Tests now cover a string of exactly the limit passing through untouched, one character over truncating, truncation of stack and componentStack rather than message alone, and a report full of newlines producing a single log line.

Verified: 10 integration tests pass, up from 4, and lint reports no new warnings.

Refs #62
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 17:32:45 -05:00
bermudalambandClaude Opus 5 c693672051 feat(backend): log client-side render errors to the server (#62)
The frontend's error boundaries need somewhere to report to. A boundary that only shows a customer a message leaves nobody knowing it happened, which is the failure shape this project has designed against three times already.

POST /api/client-errors takes a report, truncates its fields, logs it with a [client-error] prefix and returns 204. No storage: the container log is where this project's operational visibility already lives, and a table with a retention policy and an admin screen is a subsystem larger than the issue.

An unrecognised context is a 400 rather than a log line under a guessed label, following parseItemFilters, which refuses a malformed filter instead of coercing it. Oversized fields go the other way and are truncated rather than refused, because an over-long report is still the only record of the failure.

The endpoint gets its own rate limiter rather than reusing passwordResetRequestLimiter, whose comment already warns that its caller-and-email key collapses every caller into one shared bucket on an endpoint without an email. The new one takes the default key generator, which also avoids the ERR_ERL_KEY_GEN_IPV6 warning the custom key produces.

Verified: 138 integration tests pass, 4 of them new, and 79 unit. The unit count rose by one without a test being written — routesAreWrapped.test.ts runs describe.each over the files in src/routes, so a new route file generates a case. The handler is synchronous and needs no asyncRoute wrapper.

Refs #62
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 17:23:18 -05:00
bermudalambandClaude Opus 5 94e2267eaf docs: implementation plan for React error boundaries (#62)
SonarQube Analysis / sonarqube (pull_request) Successful in 14m37s
Tests / lint (pull_request) Successful in 1m59s
Tests / backend-unit (pull_request) Successful in 1m13s
Tests / frontend-e2e (pull_request) Failing after 8m46s
Four tasks, each ending in an independently testable deliverable: the backend endpoint with its own rate limiter, the boundary and fallback components, the three mount points with end-to-end coverage, and the production-gate verification.

Refs #62
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:28:38 -05:00
bermudalambandClaude Opus 5 683139b8d8 docs: correct the error-boundary design's fallback actions (#62)
Found while working the design into a plan: the obvious implementation of the fallback's escape actions is wrong, and the spec was recommending it.

A React error boundary does not reset when the route changes. The original spec justified placing the root boundary inside BrowserRouter on the grounds that the fallback needed router context to offer a way back — but a fallback offering a Link would change the URL and go on rendering the fallback, which reads as the app being permanently broken rather than recovering. Every escape action is therefore a hard navigation: reload, or setting window.location.href. The placement is unchanged, but it is now justified by what the boundary guards rather than by reasoning that does not hold.

Three consequences recorded while there. The three fallbacks get distinct titles rather than one shared string, so a customer learns which part failed and the tests get an unambiguous locator for which boundary caught. The modal throw trigger mounts as an unconditional sibling inside its boundary, so /?boom=modal exercises it with the storefront behind rather than depending on /account resolving a session first. And a fourth end-to-end test asserts the report actually reaches /api/client-errors by observing the request, rather than trusting the reporter was called.

Also recorded: new files use antd/es deep imports, this project's documented convention — not antd/lib, which #65 notes loads a second React context and breaks ConfigProvider.

Refs #62
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:25:48 -05:00
bermudalambandClaude Opus 5 e8374e391a docs: design for React error boundaries (#62)
SonarQube Analysis / sonarqube (pull_request) Failing after 13m37s
Tests / lint (pull_request) Successful in 2m27s
Tests / backend-unit (pull_request) Successful in 40s
Tests / frontend-e2e (pull_request) Failing after 9m4s
Settles the four questions #62 left open, and records why each alternative lost.

Three mount points rather than one: root, the catalogue, and the modal block. The catalogue boundary is the one that earns its keep, because the likeliest throw in this app is a component rendering API data and the item grid renders the most of it per page — containing it there keeps the header, cart badge and filters alive instead of handing the customer one dead page. The modal boundary exists because the modal-route arrangement couples two independent trees: without a boundary between them a throw in Account blanks the storefront behind it, and a throw in the storefront takes the open modal with it.

Errors get reported to a new POST /api/client-errors that logs and returns 204, with no storage. A boundary that only shows a message leaves nobody knowing it happened, which is the exact failure shape this project has designed against three times already. A persisted store with an admin screen was rejected as a subsystem larger than the rest of the issue.

Rate limiting needs its own limiter rather than the existing one. rateLimit.ts already documents that passwordResetRequestLimiter is keyed on caller and email, and that reusing it where there is no email collapses every caller into one shared bucket — so this endpoint gets a separate limiter keyed on req.ip, which is the real client address because trust proxy is already set.

Recorded as rejected: an outermost boundary around the providers, which would sit outside ConfigProvider and need a second hand-styled fallback for a case that is remote — their render bodies are state and JSX with no data mapping. Flagged for revisiting if that stops being true.

Also recorded: the rate limiter is deliberately not asserted in the integration suite, because its store is process-wide and a test that exhausts the allowance leaks into every later test keyed on the same address.

Refs #62
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:17:29 -05:00
bermudalamb 8fef669358 Merge pull request 'Feature/81 technical debt' (#82) from feature/81-technical-debt into main
SonarQube Analysis / sonarqube (push) Failing after 13m29s
Tests / lint (push) Successful in 1m43s
Tests / backend-unit (push) Successful in 1m7s
Tests / frontend-e2e (push) Failing after 8m41s
Reviewed-on: #82
2026-08-20 15:08:18 -05:00
bermudalamb 4bad40868c refactor(frontend): actually reduce Customers' complexity rather than relocate it (#81)
SonarQube Analysis / sonarqube (pull_request) Successful in 14m7s
Tests / lint (pull_request) Successful in 1m52s
Tests / backend-unit (pull_request) Successful in 39s
Tests / frontend-e2e (pull_request) Failing after 8m52s
The previous commit claimed this one fixed. It was not: hoisting the confirm dialog to module level moved the reported line from 12 to 60, and I read that as the finding having moved to the hoisted function. It had not — line 60 was Customers() itself, still scoring exactly 16. The scan is what caught it, which is the argument for scanning rather than reasoning about what a rule will say.

Two further attempts also failed to move the number, and both are worth recording because they were wrong about what cognitive complexity counts. Collapsing five branches on `disabling` into one copy object fixed the hoisted function but left Customers() at 16. Extracting ten ternaries out of the table's cell renderers into module-level components left it at 16 as well — the ternaries inside a render callback were never the weight.

What actually carried the score was the drawer and the reserved-items dialog: two JSX bodies whose loading, empty and populated states are each a branch nested several levels inside the component. Extracting them as CustomerDetailPanel and ReservedItemsBody takes Customers() under the limit.

The cell-renderer extraction is kept even though it did not move the metric. NameAndEmail, BooleanTag, ReservedCell and ToggleDisabledButton read better than the inline callbacks did, and BooleanTag removes a repetition the Status column was open-coding differently from Verified and Subscribed.

Verified on a scan rather than by argument: technical debt 85 minutes to 5, code smells 14 to 1, and the one that remains is the S6478 false positive. ESLint holds at 31 warnings against a baseline of 35. End-to-end 83 pass.

One thing found on the way: the e2e suite is not idempotent against a persistent database. Two runs against a database that had already served three produced two different pairs of failures; recreating it produced a clean 83. CI is unaffected because its Postgres is fresh per run, but locally the suite needs a new database rather than a repeated one.

Refs #81
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:39:39 -05:00
bermudalambandClaude Opus 5 5f9172512c refactor: clear the 85 minutes of technical debt (#81)
Thirteen of SonarQube's fourteen code smells, with the fourteenth argued as a false positive rather than coded around.

Four of these were not stylistic. The Remove button in Cart's List.Item actions array carried no key, so React could not match it across renders and rebuilt it on every cart render. The cart, customer-auth and favorites providers each passed a freshly allocated object as their context value, which re-renders every consumer whenever the provider renders, cart or session unchanged — and these three wrap the entire storefront, so the fan-out was the whole tree. Two of them also rebuilt a Set on every render for good measure. All four now memoized on the values they actually derive from.

The four cognitive-complexity findings wanted real restructuring rather than suppression. parseItemFilters splits into one helper per field, leaving the function with the order those helpers run in and the single rule that spans two fields; the order is preserved exactly, because a query wrong in two ways reports the first field and rearranging the calls would change which error a caller sees. adminCategories' PUT extracts the parent resolution — the existence check and the cycle check — into a resolver that returns the refusal rather than sending it. App's four-way render chain becomes a Catalogue component, which also removes two of the nested ternaries. Customers' confirm dialog moves to module level, since a function's cognitive complexity counts everything declared inside it and that dialog branches on `disabling` five times.

The rest were mechanical: two more nested ternaries — a status-colour lookup and a pluralisation helper — and one type assertion that asserted the type the expression already had.

Left alone: S6478 on CategoryTreeSelect's popupRender. That is antd's render prop, called as a function and spliced in, never mounted as a component type, so the destroy-the-subtree failure the rule describes cannot happen. Marked false positive in SonarQube with that reasoning rather than contorting the component around a rule that misread it.

Verified rather than assumed. Backend unit 78 pass, integration 134 pass, end-to-end 83 pass, both workspaces build clean. ESLint warnings drop from 35 to 31 with no new file warning — the same React and SonarJS rules #60 turned on are what surfaced this backlog in the first place.

Refs #81
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:10:12 -05:00
bermudalamb 931d8f8167 Merge pull request 'chore(ci): declare sonar.projectVersion so the new-code period means something (#79)' (#80) from feature/79-sonar-project-version into main
SonarQube Analysis / sonarqube (push) Failing after 18m3s
Tests / lint (push) Successful in 2m7s
Tests / backend-unit (push) Successful in 46s
Tests / frontend-e2e (push) Failing after 10m30s
Reviewed-on: #80
2026-08-20 13:19:32 -05:00
bermudalamb e6d9079097 chore(ci): declare sonar.projectVersion so the new-code period means something (#79)
SonarQube Analysis / sonarqube (pull_request) Successful in 13m35s
Tests / lint (pull_request) Successful in 2m4s
Tests / backend-unit (pull_request) Successful in 41s
Tests / frontend-e2e (pull_request) Failing after 9m22s
The quality gate has been grading the entire codebase rather than what changed. The server's new-code period is PREVIOUS_VERSION, but sonar.projectVersion was never set anywhere — not here, not in the workflow — so every analysis recorded "not provided" and there was no previous version to diff against. SonarQube's fallback is to treat everything as new.

The tell was visible in the measures all along: new_lines read 9460 against a total ncloc of 5496, and new_coverage tracked overall coverage to within two points. Both are what you would expect if "new code" meant "all code", and neither is surprising enough to notice unless you go looking. This is the fourth time the project has hit the same shape — a tool reporting a plausible number for something other than what was asked.

Verified with a scan against the scratch key rather than by reasoning about the config. The analysis now records version 1.0.0, ncloc holds at 5557 so nothing was silently dropped, and new_lines falls from the whole codebase to 151 — the window is now the diff.

One consequence worth expecting rather than discovering: a narrow window makes new_coverage volatile. The verification scan reported 0.0% on two lines to cover, because two uncovered lines is all it takes. The number will settle as commits accumulate and the window widens, but the gate will be jumpy for the first few merges, and it will not simply turn green on its own.

Left at 1.0.0 to match both package.json files. Nothing enforces that they stay in step, so the comment says to move all three together.

Refs #79
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:33:17 -05:00
bermudalamb c2964482e0 Merge pull request 'docs: require an issue behind every branch and PR (#77)' (#78) from chore/77-branch-issue-convention into main
SonarQube Analysis / sonarqube (push) Successful in 23m46s
Tests / lint (push) Successful in 2m25s
Tests / backend-unit (push) Successful in 55s
Tests / frontend-e2e (push) Failing after 10m39s
Reviewed-on: #78
2026-08-20 11:10:21 -05:00
bermudalamb 0b8419dffb docs: require an issue behind every branch and PR (#77)
The documented convention allowed dropping the issue number when no issue existed. The clause looks harmless — it was meant for trivial work — but in practice it turns "there is no issue yet" into "there is no issue ever", and two branches went that way this week: the app icon, merged as #70, and the header mark, which had to be filed retroactively as #76. Both were legitimate work that the tracker never heard about, and the convention is what permitted it.

The cost is not bookkeeping. The issue is where the reasoning, the rejected alternatives, and the verification end up — the record that outlives the conversation that produced it. A branch with nothing behind it leaves that nowhere but a commit message.

Everything else about the convention is unchanged: the type prefixes, the (#N) subject, the Closes #N body line, and the point that Gitea builds the link from the commit rather than the branch name.

Closes #77
2026-08-20 11:10:21 -05:00
bermudalamb 95d36b0dab Merge pull request 'feat(frontend): show the RD mark beside the storefront wordmark' (#75) from feature/header-brand-mark into main
SonarQube Analysis / sonarqube (push) Successful in 20m15s
Tests / lint (push) Successful in 1m53s
Tests / backend-unit (push) Successful in 38s
Tests / frontend-e2e (push) Failing after 9m13s
Reviewed-on: #75
2026-08-20 11:00:18 -05:00
bermudalamb 7b89023d5e feat(frontend): show the RD mark beside the storefront wordmark
SonarQube Analysis / sonarqube (pull_request) Failing after 15m22s
Tests / lint (pull_request) Successful in 2m8s
Tests / backend-unit (pull_request) Successful in 44s
Tests / frontend-e2e (pull_request) Failing after 10m12s
Puts the monogram to the left of "Redefined Designs" in the site header.

Inline SVG rather than an <img src="/favicon.svg"> for two reasons. The tile is drawn in currentColor so it follows the antd text token and inverts with the app's own theme switch — the favicon file inverts on prefers-color-scheme, which tracks the operating system and would disagree with the app whenever someone toggles the theme themselves. And the letters are knocked out with a mask rather than painted in the background colour, so the mark sits correctly on any surface instead of carrying a backdrop that only matches this header.

The mask id is generated per instance, since two marks on one page would otherwise share an id and the second would reference the first. It is stripped to alphanumerics because React's generated ids contain colons.

Marked aria-hidden: the wordmark beside it already says the name, so announcing it again would be noise.

Verified by screenshotting the header in both themes rather than by reasoning about the colours.
2026-08-20 10:55:47 -05:00
bermudalamb 0587c18307 Merge pull request 'Feature/61 coverage import' (#73) from feature/61-coverage-import into main
SonarQube Analysis / sonarqube (push) Failing after 19m4s
Tests / lint (push) Successful in 3m20s
Tests / backend-unit (push) Successful in 48s
Tests / frontend-e2e (push) Failing after 9m10s
Reviewed-on: #73
2026-08-20 10:30:29 -05:00
bermudalamb 261d087a9c docs(ci): record the coverage pipeline contract and the CI identity gap
SonarQube Analysis / sonarqube (pull_request) Successful in 14m41s
Tests / lint (pull_request) Successful in 2m5s
Tests / backend-unit (pull_request) Successful in 40s
Tests / frontend-e2e (pull_request) Failing after 8m57s
Two standing documents rather than one, because they are different kinds of thing: one is a contract the pipeline must keep, the other is work not yet done.

The coverage contract names the seven requirements that keep SonarQube's number real, and what specifically breaks if each lapses. It exists because coverage does not fail loudly — it reports a smaller number, which looks exactly like tests covering less. That is the third time this project has met a tool that succeeds while measuring nothing, after #67 and #60, so the failure mode is written down alongside how to check the guard still fires.

The identity document covers CI authenticating to SonarQube as admin rather than a restricted account, raised as a "Related" note in #61 and split out so a permissions change is not buried in a CI-config commit. It spells out the revoke step explicitly, since the workflow goes green one step earlier and stopping there leaves the old credential valid.
2026-08-20 10:14:33 -05:00
bermudalamb 332c1e7cd0 feat(ci): import test coverage into SonarQube (#61)
SonarQube reported 0% coverage for 78 unit, 134 integration and 83 end-to-end tests, so the coverage-on-new-code gate — the most useful thing SonarQube offers a project this size — has been failing permanently while looking configured. It now reports 69.6%, verified by a real scan.

Backend coverage comes from both suites, written to separate directories because jest writes coverage/lcov.info by default and the second run would silently overwrite the first. Both are needed rather than just the fast one: the unit suite alone reports 11%, because everything in src/routes is exercised by the integration suite. That suite is manual-only after hanging for 3h12m post-run, so it runs here with --forceExit and the job carries a hard timeout; jest confirmed during testing that it would otherwise have hung.

The frontend had no unit tests at all, so its coverage comes from Playwright driving an istanbul-instrumented dev server, collected per test by an auto-fixture and merged with nyc. The 17 specs now import from a local fixtures module that re-exports @playwright/test, which is what lets the fixture attach without touching each test body.

Instrumentation is gated behind COVERAGE=true and loaded by dynamic import, since vite-plugin-istanbul is ESM-only while vite.config.ts evaluates as CommonJS. Both directions were checked rather than assumed: a normal build contains no instrumentation, and the dev server instruments nested modules as well as top-level ones — the first attempt used an include glob of src/* which would have silently missed everything under src/admin and src/cart.

coverage:report fails when nothing was collected instead of writing an empty report, and that guard was fired deliberately to confirm it works. This project has been bitten twice by tools succeeding while measuring nothing — SonarQube skipping the whole frontend and still exiting EXECUTION SUCCESS in #67, and an ESLint matcher silently matching no files during #60 — and coverage has exactly that shape: an uninstrumented dev server lets every test pass while gathering nothing, and the 0% that follows reads as lost coverage rather than broken collection.

Worth knowing when reading the numbers: end-to-end coverage flatters. Istanbul marks a line covered when the browser ran it, so a component rendered during a test counts as covered with nothing asserting anything about it. Recorded in the design doc and the project context rather than left to be discovered.

Also declares sonar.tests so test files are analysed under the test rule set rather than as production code.

Closes #61
2026-08-20 10:13:13 -05:00
bermudalamb 7e4084a65f docs: design for importing test coverage into SonarQube (#61)
Records what #61 still needs after #67 delivered two of its four asks, and the two constraints that shape the rest: backend route logic is covered only by the integration suite, which is manual-only because of a post-run hang, and the frontend has no unit tests at all so its coverage has to come from instrumenting the app and collecting from Playwright.

Also records the thing most likely to mislead later — end-to-end coverage marks a line covered when the browser merely ran it, so the frontend number will read considerably better than the testing behind it, and the 80% gate will be easier to clear on frontend changes than backend ones. Accepted deliberately, because the alternative leaves every frontend pull request failing a gate it cannot satisfy.

Refs #61
2026-08-20 09:50:46 -05:00
bermudalamb 0e7fc83ea9 Merge pull request 'feat(frontend): add an RD monogram app icon' (#70) from feature/app-icon into main
SonarQube Analysis / sonarqube (push) Successful in 3m25s
Tests / lint (push) Successful in 2m4s
Tests / backend-unit (push) Successful in 38s
Tests / frontend-e2e (push) Failing after 9m9s
Reviewed-on: #70
2026-08-20 09:01:27 -05:00
bermudalamb b677378533 feat(frontend): add an RD monogram app icon
SonarQube Analysis / sonarqube (pull_request) Successful in 3m50s
Tests / lint (pull_request) Successful in 2m19s
Tests / backend-unit (pull_request) Successful in 2m18s
Tests / frontend-e2e (pull_request) Failing after 9m50s
The app had no favicon at all, so every tab showed the browser's default globe. Adds an RD monogram as frontend/public/favicon.svg plus a 180x180 apple-touch-icon.png, and links both from index.html.

The mark is the letters knocked out of a filled tile rather than drawn on their own. At 16px — which is the only size that really decides a favicon — a solid shape with a bright interior stays findable in a strip of twenty tabs, where bare strokes turn to grey fuzz. Every candidate was rendered at 16, 32, 64 and 128 in headless Chromium and looked at rather than trusted from the path data, which is what caught the two drafts that did not survive: one had the D's bowl extending past the viewBox and clipping flat, because a stroked arc reaches half the stroke width beyond its centreline; another shared a stem between a mirrored R and the D and read as a Cyrillic Я.

Colours are the accent already defined in src/main.tsx, and the SVG inverts with the theme for the same reason that accent does. Chrome ignores media queries inside a favicon and keeps the light rendering; that is acceptable here rather than worked around, because the pale letters still carry the mark against a dark tab strip. The Apple icon is full-bleed with no inner corner radius, since iOS applies its own mask and would otherwise show a small rounded tile floating inside a larger rounded tile.

Also sets theme-color for both schemes so the browser chrome on Android and in an installed PWA matches the app rather than fighting it.

Verified: build clean, lint clean, and both files land in dist/, which the Dockerfile copies wholesale into the image's public directory.
2026-08-20 08:41:35 -05:00
bermudalamb 733fcf3c2d Merge pull request 'fix(ci): make SonarQube actually analyse the frontend (#67)' (#69) from bugfix/67-sonar-frontend-skipped into main
SonarQube Analysis / sonarqube (push) Successful in 4m10s
Tests / lint (push) Successful in 1m58s
Tests / backend-unit (push) Successful in 41s
Tests / frontend-e2e (push) Failing after 9m38s
Reviewed-on: #69
2026-08-19 14:52:07 -05:00
bermudalamb ae8f0eb12c fix(ci): make SonarQube actually analyse the frontend (#67)
SonarQube Analysis / sonarqube (pull_request) Successful in 3m52s
Tests / lint (pull_request) Successful in 1m55s
Tests / backend-unit (pull_request) Successful in 46s
Tests / frontend-e2e (pull_request) Failing after 9m41s
SonarQube was analysing none of frontend/src. All 34 files were indexed and reported ncloc 0, so the quality gate had been measuring roughly a third of the codebase while appearing to cover it. The scanner log gives the cause: frontend/tsconfig.json sets "moduleResolution": "bundler", which is correct for Vite, and the TypeScript bundled with SonarQube 9.9 predates 5.0 and rejects it. Building the frontend program throws, every frontend file is dropped, and the scan still exits EXECUTION SUCCESS — which is why a green job hid this indefinitely.

Adds frontend/tsconfig.sonar.json, an analysis-only mirror that differs only in using "node", and points the scan at it via sonar.typescript.tsconfigPaths. The app's own tsconfig is deliberately untouched: "bundler" is right for the build, and changing it to satisfy an old analyser would let the tool dictate the build. The mirror cannot use `extends` — the old compiler validates the base file while reading it, so the error just moves to pointing at tsconfig.json.

Verified locally against a scratch project: 59/59 files analysed, no skips. Analysed lines go from 2,069 to 5,481, code smells from 2 to 14, security hotspots from 3 to 4, and technical debt from 21 to 85 minutes. The frontend had been hiding twelve code smells and a hotspot, which is part of why the React problems behind #60 and #62 had to be found by hand.

A standalone copy drifts, and drift here does not fail anything — it silently returns to skipping the frontend while reporting success. scripts/check-sonar-tsconfig.js compares the two and fails when they diverge in anything but moduleResolution, and runs before the scan so the scan is never what discovers it. Confirmed it catches drift by introducing some.

Moves scan settings into sonar-project.properties at the repo root so a local scan and the CI scan analyse the same thing, leaving only the host and token in secrets. Adds scripts/scan-local.sh, which runs the scanner in Docker because it needs Java 11+ and the dev machine has Java 8, and which defaults to a scratch project key: the server is Community edition with no branch analysis, so any scan overwrites the single main analysis of whichever key it is given.

Closes #67
2026-08-19 14:50:20 -05:00
bermudalamb 29e97f60d3 Merge pull request 'Feature/60 eslint' (#68) from feature/60-eslint into main
SonarQube Analysis / sonarqube (push) Successful in 3m30s
Tests / lint (push) Successful in 2m16s
Tests / backend-unit (push) Successful in 45s
Tests / frontend-e2e (push) Failing after 9m14s
Reviewed-on: #68
2026-08-19 14:42:17 -05:00
bermudalamb c058b3ed2e feat(ci): add ESLint to both workspaces with a defect-only CI gate (#60)
SonarQube Analysis / sonarqube (pull_request) Successful in 3m24s
Tests / lint (pull_request) Successful in 1m54s
Tests / backend-unit (pull_request) Successful in 43s
Tests / frontend-e2e (pull_request) Failing after 8m27s
TypeScript's strict mode checks types and nothing else, so nothing enforced the React hook rules, the SonarJS rules, or unhandled-promise detection. Adds a flat config per workspace, a lint script in each, and a lint job in tests.yml.

The rule selection is the substance of this change and is measured rather than guessed. A full-strength config reports 435 violations across 50 files, but 325 of those are the no-unsafe-* family from recommendedTypeChecked, every one downstream of pool.query() returning any rows and untyped fetch responses. Typing those boundaries is the whole of #65, so enabling the rules here would ship a linter whose output is three-quarters another issue's backlog — the reliable way to teach everyone to ignore lint output. This enables recommended plus the two type-aware rules that catch defects rather than describe type debt, which leaves 110 findings.

Both configs downgrade every preset to a warning and then list the error rules explicitly at the bottom, so the CI gate is readable in one place instead of inferred from four presets' defaults. Errors are no-floating-promises, no-misused-promises, rules-of-hooks, exhaustive-deps and jsx-a11y/alt-text; everything else warns. No --max-warnings flag is needed because ESLint already exits non-zero on errors and zero on warnings. no-misused-promises runs with checksVoidReturn.attributes false, since onClick={async () => ...} is idiomatic React and safe when the handler catches its own errors — at the default it flags every antd button in the admin screens, 25 of its 28 hits, and a rule that is 89% noise gets switched off within a week.

The 37 errors this surfaced were mostly not the mechanical fix they looked like. The plan assumed the 30 floating promises were fire-and-forget loaders that already handled their own failures, which was true of the one sampled when writing the design and false for most of the rest: Admin, Categories, Customers, Tags, Settings, Account and CustomerAuthContext all had no rejection handling at all, so `void` on them would have hidden real failures rather than annotated deliberate ones. Each of those loaders now catches and surfaces the failure before the call site voids it. The CustomerAuthContext one was a live bug — a rejected fetchMe left loading true forever, rendering as a permanent spinner instead of a signed-out page.

Admin's load became a useCallback so its effect can name it honestly rather than suppress the dependency, Categories' drop handler was split so the function antd receives returns void as its type says, and Cart's effect now names refreshCartContext, which is a useCallback with an empty dependency list and so cannot re-run it. The only disable added is in asyncRoute, where returning a promise where Express expects void is the entire point of the wrapper and the promise cannot reject.

Two of the issue's premises did not survive measurement, both recorded in the spec: exhaustive-deps flags 2 cases rather than the 10 inferred from empty dependency arrays, and the backend was already clean on the defect rules because #59 wrapped every async route.

Verified: lint, build, 78 unit, 134 integration and 83 e2e all pass in both workspaces, and the CI gate was confirmed to fail by introducing a deliberate violation rather than by assuming the job is wired correctly.

Closes #60
2026-08-19 14:08:37 -05:00
bermudalamb 3cb6a42fb3 docs: unwrap the ESLint design doc (#60)
Hard-wrapped at 100 columns, which assumes a viewer width neither Gitea's web UI nor the VS Code markdown preview has, so the wrap points landed mid-sentence for the person reading it. Paragraphs and list items are now one line each; tables, code fences and the header block keep their own breaks because those are structure rather than wrapped prose.

Refs #60
2026-08-19 13:44:12 -05:00
bermudalamb 8e859e58ec docs: design for adding ESLint to both workspaces (#60)
Records the measurement the design rests on — 435 violations from a full-strength config, of which 325 are the no-unsafe-* family — and the decision not to enable recommendedTypeChecked, since those 325 all trace to untyped pool.query rows and fetch responses, which is the whole of #65. Also records two of the issue's premises that measurement contradicts: exhaustive-deps flags 2 rather than the 10 the issue inferred from empty dependency arrays, and the backend is already clean on the defect rules because #59 wrapped every async route.

Refs #60
2026-08-19 13:35:33 -05:00
bermudalamb 259b3779c6 Merge pull request 'Feature/59 wrap async routes' (#66) from feature/59-wrap-async-routes into main
SonarQube Analysis / sonarqube (push) Successful in 2m54s
Tests / backend-unit (push) Successful in 45s
Tests / frontend-e2e (push) Failing after 9m26s
Reviewed-on: #66
2026-08-19 13:19:32 -05:00
bermudalamb e58c8b4009 docs: the asyncRoute guarantee now holds everywhere, and is enforced (#59)
SonarQube Analysis / sonarqube (pull_request) Successful in 2m33s
Tests / backend-unit (pull_request) Successful in 34s
Tests / frontend-e2e (pull_request) Failing after 9m31s
The note said the older route files were still unwrapped, which stopped being true with #59. Records where the guarantee now reaches — including the second `webhookRouter` and the globally-mounted `attachCustomer`, both of which an audit grepping for `router.` misses — and points at the test that enforces it, so the next person adding a route learns it from a failing build rather than from this file.
2026-08-19 13:13:01 -05:00
bermudalambandClaude Opus 5 1d7aba2d60 fix(backend): route every async handler through the error middleware (#59)
Express 4 does not forward a rejected promise from an async handler, so an unwrapped async route never responds at all — the request hangs until the client gives up, nothing reaches the error middleware, and monitoring sees an open connection rather than a 500. That silence is the shape of the 2026-08-17 incident, where an unhandled rejection left every item query hanging and the storefront rendered it as an empty shop. `asyncRoute` was written in response, but it was only applied to some routes: 30 handlers added afterwards were still bare, including register, login, the whole cart, and PayPal checkout.

Wraps all 30, plus two the issue's inventory missed. `attachCustomer` is a bare async middleware mounted globally in app.ts, so a rejection in its session lookup would hang every request in the application — including the 25 handlers that were already wrapped correctly, which meant the guarantee did not actually hold anywhere. The PayPal webhook registers on a second router named `webhookRouter`, so an audit grepping for `router.` walked straight past it.

Adds a unit test that scans the route sources and fails on any registration whose handler is not wrapped. A convention already half-forgotten once will be forgotten again, and enforcement is what the issue asked for; ESLint would be the better home for it but there is no ESLint in this repo yet (#60). The test walks parens rather than lines, so it also catches a handler whose `async` sits on its own line, and it matches any `*Router` name rather than just `router` — the two ways the existing bare handlers escaped notice. It is deleted along with `asyncRoute` if the project moves to Express 5, which forwards rejections natively.

No behaviour changes on the success path; the failure path turns a hung request into a logged 500.

Closes #59

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 13:12:25 -05:00
bermudalamb 07247caab4 Merge pull request 'Feature/50 auth modal routes' (#58) from feature/50-auth-modal-routes into main
SonarQube Analysis / sonarqube (push) Successful in 3m29s
Tests / backend-unit (push) Successful in 55s
Tests / frontend-e2e (push) Failing after 13m33s
Reviewed-on: #58
2026-08-18 17:27:52 -05:00
bermudalamb b5e3f8fc4a docs: record the shared auth form, its consent coupling, and the Node switch
SonarQube Analysis / sonarqube (pull_request) Successful in 4m31s
Tests / backend-unit (pull_request) Successful in 58s
Tests / frontend-e2e (pull_request) Failing after 19m7s
Notes that sign-in now has one implementation shared by the auth routes and the cart prompt, and where to change it. Records the marketing consent wording as a cross-boundary duplication in the same class as TAG_COLORS, but with a sharper failure mode: the server stores its copy verbatim so the record says what the customer saw, and a drifted label defeats that silently rather than loudly. An end-to-end test now spans the two sides.

Also records that switching the active Node version for a test run is fine provided it is switched back to 18.16.1 afterwards, and expands the modal-route entry to the full route list now that four auth routes use it.
2026-08-18 17:24:51 -05:00
bermudalamb 5ebb366074 feat(auth): open sign-in and registration as modals over the page behind (#50)
/login, /register and /forgot-password rendered bare cards with no site header. They linked to each other and nowhere else, so a customer who clicked Log in from the storefront and changed their mind had no way back except the browser's back button. All four auth routes are now modals over the page the customer was already on, reusing the backdrop-location arrangement from #51: a direct visit or a link from an email opens over the storefront, so closing always lands somewhere real. They remain real routes, because /reset-password links to /login and customers may have bookmarks.

Signing in or registering now returns the customer to the page behind, signed in, rather than moving them to /account. Someone who signs in while browsing wants to carry on browsing, and this is already how the cart and favorites prompts behave when they resume an interrupted action.

The larger half of this is removing the duplication. Signing in existed twice — as these routes and again inside the prompt shown when a signed-out visitor adds to the cart or favorites something — and the two had already drifted. There were three different wordings of the marketing consent in circulation: the register page's, a shorter one in the prompt, and the string the server actually stores. The server keeps that text verbatim so the consent record says what the customer saw, which none of the three did. Both callers now render one shared AuthForm whose checkbox is the exact string the server records, and a test asserts that wording so it cannot drift again silently.

Steps within the auth flow replace rather than push, so switching between tabs or stepping to password recovery leaves the whole detour as a single history entry and closing returns to where it started instead of walking back through every tab that was looked at.

The privacy policy link opens in a new tab: following it in place would discard a part-filled signup form, and /privacy still has no way back of its own until #52.

Test changes follow from the destination change rather than being incidental. Nineteen assertions across seven specs waited for /account after signing in; they now assert the header shows a signed-in customer, which is the condition actually being waited for. Modal submits are scoped to their dialog, because the storefront behind now offers a Log in button of its own and an unscoped locator matched both. Assertions that follow a server round-trip were given a realistic timeout — the 5s default is too tight for a bcrypt hash plus re-rendering the storefront behind the modal.

Verified with 83 end-to-end tests, all passing, and type checking clean. No backend changes.

Closes #50
2026-08-18 17:24:28 -05:00
bermudalamb 7e26acace3 Merge pull request '48 my account modal' (#57) from 48-my-account-modal into main
SonarQube Analysis / sonarqube (push) Successful in 2m31s
Tests / backend-unit (push) Successful in 39s
Tests / frontend-e2e (push) Failing after 7m35s
Reviewed-on: #57
2026-08-18 16:39:06 -05:00
bermudalamb 8ad11c6dd6 docs: record branch naming, the modal-route pattern, and two e2e timing traps
SonarQube Analysis / sonarqube (pull_request) Successful in 2m48s
Tests / backend-unit (pull_request) Successful in 33s
Tests / frontend-e2e (pull_request) Failing after 6m27s
Branches follow Conventional Branch — `<type>/<description>` with types feature, bugfix, hotfix, release, chore — carrying the issue number so the work is identifiable from `git branch`. Commits stay Conventional Commits with the number appended to the subject and a `Closes #N` line in the body. Spells out which half does what, because it is easy to assume the branch name links the work: Gitea builds the reference from a `#N` in a commit message or PR and never from the branch name.

Records the backdrop-location arrangement in `AppRoutes` as the pattern the sibling navigational dead-end issues should copy rather than each inventing their own.

Adds two testing lessons that cost real time here: the 5s default expect timeout is too tight for anything waiting on a bcrypt round-trip, and the local database's accumulated junk eventually stops being harmless clutter and starts producing flake that rotates between unrelated specs on every run.
2026-08-18 16:36:20 -05:00
bermudalamb 91485b6ac1 feat(account): open My Account as a modal over the page behind it (#51)
/account had no header and no links of any kind, so once a customer opened it the only way back to the storefront was the browser's back button or editing the URL.

It is now a modal rendered over whatever the customer was looking at, while staying a real route. Opening it from the header pushes /account and names the current page as the backdrop, so closing returns there with filters intact, and the browser's Back button does the same thing as the close control. Entering /account directly — a bookmark, the link in a verification email, or the redirect after registering — has no page behind it and falls back to the storefront, so closing always lands somewhere real. Keeping it a route means the URL still works: bookmarkable, shareable, and refreshable with the view still open, which the header link and the four post-authentication redirects already depend on.

Deleting an account now clears the session as well. Previously it removed the account server-side and navigated home without touching the auth context, so the header went on offering "My Account" for an account that no longer existed until the next reload. That was always wrong, but the modal makes it visible rather than merely stale, because the storefront is rendered behind and the wrong header is on screen throughout.

The modal body is capped and scrolls, and the order history table scrolls within itself, so the view survives a phone without pushing its own title and close control off-screen.

Also scopes the account switch locator in the favorites spec to the modal, since the storefront now renders behind the account view and has a theme switch of its own, and gives the post-registration wait a realistic timeout — it waits on a bcrypt round-trip rather than a render, and the 5s default was surfacing as a flake on whichever test lost the race under parallel load.

Closes #51
2026-08-18 16:36:20 -05:00
bermudalamb 0f04cd25cd Merge pull request 'Feature/favorites filter' (#55) from feature/favorites-filter into main
SonarQube Analysis / sonarqube (push) Successful in 2m47s
Tests / backend-unit (push) Successful in 44s
Tests / frontend-e2e (push) Failing after 14m43s
Reviewed-on: #55
2026-08-18 15:17:34 -05:00
bermudalamb 1249f9a311 docs: record the favorites filter and the Node version trap
SonarQube Analysis / sonarqube (pull_request) Successful in 3m8s
Tests / backend-unit (pull_request) Successful in 41s
Tests / frontend-e2e (pull_request) Failing after 8m36s
Notes that the default local Node (18.16.1) cannot run either the integration suite or Playwright, and that neither failure names the version as the cause — the integration one reads like a broken lru-cache dependency. Records the newer version's path so a single command can be run against it without switching what the user has active.

Adds two e2e lessons from this change: isVisible() does not wait, so guarding an optional dialog with it loses the race and leaves an antd modal open to intercept every later click; and under fullyParallel a test that mutates a shared fixture races its siblings.

Records that the storefront listing sold items is now load-bearing rather than merely tolerated, since the favorites filter deliberately shows sold favorites, and points at the favorites filter as the pattern for any future filter dimension that depends on who is asking.
2026-08-18 15:12:14 -05:00
bermudalamb d4e2abe743 feat: filter the storefront by favorited items (#35)
Adds favorites as another dimension of the existing storefront filter rather than a separate view, so it lives in the URL, shows up as a removable chip, and combines with category, tags, and price by AND like everything else. A customer can ask for "my favorites under $500 in Furniture" instead of only "my favorites".

Sold favorites are included. The storefront shows sold items everywhere else, and a favorite that has just sold is often exactly what the customer came back to look at after being emailed about it in #34. Hiding them would make items disappear from a list the customer curated themselves. Anyone wanting only what they can still buy can combine the toggle with the status filter.

Which customer "my favorites" means comes from the session, never from the query string, so a hand-edited URL cannot name someone else's favorites. A signed-out visitor sees the toggle and gets the same inline register/login prompt the heart button and Add to Cart already use; signing in resolves the gate and the filter applies on its own. A bookmarked favorites link whose session has expired says so rather than rendering an empty grid, which would tell the visitor they have no favorites instead of that we do not know who they are. The API answers 401 for the same reason, and the admin inventory refuses the filter outright rather than ignoring it.

The shared SQL builder now requires callers to say whose favorites they mean, even when that is nobody, and throws instead of dropping the clause — a future caller that forgets the guard fails loudly rather than quietly returning the whole catalogue.

Verified with 59 unit tests, 134 backend integration tests, and 70 end-to-end tests, all passing, with type checking clean on both sides.
2026-08-18 15:10:45 -05:00
371 changed files with 64522 additions and 2711 deletions
+54 -6
View File
@@ -75,7 +75,7 @@ A storefront for one-of-a-kind items (quantity 1 per item — once sold, it's go
8. **Categories are manual metadata, deliberately not a rule engine.** Issue #23's wording ("rules that dictate how the app automatically organizes items") was explicitly resolved with the author to mean an admin-built tree with a per-item assignment — there is no predicate evaluation anywhere, and adding one would be a new feature, not a completion of this one. Categories are single-valued per item on purpose, to stay distinct from multi-valued tags. **Tag filtering is AND** ("must have all"), not OR.
9. **Item filtering happens in SQL, and malformed filter params return `400`** rather than being ignored — a broken filter link should show itself instead of quietly returning the whole catalogue. Parsing/SQL-building live in `backend/src/itemFilters.ts`, apart from the route so they're unit-testable with no database. Category filtering matches a node *and all descendants* via a recursive CTE; a materialized path column was rejected because reparenting would then have to rewrite every descendant's path, which is a standing drift risk.
10. **Migrations run automatically at container start**, via `CMD ["sh", "-c", "node migrate.js up && node dist/server.js"]` in the `Dockerfile`. Deployed code can therefore never be ahead of the schema. This replaced a separate manual `docker exec ... node migrate.js up` step that was easy to forget — and forgetting it took the storefront down (see the incident note below). `migrate.js` waits for Postgres to accept connections before running (the NAS routinely brings the DB container up slower than the app), and exits non-zero on failure, so `&&` stops a bad migration from serving against a half-migrated schema.
11. **Every async route is wrapped in `asyncRoute()` and the app mounts error middleware.** Express 4 does *not* forward a rejected promise from an async handler, and with no error middleware such a request **never responds at all** — it hangs until the client gives up. A hung request is indistinguishable from an empty result in the UI. New async routes must use the wrapper (`backend/src/asyncRoute.ts`); it becomes unnecessary only if the project moves to Express 5. Note the older route files predate this and are still unwrapped.
11. **Every async route is wrapped in `asyncRoute()` and the app mounts error middleware.** Express 4 does *not* forward a rejected promise from an async handler, and with no error middleware such a request **never responds at all** — it hangs until the client gives up. A hung request is indistinguishable from an empty result in the UI. New async routes must use the wrapper (`backend/src/asyncRoute.ts`); it becomes unnecessary only if the project moves to Express 5. As of #59 this holds everywhere — every route file, the second `webhookRouter` in `cartCheckout.ts`, and the globally-mounted `attachCustomer` middleware — and `backend/tests/unit/routesAreWrapped.test.ts` fails the build if a new handler is added bare, so it is enforced rather than remembered. Delete that test along with `asyncRoute` on any Express 5 upgrade.
12. **Item SELECTs aggregate with scalar subqueries, never `LEFT JOIN` + `GROUP BY`** — see `backend/src/itemSelect.ts`, the single source for both the public and admin shapes. Joining two one-to-many relations in one query multiplies their rows together: with the old shape, an item with 2 images and 3 tags repeated every image three times. This bit once already when tags were added. If a third one-to-many relation is ever attached to items, extend `itemSelect.ts` the same way rather than adding a join.
## Frontend gotchas
@@ -93,7 +93,7 @@ A storefront for one-of-a-kind items (quantity 1 per item — once sold, it's go
| # | Step | Gate before moving on |
| --- | --- | --- |
| 1 | Implement on a branch, verify locally | Unit + integration + e2e pass; `tsc --noEmit` and `npm run build` clean |
| 1 | Implement on a branch, verify locally | `npm run lint`, unit, integration and e2e all pass; `tsc --noEmit` and `npm run build` clean |
| 2 | Commit and push | `git branch -a --contains <sha>` lists the pushed branch |
| 3 | Open a PR and merge to `main` | The merge actually contains the expected commits |
| 4 | **Build and deploy to QA, and review it in a browser** | The change does what it claims, behind authentik |
@@ -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. 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.
- **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.
- **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.
@@ -226,13 +226,54 @@ sudo docker exec -it redefined-designs-db-syn psql -U redefined -d redefined -c
- **Design specs live in `docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md`** and are committed before implementation starts.
- **`.superpowers/` stays gitignored, but design artifacts inside it must be lifted out before they are lost.** That directory is scratch state belonging to the brainstorming tool and contains a session token, PID files, and absolute local paths — none of which belong in the repo. The mockups it holds *are* worth keeping, so copy them into `docs/superpowers/specs/<date>-<topic>-mockups/` and wrap them as standalone pages (they are served as fragments inside a tool-provided frame, so they need its style tokens and `toggleSelect` helper inlined to open on their own). Keep the rejected options, not just the chosen one — the value is in the comparison.
## Linting
`npm run lint` in each workspace (`backend/eslint.config.mjs`, `frontend/eslint.config.mjs`, flat config, `.mjs` because neither package is `"type": "module"`). Added in #60, with a `lint` job in `tests.yml`.
The severity split is deliberate and is the whole design: every preset is downgraded to a warning, and only the rules that catch real defects are errors — `no-floating-promises`, `no-misused-promises`, `rules-of-hooks`, `exhaustive-deps`, `jsx-a11y/alt-text`. They are listed explicitly at the bottom of each config, so the CI gate is readable in one place. No `--max-warnings` flag is needed: ESLint exits non-zero on errors and zero on warnings by itself. Expect roughly 10 warnings on the backend and 34 on the frontend — that is the intended state, not a backlog someone forgot.
**`recommendedTypeChecked` is deliberately not enabled.** Its `no-unsafe-*` family reports ~325 violations, all of them downstream of `pool.query()` returning `any` rows and untyped `fetch(...).json()`. That is #65's work, and turning the rules on before that work is done buries the ~44 warnings worth reading under a backlog belonging to another issue. #65 enables them as it types those boundaries.
`@typescript-eslint/no-misused-promises` runs with `checksVoidReturn: { attributes: false }`, because `onClick={async () => ...}` is idiomatic React and safe when the handler catches its own errors — left at the default the rule flags every antd button in the admin screens.
## SonarQube
Server is **SonarQube 9.9.8 LTA, Community edition**, at the URL in `SONARQUBE_URL`. Scan settings live in `sonar-project.properties` at the repo root, not as inline `-D` args, so a local scan and the CI scan analyse the same thing; only the host and token come from Gitea secrets.
**Community edition has no branch analysis.** Every scan overwrites the single `main` analysis of whatever project key it is given, so scanning a feature branch under the real key silently replaces CI's picture of main with your working tree. `scripts/scan-local.sh` therefore defaults to the scratch key `redefined-designs-local`; pass `redefined-designs` explicitly to publish for real. The scanner runs in Docker because it needs Java 11+ and the dev machine's Java is 8.
**`frontend/tsconfig.sonar.json` is load-bearing, and its failure mode is silent.** SonarQube 9.9's bundled TypeScript predates 5.0 and rejects `"moduleResolution": "bundler"`, which `frontend/tsconfig.json` needs for Vite. Without the shim the frontend program fails to build, all 34 frontend files are skipped, and **the scan still exits `EXECUTION SUCCESS`** — the state #67 found, where the gate had been reporting on a third of the codebase while looking complete. The shim cannot use `extends`: the old compiler validates the base file while reading it, so the error fires before any override applies. `scripts/check-sonar-tsconfig.js` runs before the scan in CI and fails if the copy drifts from the real tsconfig in anything but `moduleResolution`. Delete the shim, the check, and the `sonar.typescript.tsconfigPaths` line together once the server is new enough to parse `bundler`.
Take a green SonarQube job as weak evidence. It exits success on a partially-failed analysis, so the number worth checking after a scan is `ncloc` — if it drops sharply, something is being skipped.
## Testing
- **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
`nvm4w` has both **18.16.1 (active by default)** and **24.13.1** installed, and the active one is too old for two of the three suites:
- Integration tests fail in `globalSetup` with `(0 , U.tracingChannel) is not a function` — a transitive `lru-cache` needs `diagnostics_channel.tracingChannel`, added in Node 20.2.
- Playwright refuses outright: "Playwright requires Node.js 20 or higher."
Neither failure mentions the Node version as the cause, and the first one reads like a broken dependency. Rather than switching the user's active version, prepend the newer one for the single command:
```bash
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.
Unit tests and `tsc` run fine on 18, so a green `npm test` says nothing about whether the other two suites can even start.
### E2E constraints — the local database is never reset
Integration tests truncate between cases (`resetDb()` in `tests/integration/setup/testDb.ts`**add any new table to that TRUNCATE list**, or state leaks between tests). Playwright specs have no such hook and run against whatever is already there, which locally accumulates across every previous run. Consequences worth knowing before writing a new spec:
@@ -241,6 +282,10 @@ Integration tests truncate between cases (`resetDb()` in `tests/integration/setu
- **`beforeAll` runs once per worker, but a worker can be handed the same spec file in more than one batch**, re-running it against the module-cached suffix. `filters.spec.ts` therefore checks whether its fixtures already exist and returns early — without that, the second pass 409s on category names and duplicates every item, which then breaks strict-mode locators.
- **Tables paginate.** With dozens of accumulated rows a freshly created record often isn't on page 1; `admin-taxonomy.spec.ts` confirms tag creation through the API rather than hunting for the row.
- **Clicking a submit button only dispatches the request.** Wait for the resulting confirmation (`'Tag added'`) before querying the API, or the read races the write. This produced a one-in-four flake until fixed.
- **`isVisible()` does not wait.** It answers about *this instant*, so guarding an optional dialog with `if (await x.isVisible())` loses the race whenever the dialog is still on its way — and an antd modal left open then intercepts every later click, which surfaces as an unrelated element "not found" thirty seconds later. If the dialog is deterministic, click it unconditionally and let the locator auto-wait; only use `isVisible()` when it genuinely may never appear, and even then give it something to wait on first.
- **The 5s default `expect` timeout is too tight for anything waiting on a round-trip.** Registration is a bcrypt hash — about half a second unloaded, and well past 5s when the suite's workers all register at once. The failure surfaces on whichever test lost the race, so it looks like an unrelated flake that moves between runs. Give such assertions an explicit generous timeout; they are asserting *that* the server answered, not how fast.
- **The local database's accumulated junk eventually shows up as flake, not just clutter.** At ~450 items and ~250 customers the storefront render and the parallel bcrypt load together push these round-trips past their timeouts, and the repeated runs also trip the password-reset rate limiter. When failures start rotating between unrelated specs on each run, reset the database before debugging any of them.
- **`fullyParallel: true` means a test that mutates a shared fixture races every other test in the file.** A spec that marked an item sold broke the sibling tests reading that same item. Give any test that changes an item's state its own fixture.
## Known gaps / natural next steps
@@ -251,9 +296,10 @@ Integration tests truncate between cases (`resetDb()` in `tests/integration/setu
- **Daily cart reminder emails** fire via `node-cron` inside the app process at 9am container-local time — if the container restarts frequently, reminders could silently stop firing with no alerting on that failure mode.
- **Old single-item checkout route files** (`backend/src/routes/paypal.ts`, `backend/src/routes/demo.ts`) are dead code, unmounted but never deleted — safe cleanup opportunity.
- **`backend/src/routes/shippingAddresses.ts` USPS OAuth token format** was implemented against the current (2026) USPS Addresses API docs at time of writing, using a JSON-body `client_credentials` request — if USPS changes their API again, this is the first place to check.
- **The marketing consent wording is duplicated** between `MARKETING_CONSENT_TEXT` in `backend/src/utils.ts` and the constant of the same name in `frontend/src/customer/AuthForm.tsx`. The server stores its copy verbatim against the customer's consent record, so the whole point is that the record says what the customer actually saw — a label that drifts from the stored string quietly defeats that. This is not hypothetical: before the form was shared there were three wordings in play (the register page's, a shorter one in the cart prompt, and the stored string) and none matched. An e2e test now asserts the rendered label equals the stored wording, which is the only thing spanning the two sides.
- **`TAG_COLORS` is duplicated** between `backend/src/utils.ts` and `frontend/src/admin/Tags.tsx`. The server validates against its copy, so editing one alone makes the admin colour picker offer values that get rejected with a `400`. There's no shared module between backend and frontend in this repo to put it in.
- **The local e2e database accumulates junk indefinitely** — every Playwright run seeds categories, tags, and items that are never cleaned up, so the admin tables and filter drawer fill with `Furniture fmsxb…` noise over time. Harmless, but `npm run db:test:down` + `db:test:up` + `migrate:up` resets it when the clutter starts getting in the way. CI is unaffected (fresh service container per run).
- **The storefront still lists sold items** and the price-range bounds are computed across all items regardless of status. Unchanged by the filters work — deliberately left as-is, but worth revisiting if sold stock ever outnumbers available stock.
- **The storefront still lists sold items** and the price-range bounds are computed across all items regardless of status. Deliberately left as-is, and now load-bearing: the favorites filter (#35) shows sold favorites on purpose, since an item that just sold is often what the customer came back to look at after the #34 email. Anyone wanting only purchasable stock combines the favorites toggle with the status filter. Worth revisiting if sold stock ever outnumbers available stock — but changing the default would change what a favorites view means.
- **No admin-side filtering.** The admin inventory table shows category and tag columns but can't filter or search on them; with 100+ items that will start to hurt.
## Where to look first for common tasks
@@ -263,8 +309,10 @@ Integration tests truncate between cases (`resetDb()` in `tests/integration/setu
- Change admin-configurable settings → `admin_settings` table + `backend/src/routes/adminSettings.ts` + `frontend/src/admin/Settings.tsx`
- Add a new async route → wrap the handler in `asyncRoute()` from `backend/src/asyncRoute.ts`, or a failure will hang the request instead of returning 500
- Change what an item row returns → `backend/src/itemSelect.ts` (one place, used by both the public and admin routes)
- Change how a customer route is framed (modal vs page) → `frontend/src/main.tsx`. `AppRoutes` renders the route table against a *backdrop* location rather than the real one: `/account` is a modal over the page named in `location.state.background`, falling back to the storefront when there is none (a bookmark, an email link, a post-registration redirect). This is the pattern to copy for the sibling dead-end issues (#49, #50) — it keeps the URL real and linkable while making sure closing always lands somewhere. The link that opens it must pass `state={{ background: location }}`, or closing goes to the fallback instead of where the customer was.
- Change storefront filtering → `backend/src/itemFilters.ts` (parsing + SQL), `backend/src/routes/filters.ts` (`/api/filters`, the drawer's single fetch), `frontend/src/filters.ts` (state, URL round-trip, tree building), `frontend/src/components/FilterDrawer.tsx`
- Change how a customer route is framed (modal vs page) → `frontend/src/main.tsx`. `AppRoutes` renders the route table against a *backdrop* location rather than the real one: everything in `MODAL_ROUTES` (`/account`, `/login`, `/register`, `/forgot-password`, `/reset-password`) is a modal over the page named in `location.state.background`, falling back to the storefront when there is none (a bookmark, an email link). The link that opens one must pass `state={{ background: location }}`, or closing goes to the fallback instead of where the customer was; steps *within* a flow navigate with `replace` so the whole detour stays one history entry. This is the pattern to copy for the remaining dead-end pages (#52) — it came out of #51 — it keeps the URL real and linkable while making sure closing always lands somewhere. The link that opens it must pass `state={{ background: location }}`, or closing goes to the fallback instead of where the customer was.
- Change sign-in or registration → `frontend/src/customer/AuthForm.tsx`, which is the single implementation. It is rendered both by `AuthRouteModal` (the `/login` and `/register` routes) and by `AuthPromptModal` (the prompt shown when a signed-out visitor adds to the cart, favorites, or filters by favorites). Changing one caller's copy or validation without the other is the drift this deliberately removed.
- Change storefront filtering → `backend/src/itemFilters.ts` (parsing + SQL), `backend/src/routes/filters.ts` (`/api/filters`, the drawer's single fetch), `frontend/src/filters.ts` (state, URL round-trip, tree building), `frontend/src/components/FilterDrawer.tsx`, `frontend/src/components/ActiveFilterChips.tsx`
- Add a filter dimension that depends on who is asking → follow the favorites filter (#35). The identity comes from `req.customerId` (`attachCustomer` runs globally, so it is available on the public `/api/items` too) and is passed into `buildItemFilterSql` as an explicit argument — never parsed from the query string, or a hand-edited URL could name another customer. Each route decides what to do when it cannot satisfy the filter: the storefront answers 401, the admin inventory 400, and the builder throws rather than silently dropping the clause and returning everything.
- Change category/tag management → `backend/src/routes/adminCategories.ts`, `backend/src/routes/adminTags.ts`, `frontend/src/admin/Categories.tsx`, `frontend/src/admin/Tags.tsx`
- Change tag colours → `TAG_COLORS` + `tagColorFor()` in `backend/src/utils.ts`; the list is **duplicated** in `frontend/src/admin/Tags.tsx` for the override picker, and the server rejects anything outside it, so the two must be changed together
- NPM/authentik/DSM reverse-proxy config for this app → not in this repo; documented in the broader homelab's Claude Project knowledge base, not here
+53
View File
@@ -0,0 +1,53 @@
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
+46
View File
@@ -0,0 +1,46 @@
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
+264 -6
View File
@@ -1,5 +1,17 @@
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]
@@ -10,6 +22,55 @@ 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
@@ -37,14 +98,211 @@ 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
- 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.
- 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 }}
with:
args: >
-Dsonar.projectKey=redefined-designs
-Dsonar.login=${{ secrets.SONAR_TOKEN }}
-Dsonar.sources=backend/src,frontend/src
-Dsonar.exclusions=**/node_modules/**,**/dist/**
# 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
-137
View File
@@ -1,137 +0,0 @@
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:
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
+14
View File
@@ -10,3 +10,17 @@ 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/
+22 -1
View File
@@ -10,7 +10,28 @@ WORKDIR /app/backend
COPY backend/package.json ./
RUN npm install
COPY backend/ ./
RUN npm run build
# 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
FROM node:20-bookworm-slim
WORKDIR /app
+86 -1
View File
@@ -24,6 +24,45 @@ 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
.\scriptsun-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.
@@ -82,6 +121,27 @@ 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
@@ -136,7 +196,7 @@ npm run db:test:down # when finished
### Frontend Playwright e2e tests
Needs the backend running against a database with the schema loaded (steps 14 above), 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 14 above, or `.\scripts\start-local.ps1`), since these tests drive real registration/login/purchase flows through a live API.
```powershell
cd frontend
@@ -179,10 +239,35 @@ 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 \
+39
View File
@@ -0,0 +1,39 @@
# 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
+139
View File
@@ -0,0 +1,139 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import sonarjs from 'eslint-plugin-sonarjs';
import globals from 'globals';
// Named `.mjs` because this package is CommonJS — `eslint.config.js` would be
// parsed as CJS and the imports above would fail.
//
// Policy: every preset is downgraded to advisory, and the rules that actually
// fail the build are listed once at the bottom. That way the CI gate is
// readable in one place rather than inferred from four presets' defaults.
// The reasoning behind the split, and the measurements it rests on, are in
// docs/superpowers/specs/2026-08-19-eslint-design.md.
/**
* Rewrites a preset's enabled rules to `warn`, preserving each rule's options.
* Rules the preset explicitly turned off stay off — a preset that disables a
* rule means it, and flipping those to `warn` turns the whole of SonarJS's
* opt-in catalogue (file headers, naming conventions) into daily noise.
*/
const advisory = (config) => ({
...config,
rules: Object.fromEntries(
Object.entries(config.rules ?? {}).map(([rule, level]) => {
const severity = Array.isArray(level) ? level[0] : level;
if (severity === 'off' || severity === 0) return [rule, level];
return [rule, Array.isArray(level) ? ['warn', ...level.slice(1)] : 'warn'];
})
),
});
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'
]
},
...[js.configs.recommended, ...tseslint.configs.recommended, sonarjs.configs.recommended].map(
advisory
),
{
files: ['src/**/*.ts'],
languageOptions: {
globals: globals.node,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// The two rules this repo has actually been bitten by. #59 is the whole
// argument: an async handler whose rejection nothing forwards produces no
// response at all, and the request hangs rather than failing visibly.
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': [
'error',
{ 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',
},
}
);
+9
View File
@@ -2,6 +2,15 @@ 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
+10 -1
View File
@@ -1,5 +1,14 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['<rootDir>/tests/unit/**/*.test.ts']
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']
};
@@ -0,0 +1,18 @@
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';
`);
};
@@ -0,0 +1,62 @@
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;
`);
};
@@ -0,0 +1,80 @@
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;
`);
};
@@ -0,0 +1,35 @@
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;
`);
};
@@ -0,0 +1,17 @@
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;`);
};
@@ -0,0 +1,39 @@
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;
`);
};
@@ -0,0 +1,97 @@
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;
`);
};
@@ -0,0 +1,23 @@
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;`);
};
@@ -0,0 +1,54 @@
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;`);
};
@@ -0,0 +1,77 @@
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;
`);
};
+2855 -2
View File
File diff suppressed because it is too large Load Diff
+28 -3
View File
@@ -3,33 +3,53 @@
"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",
"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"
"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"
},
"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"
"pg": "^8.12.0",
"sharp": "^0.35.4",
"zod": "^4.5.4"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.7",
"@types/express": "^4.17.21",
@@ -40,10 +60,15 @@
"@types/nodemailer": "^6.4.15",
"@types/pg": "^8.11.6",
"@types/supertest": "^6.0.2",
"eslint": "^9.39.5",
"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",
"typescript": "^5.5.4"
"typescript": "^5.5.4",
"typescript-eslint": "^8.67.0"
}
}
+201
View File
@@ -0,0 +1,201 @@
/**
* 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;
});
+193
View File
@@ -0,0 +1,193 @@
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)]
);
}
}
+82 -8
View File
@@ -6,14 +6,30 @@ 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
@@ -24,8 +40,11 @@ app.set('trust proxy', 1);
app.use('/webhooks/paypal', express.json(), cartCheckoutWebhookRouter);
app.use(express.json());
app.use(cookieParser());
app.use(attachCustomer);
app.use('/uploads', express.static(process.env.UPLOADS_DIR || '/app/uploads'));
// Wrapped like any route: attachCustomer awaits a session lookup, and mounted
// 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.get('/api/config', (_req, res) => {
const clientId = process.env.PAYPAL_CLIENT_ID;
@@ -33,21 +52,76 @@ app.get('/api/config', (_req, res) => {
res.json({
paypalClientId: isPlaceholder ? null : clientId,
demoMode: process.env.DEMO_MODE !== 'false',
currency: process.env.SITE_CURRENCY || 'USD'
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
});
});
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);
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);
// 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/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') {
+5
View File
@@ -10,6 +10,11 @@ import { Request, Response, NextFunction, RequestHandler } from 'express';
export function asyncRoute(
handler: (req: Request, res: Response, next: NextFunction) => unknown
): RequestHandler {
// Returning a promise where Express expects void is the entire point of this
// wrapper, and the promise cannot reject — `.catch(next)` is the last link in
// the chain. Express ignores the return value; the unit tests await it to
// observe that next() was called.
// eslint-disable-next-line @typescript-eslint/no-misused-promises
return (req, res, next) => {
// Promise.resolve also captures a synchronous throw, so both failure modes
// reach the same place.
+179
View File
@@ -0,0 +1,179 @@
/**
* 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());
}
+157
View File
@@ -0,0 +1,157 @@
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;
}
+51
View File
@@ -0,0 +1,51 @@
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));
}
+58
View File
@@ -0,0 +1,58 @@
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));
}
+80
View File
@@ -0,0 +1,80 @@
# 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.
+277
View File
@@ -0,0 +1,277 @@
/**
* 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;
}
+52
View File
@@ -1,4 +1,6 @@
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,
@@ -7,3 +9,53 @@ 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;
}
+358
View File
@@ -0,0 +1,358 @@
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 };
}
+301
View File
@@ -0,0 +1,301 @@
/**
* 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
]
};
}
+33 -24
View File
@@ -1,5 +1,8 @@
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
@@ -9,6 +12,10 @@ 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;
}
@@ -24,7 +31,7 @@ export async function collectFavoriteRecipients(
if (!itemIds.length) return [];
const { rows } = await pool.query<FavoriteRecipient>(
`SELECT c.email, i.name AS item_name
`SELECT c.email, c.first_name, c.last_name, 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
@@ -42,15 +49,28 @@ 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.
function send(recipients: FavoriteRecipient[], subject: (name: string) => string, body: (name: string) => string): void {
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();
for (const recipient of recipients) {
sendMail(recipient.email, subject(recipient.item_name), body(recipient.item_name))
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)
.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
@@ -60,27 +80,16 @@ const FOOTER = `<p>You are receiving this because you asked to be told when a fa
// longer available reads as a bug.
export async function notifyFavoritersOfSale(itemIds: number[], buyerId: number | null): Promise<void> {
const recipients = await collectFavoriteRecipients(itemIds, buyerId);
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}`
);
await send(recipients, 'favoriteSold');
}
// 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.
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}`
);
// 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');
}
+118
View File
@@ -0,0 +1,118 @@
/**
* 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 !== ''
};
}
+76
View File
@@ -0,0 +1,76 @@
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 };
}
+106
View File
@@ -0,0 +1,106 @@
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();
}
}
+254
View File
@@ -0,0 +1,254 @@
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)
};
}
+53
View File
@@ -0,0 +1,53 @@
/**
* 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;
}
+191
View File
@@ -0,0 +1,191 @@
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;
}
}
+81
View File
@@ -0,0 +1,81 @@
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);
}
}
+286
View File
@@ -0,0 +1,286 @@
/**
* 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
};
+74
View File
@@ -0,0 +1,74 @@
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>`
);
}
+75
View File
@@ -0,0 +1,75 @@
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}`;
}
+56
View File
@@ -0,0 +1,56 @@
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;
}
+78
View File
@@ -0,0 +1,78 @@
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
]);
}
}
+300
View File
@@ -0,0 +1,300 @@
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 };
}
+68
View File
@@ -0,0 +1,68 @@
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 };
}
+77
View File
@@ -0,0 +1,77 @@
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)
};
}
+76
View File
@@ -0,0 +1,76 @@
/**
* 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;
}
+47
View File
@@ -0,0 +1,47 @@
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>;
+204
View File
@@ -0,0 +1,204 @@
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 };
}
+57
View File
@@ -0,0 +1,57 @@
/**
* 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);
}
+70
View File
@@ -0,0 +1,70 @@
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);
}
+34
View File
@@ -0,0 +1,34 @@
/**
* 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';
}
+117
View File
@@ -0,0 +1,117 @@
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.12.3 s against
* 1420 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;
}
+238 -64
View File
@@ -2,33 +2,76 @@
// 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 {
categoryId: number | null;
// 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[];
tagIds: number[];
minPriceCents: number | null;
maxPriceCents: number | null;
status: ItemStatus | 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;
// 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[] = ['available', 'reserved', 'sold'];
const ITEM_STATUSES: readonly string[] = ['pending', 'available', 'reserved', 'sold'];
export interface BuiltFilter {
clauses: string[];
params: unknown[];
}
// 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'];
// 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
// '10.5' are caller mistakes worth surfacing rather than silently coercing.
const NON_NEGATIVE_INTEGER = /^\d+$/;
// Accepts both spellings because these URLs get hand-edited and shared, but
// nothing else: '?favorites=yes' is a mistake worth reporting rather than
// treating as either on or off.
const TRUE_VALUES: readonly string[] = ['1', 'true'];
const FALSE_VALUES: readonly string[] = ['0', 'false'];
function singleValue(value: unknown, name: string): string | null {
if (value === undefined || value === null) {
return null;
@@ -71,26 +114,112 @@ function parsePrice(value: unknown, name: string): number | null {
return parseNonNegativeInteger(raw, name);
}
export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
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);
}
/**
* 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 minPriceCents = parsePrice(query.min_price, 'min_price');
const maxPriceCents = parsePrice(query.max_price, 'max_price');
@@ -98,71 +227,116 @@ export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
throw new FilterError('min_price may not exceed max_price');
}
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;
}
const status = parseStatus(query.status);
const favoritesOnly = parseFavoritesOnly(query.favorites);
return { categoryId, tagIds, minPriceCents, maxPriceCents, status };
return { categoryIds, tagIds, minPriceCents, maxPriceCents, status, favoritesOnly };
}
// Returns WHERE fragments plus their parameters, with placeholders numbered
// from `startIndex` so the caller can splice these in after its own params.
export function buildItemFilterSql(filters: ItemFilters, startIndex: number): BuiltFilter {
const clauses: string[] = [];
const params: unknown[] = [];
let next = startIndex;
// 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.
//
// `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
// reject a favorites filter they cannot satisfy, so reaching the throw below is
// 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,
filters: ItemFilters,
favoritesCustomerId: number | null
): Expression<SqlBool>[] {
const clauses: Expression<SqlBool>[] = [];
if (filters.categoryId !== null) {
params.push(filters.categoryId);
if (filters.categoryIds.length) {
// Selecting a category means "and everything filed beneath it", so walk the
// tree down from the chosen node. A recursive CTE keeps the tree
// tree down from each chosen node. A recursive CTE keeps the tree
// un-denormalized: reparenting stays a single UPDATE with no stored paths
// to rewrite.
clauses.push(`i.category_id IN (
//
// 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 (
WITH RECURSIVE subtree AS (
SELECT id FROM categories WHERE id = $${next}
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
)`);
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(
`(SELECT COUNT(*) FROM item_tags it
WHERE it.item_id = i.id AND it.tag_id = ANY($${next}::int[])) = $${next + 1}`
);
next += 2;
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}`);
}
if (filters.minPriceCents !== null) {
params.push(filters.minPriceCents);
clauses.push(`i.price_cents >= $${next}`);
next++;
clauses.push(eb('i.price_cents', '>=', filters.minPriceCents));
}
if (filters.maxPriceCents !== null) {
params.push(filters.maxPriceCents);
clauses.push(`i.price_cents <= $${next}`);
next++;
clauses.push(eb('i.price_cents', '<=', filters.maxPriceCents));
}
if (filters.status !== null) {
params.push(filters.status);
clauses.push(`i.status = $${next}`);
next++;
// `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`
);
}
return { clauses, params };
if (filters.favoritesOnly) {
if (favoritesCustomerId === null) {
throw new Error('favorites filter requires a customer id');
}
// 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)
)
);
}
return clauses;
}
+190 -34
View File
@@ -1,43 +1,199 @@
// Shared item SELECT shapes for the public and admin routes.
// Shared item query shapes for the public and admin routes, and the row types
// they return.
//
// Images and tags are pulled as scalar subqueries rather than LEFT JOIN +
// 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 +
// 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.
// aggregate independent and drop the GROUP BY entirely. `jsonArrayFrom` emits
// `coalesce(json_agg(agg), '[]')`, which is what these hand-wrote before.
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`;
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 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`;
/**
* 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 FROM_CLAUSE = `
FROM items i
LEFT JOIN categories c ON c.id = i.category_id`;
const itemDb = db as unknown as Kysely<ItemDB>;
// 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 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'
>;
export const ADMIN_ITEM_SELECT = `
SELECT i.*,
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;
}
+118 -3
View File
@@ -1,5 +1,19 @@
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),
@@ -7,18 +21,119 @@ const transporter = nodemailer.createTransport({
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD
}
},
connectionTimeout: 5000,
greetingTimeout: 5000,
socketTimeout: 5000
});
export async function sendMail(to: string, subject: string, html: string): Promise<void> {
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> {
if (!process.env.SMTP_USER || !process.env.SMTP_PASSWORD) {
console.warn(`SMTP not configured — skipping email to ${to}: "${subject}"`);
return;
return 'skipped-unconfigured';
}
// 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';
}
+61
View File
@@ -0,0 +1,61 @@
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();
}
+13 -2
View File
@@ -2,6 +2,11 @@ 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;
@@ -9,6 +14,11 @@ 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();
@@ -16,14 +26,15 @@ 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(
const { rows } = await pool.query<SessionOwnerRow>(
`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]
);
if (rows.length) req.customerId = rows[0].customer_id;
const [session] = rows;
if (session) req.customerId = session.customer_id;
next();
}
+48
View File
@@ -0,0 +1,48 @@
/**
* 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);
}
+111
View File
@@ -0,0 +1,111 @@
/**
* 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]
};
}
+51
View File
@@ -0,0 +1,51 @@
/**
* 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'
};
}
+78
View File
@@ -0,0 +1,78 @@
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);
}
+177 -3
View File
@@ -1,4 +1,4 @@
import rateLimit from 'express-rate-limit';
import rateLimit, { ipKeyGenerator, MemoryStore } from 'express-rate-limit';
import { Request } from 'express';
// First rate limiting in the codebase. The password-reset endpoints need it
@@ -20,9 +20,30 @@ 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.
function keyByCallerAndEmail(req: Request): string {
//
// 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 {
const email = typeof req.body?.email === 'string' ? req.body.email.toLowerCase().trim() : '';
return `${req.ip}:${email}`;
return `${ipKeyGenerator(req.ip ?? '')}:${email}`;
}
export const passwordResetRequestLimiter = rateLimit({
@@ -33,3 +54,156 @@ 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' }
});
+311 -116
View File
@@ -1,62 +1,34 @@
import { Router, Request, Response, NextFunction } from 'express';
import multer from 'multer';
import path from 'path';
import { randomUUID } from 'crypto';
import { Router, Request, Response } from 'express';
import { PoolClient } from 'pg';
import { pool } from '../db';
import { ADMIN_ITEM_SELECT } from '../itemSelect';
import { pool, requireRow } from '../db';
import { adminItemQuery, AdminItemRow, ItemRecord } from '../itemSelect';
import { ItemStatus } from '../types';
import { asyncRoute } from '../asyncRoute';
import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters';
import { tagColorFor } from '../utils';
import { parseItemFilters, itemFilterExpressions, FilterError } from '../itemFilters';
import { readId, 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();
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/app/uploads';
/** The next image slot, from a COALESCE'd MAX so it is never null. */
interface MaxSortRow {
max_sort: number;
}
// 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;
/** Just the status column, read before deciding whether a transition is legal. */
interface ItemStatusRow {
status: ItemStatus;
}
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
@@ -114,6 +86,32 @@ 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
@@ -128,45 +126,52 @@ router.get('/items', asyncRoute(async (req: Request, res: Response) => {
throw err;
}
const { clauses, params } = buildItemFilterSql(filters, 1);
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const { rows } = await pool.query(`${ADMIN_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params);
// Favorites belong to a customer, and the admin inventory view is not
// browsing as one. Refused rather than ignored so the mistake is visible.
if (filters.favoritesOnly) {
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();
res.json(rows);
}));
router.post('/items', uploadImages, async (req: Request, res: Response) => {
router.post('/items', uploadImages, asyncRoute(async (req: Request, res: Response) => {
const { name, description, price } = req.body;
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 parsed = readOptionalItemFields(req.body);
if (!parsed.ok) return res.status(400).json({ error: parsed.error });
const { categoryId, tagNames } = parsed;
const files = (req.files as Express.Multer.File[]) || [];
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
const { rows } = await client.query<ItemRecord>(
`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 = 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]
);
}
const item = requireRow(rows, 'the item INSERT');
await insertItemImages(client, item.id, files, 0);
if (tagNames) {
await setItemTags(client, item.id, await resolveTagIds(client, tagNames));
}
await client.query('COMMIT');
const { rows: full } = await pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [item.id]);
res.json(full[0]);
// 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'));
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
@@ -174,19 +179,17 @@ router.post('/items', uploadImages, async (req: Request, res: Response) => {
} finally {
client.release();
}
});
}));
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' });
router.put('/items/:id', uploadImages, async (req: Request, res: Response) => {
const { name, description, price } = req.body;
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 parsed = readOptionalItemFields(req.body);
if (!parsed.ok) return res.status(400).json({ error: parsed.error });
const { categoryId, tagNames } = parsed;
const files = (req.files as Express.Multer.File[]) || [];
const client = await pool.connect();
@@ -194,32 +197,42 @@ router.put('/items/:id', uploadImages, async (req: Request, res: Response) => {
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), req.params.id]
[name, description, Math.round(parseFloat(price) * 100), itemId]
);
// 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, req.params.id]);
await client.query(`UPDATE items SET category_id=$1 WHERE id=$2`, [categoryId, itemId]);
}
if (tagNames) {
await setItemTags(client, Number(req.params.id), await resolveTagIds(client, tagNames));
await setItemTags(client, itemId, await resolveTagIds(client, tagNames));
}
if (files.length) {
const { rows: existing } = await client.query(
const { rows: existing } = await client.query<MaxSortRow>(
`SELECT COALESCE(MAX(sort_order), -1) AS max_sort FROM item_images WHERE item_id = $1`,
[req.params.id]
[itemId]
);
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++]
);
}
// 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);
}
await client.query('COMMIT');
const { rows: full } = await pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [req.params.id]);
res.json(full[0]);
// 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);
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
@@ -227,10 +240,17 @@ router.put('/items/:id', uploadImages, async (req: Request, res: Response) => {
} finally {
client.release();
}
});
}));
router.delete('/items/:id', asyncRoute(async (req: Request, res: Response) => {
const itemId = Number(req.params.id);
// 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' });
// 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
@@ -241,33 +261,208 @@ 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.
notifyFavoritersOfRemoval(recipients);
await notifyFavoritersOfRemoval(recipients);
res.status(204).end();
}));
router.delete('/items/:id/images/:imageId', async (req: Request, res: Response) => {
await pool.query(`DELETE FROM item_images WHERE id = $1 AND item_id = $2`, [req.params.imageId, req.params.id]);
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]);
res.status(204).end();
});
}));
router.post('/items/:id/mark-sold', async (req: Request, res: Response) => {
const { rows } = await pool.query(
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>(
`UPDATE items SET status='sold', sold_at=now() WHERE id=$1 RETURNING *`,
[req.params.id]
[itemId]
);
// No buyer to exclude: an admin marking an item sold has no associated
// customer, so everyone watching it hears about it.
await notifyFavoritersOfSale([Number(req.params.id)], null);
res.json(rows[0]);
});
const sold = rows[0];
if (!sold) return res.status(404).json({ error: 'not found' });
router.post('/items/:id/mark-available', async (req: Request, res: Response) => {
const { rows } = await pool.query(
// 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]);
}));
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>(
`UPDATE items SET status='available', sold_at=NULL, reserved_until=NULL, paypal_order_id=NULL
WHERE id=$1 RETURNING *`,
[req.params.id]
[itemId]
);
res.json(rows[0]);
});
const available = rows[0];
if (!available) return res.status(404).json({ error: 'not found' });
res.json(available);
}));
/**
* 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;
+165 -61
View File
@@ -1,18 +1,65 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { sql } from 'kysely';
import { db, requireRow } 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';
// 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 = `
/**
* 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`
WITH RECURSIVE subtree AS (
SELECT id FROM categories WHERE id = $1
SELECT id FROM categories WHERE id = ${id}
UNION ALL
SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id
)`;
@@ -33,17 +80,33 @@ function readParentId(value: unknown): number | null | undefined {
}
async function parentExists(id: number): Promise<boolean> {
const { rows } = await pool.query(`SELECT 1 FROM categories WHERE id = $1`, [id]);
return rows.length > 0;
const row = await db
.selectFrom('categories')
.select('id')
.where('id', '=', id)
.executeTakeFirst();
return row !== undefined;
}
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
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)`
);
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();
res.json(rows);
}));
@@ -65,28 +128,72 @@ 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 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 });
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 });
} catch (err) {
if ((err as { code?: string }).code === UNIQUE_VIOLATION) {
if (isUniqueViolation(err)) {
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 existing = await pool.query(`SELECT id, name, parent_id, sort_order FROM categories WHERE id = $1`, [id]);
if (!existing.rows.length) {
const current = await db
.selectFrom('categories')
.select(CATEGORY_COLUMNS)
.where('id', '=', id)
.executeTakeFirst();
if (!current) {
return res.status(404).json({ error: 'not found' });
}
let name = existing.rows[0].name;
let name = current.name;
if (req.body.name !== undefined) {
const parsed = readName(req.body.name);
if (!parsed) {
@@ -95,42 +202,27 @@ router.put('/:id', asyncRoute(async (req: Request, res: Response) => {
name = parsed;
}
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 resolved = await resolveParentId(req.body.parent_id, id, current.parent_id);
if ('error' in resolved) {
return res.status(400).json({ error: resolved.error });
}
const parent = resolved.parent;
const sortOrder = Number.isSafeInteger(req.body.sort_order)
? req.body.sort_order
: existing.rows[0].sort_order;
: current.sort_order;
try {
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]);
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'));
} catch (err) {
if ((err as { code?: string }).code === UNIQUE_VIOLATION) {
if (isUniqueViolation(err)) {
return res.status(409).json({ error: 'a category with that name already exists here' });
}
throw err;
@@ -139,22 +231,34 @@ 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 { rows: subtree } = await pool.query(`${SUBTREE_CTE} SELECT id FROM subtree`, [id]);
if (!subtree.length) {
const subtree = await sql<{ id: number }>`
${subtreeOf(id)} SELECT id FROM subtree
`.execute(db);
if (!subtree.rows.length) {
return res.status(404).json({ error: 'not found' });
}
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]
);
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();
// The FK cascade takes the descendants; items fall back to NULL rather than
// being deleted along with their category.
await pool.query(`DELETE FROM categories WHERE id = $1`, [id]);
await db.deleteFrom('categories').where('id', '=', id).execute();
res.json({ deleted_categories: ids.length, uncategorized_items: affected[0].n });
res.json({
deleted_categories: ids.length,
uncategorized_items: requireRow(affected, 'the affected-items COUNT').n
});
}));
export default router;
+27
View File
@@ -0,0 +1,27 @@
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;
+260 -11
View File
@@ -1,13 +1,94 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { pool, requireRow } 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(`
const { rows } = await pool.query<CustomerListRow>(`
SELECT
c.id, c.email, c.name, c.email_verified, c.marketing_consent, c.created_at, c.disabled_at,
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,
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,
@@ -38,7 +119,7 @@ router.post('/:id/disable', asyncRoute(async (req: Request, res: Response) => {
try {
await client.query('BEGIN');
const { rows } = await client.query(
const { rows } = await client.query<IdRow>(
`UPDATE customers SET disabled_at = now() WHERE id = $1 RETURNING id`,
[req.params.id]
);
@@ -55,7 +136,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(
const { rows: held } = await client.query<HeldItemRow>(
`DELETE FROM cart_items ci
USING carts ca
WHERE ci.cart_id = ca.id AND ca.customer_id = $1
@@ -84,7 +165,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(
const { rows } = await pool.query<IdRow>(
`UPDATE customers SET disabled_at = NULL WHERE id = $1 RETURNING id`,
[req.params.id]
);
@@ -93,7 +174,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(
const { rows } = await pool.query<ReservedItemRow>(
`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
@@ -112,7 +193,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(
const { rows } = await client.query<HeldItemRow>(
`DELETE FROM cart_items ci
USING carts ca
WHERE ci.cart_id = ca.id AND ca.customer_id = $1 AND ci.item_id = $2
@@ -139,15 +220,183 @@ 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 customers 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(
`SELECT id, email, name, email_verified, marketing_consent, marketing_consent_at, created_at
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
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(
const { rows: orderRows } = await pool.query<AdminOrderRow>(
`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
+189
View File
@@ -0,0 +1,189 @@
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;
+392
View File
@@ -0,0 +1,392 @@
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.12.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;
+115 -20
View File
@@ -1,29 +1,124 @@
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('/', async (_req: Request, res: Response) => {
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')
});
});
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
res.json(await getSettings());
}));
router.put('/', async (req: Request, res: Response) => {
const { cartExpiryHours } = req.body;
const hours = parseFloat(cartExpiryHours);
/**
* 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 res.status(400).json({ error: 'cartExpiryHours must be a positive number' });
return { ok: false, error: `${name} must be a positive number` };
}
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 });
});
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;
}
}
await updateSettings(values);
res.json(await getSettings());
}));
export default router;
+14 -3
View File
@@ -3,6 +3,17 @@ 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';
@@ -22,7 +33,7 @@ function readColor(value: unknown): string | null | undefined {
}
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
const { rows } = await pool.query(
const { rows } = await pool.query<TagListRow>(
`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
@@ -44,7 +55,7 @@ router.post('/', asyncRoute(async (req: Request, res: Response) => {
const color = requestedColor ?? tagColorFor(name);
try {
const { rows } = await pool.query(
const { rows } = await pool.query<TagRow>(
`INSERT INTO tags (name, color) VALUES ($1, $2) RETURNING id, name, color`,
[name, color]
);
@@ -83,7 +94,7 @@ router.put('/:id', asyncRoute(async (req: Request, res: Response) => {
}
try {
const { rows } = await pool.query(
const { rows } = await pool.query<TagRow>(
`UPDATE tags SET name = $1, color = $2 WHERE id = $3 RETURNING id, name, color`,
[name, color, id]
);
+205
View File
@@ -0,0 +1,205 @@
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;
+23
View File
@@ -0,0 +1,23 @@
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;
+59 -22
View File
@@ -1,12 +1,41 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { pool, requireRow } from '../db';
import { asyncRoute } from '../asyncRoute';
import { requireCustomer } from '../middleware/customerAuth';
import { getSettings } from '../adminSettings';
import { ItemStatus, ItemImage } from '../types';
const router = Router();
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;
/**
* 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;
}
const CART_ITEM_SELECT = `
@@ -26,19 +55,23 @@ const CART_ITEM_SELECT = `
ORDER BY ci.added_at DESC
`;
router.get('/', requireCustomer, async (req: Request, res: Response) => {
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]);
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]);
res.json({ items });
});
}));
router.post('/items/:itemId', requireCustomer, async (req: Request, res: Response) => {
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(`SELECT * FROM items WHERE id = $1 FOR UPDATE`, [itemId]);
const { rows: itemRows } = await client.query<LockedItemRow>(`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') {
@@ -46,21 +79,22 @@ router.post('/items/:itemId', requireCustomer, async (req: Request, res: Respons
return res.status(409).json({ error: 'item is no longer available' });
}
let { rows: cartRows } = await client.query(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]);
const { rows: cartRows } = await client.query<IdRow>(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]);
const [existingCart] = cartRows;
let cartId: number;
if (cartRows.length) {
cartId = cartRows[0].id;
if (existingCart) {
cartId = existingCart.id;
await client.query(`UPDATE carts SET updated_at = now() WHERE id = $1`, [cartId]);
} else {
const { rows: newCart } = await client.query(
const { rows: newCart } = await client.query<IdRow>(
`INSERT INTO carts (customer_id) VALUES ($1) RETURNING id`,
[req.customerId]
);
cartId = newCart[0].id;
cartId = requireRow(newCart, 'the cart INSERT').id;
}
const hours = await getCartExpiryHours();
const expiresAt = new Date(Date.now() + hours * 60 * 60 * 1000);
const { cartExpiryHours } = await getSettings();
const expiresAt = new Date(Date.now() + cartExpiryHours * 60 * 60 * 1000);
await client.query(
`INSERT INTO cart_items (cart_id, item_id, expires_at) VALUES ($1, $2, $3)`,
[cartId, itemId, expiresAt]
@@ -75,14 +109,17 @@ router.post('/items/:itemId', requireCustomer, async (req: Request, res: Respons
} finally {
client.release();
}
});
}));
router.delete('/items/:itemId', requireCustomer, async (req: Request, res: Response) => {
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(
const { rows } = await client.query<RemovedItemRow>(
`DELETE FROM cart_items ci
USING carts c
WHERE ci.cart_id = c.id AND c.customer_id = $1 AND ci.item_id = $2
@@ -103,6 +140,6 @@ router.delete('/items/:itemId', requireCustomer, async (req: Request, res: Respo
} finally {
client.release();
}
});
}));
export default router;
+73 -29
View File
@@ -1,5 +1,7 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import type { PoolClient } from 'pg';
import { pool, requireRow } from '../db';
import { asyncRoute } from '../asyncRoute';
import { requireCustomer } from '../middleware/customerAuth';
import { notifyFavoritersOfSale } from '../favoriteAlerts';
@@ -23,6 +25,35 @@ 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;
@@ -37,11 +68,12 @@ 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: 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(
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>(
`SELECT i.id, i.name, i.price_cents
FROM cart_items ci
JOIN items i ON i.id = ci.item_id
@@ -50,7 +82,7 @@ async function loadLockedCart(client: any, customerId: number): Promise<LockedCa
[cartId]
);
if (!items.length) return { cartId, items: [], totalCents: 0 };
const totalCents = items.reduce((sum: number, it: CartItem) => sum + it.price_cents, 0);
const totalCents = items.reduce((sum, it) => sum + it.price_cents, 0);
return { cartId, items, totalCents };
}
@@ -63,13 +95,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: any,
client: PoolClient,
customerId: number,
shippingAddressId: number,
processor: string,
processorOrderId: string | null
): Promise<OpenedCheckout> {
const { rows: addrRows } = await client.query(
const { rows: addrRows } = await client.query<IdRow>(
`SELECT id FROM shipping_addresses WHERE id = $1 AND customer_id = $2`,
[shippingAddressId, customerId]
);
@@ -78,12 +110,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(
const { rows: checkoutRows } = await client.query<IdRow>(
`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 = checkoutRows[0].id;
const checkoutId = requireRow(checkoutRows, 'the checkout INSERT').id;
for (const it of cart.items) {
await client.query(
`INSERT INTO checkout_items (checkout_id, item_id, price_cents) VALUES ($1, $2, $3)`,
@@ -93,7 +125,7 @@ async function openCheckout(
return { ok: true, checkoutId, cart };
}
router.post('/paypal/create', requireCustomer, async (req: Request, res: Response) => {
router.post('/paypal/create', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { shippingAddressId } = req.body;
if (!shippingAddressId) return res.status(400).json({ error: 'shippingAddressId is required' });
@@ -139,17 +171,17 @@ router.post('/paypal/create', requireCustomer, async (req: Request, res: Respons
} finally {
client.release();
}
});
}));
// 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: any, checkoutId: number, processor: string, processorOrderId: string | null, rawEvent: unknown): Promise<{ itemIds: number[]; buyerId: number | null }> {
const { rows: checkoutItems } = await client.query(
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>(
`SELECT item_id, price_cents FROM checkout_items WHERE checkout_id = $1`,
[checkoutId]
);
const { rows: checkoutRows } = await client.query(`SELECT customer_id FROM checkouts WHERE id = $1`, [checkoutId]);
const { rows: checkoutRows } = await client.query<CheckoutOwnerRow>(`SELECT customer_id FROM checkouts WHERE id = $1`, [checkoutId]);
const customerId = checkoutRows[0]?.customer_id;
for (const ci of checkoutItems) {
@@ -164,12 +196,12 @@ async function completeCheckout(client: any, checkoutId: number, processor: stri
await client.query(`UPDATE checkouts SET status = 'completed', raw_event = $1 WHERE id = $2`, [rawEvent, checkoutId]);
return {
itemIds: checkoutItems.map((ci: { item_id: number }) => ci.item_id),
itemIds: checkoutItems.map((ci) => ci.item_id),
buyerId: customerId ?? null
};
}
router.post('/paypal/capture', requireCustomer, async (req: Request, res: Response) => {
router.post('/paypal/capture', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { orderID } = req.body;
const client = await pool.connect();
try {
@@ -183,14 +215,14 @@ router.post('/paypal/capture', requireCustomer, async (req: Request, res: Respon
return res.status(502).json({ error: 'capture failed', detail: capture });
}
const { rows } = await pool.query(
const { rows } = await pool.query<IdRow>(
`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, rows[0].id, 'paypal', orderID, capture);
const sold = await completeCheckout(client, requireRow(rows, 'the checkout lookup').id, 'paypal', orderID, capture);
await client.query('COMMIT');
await notifyFavoritersOfSale(sold.itemIds, sold.buyerId);
res.json({ status: 'completed' });
@@ -201,9 +233,9 @@ router.post('/paypal/capture', requireCustomer, async (req: Request, res: Respon
} finally {
client.release();
}
});
}));
router.post('/demo/purchase', requireCustomer, async (req: Request, res: Response) => {
router.post('/demo/purchase', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
if (process.env.DEMO_MODE === 'false') return res.status(403).json({ error: 'demo mode disabled' });
const { shippingAddressId } = req.body;
if (!shippingAddressId) return res.status(400).json({ error: 'shippingAddressId is required' });
@@ -214,9 +246,20 @@ router.post('/demo/purchase', requireCustomer, async (req: Request, res: Respons
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 }); }
const sold = await completeCheckout(client, opened.checkoutId, 'demo', null, { demo: true });
await completeCheckout(client, opened.checkoutId, 'demo', null, { demo: true });
await client.query('COMMIT');
await notifyFavoritersOfSale(sold.itemIds, sold.buyerId);
// 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.
res.json({ status: 'completed' });
} catch (err) {
await client.query('ROLLBACK');
@@ -225,9 +268,9 @@ router.post('/demo/purchase', requireCustomer, async (req: Request, res: Respons
} finally {
client.release();
}
});
}));
webhookRouter.post('/', async (req: Request, res: Response) => {
webhookRouter.post('/', asyncRoute(async (req: Request, res: Response) => {
try {
const token = await getAccessToken();
const verifyResp = await fetch(`${PAYPAL_BASE}/v1/notifications/verify-webhook-signature`, {
@@ -250,8 +293,9 @@ webhookRouter.post('/', 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(`SELECT status FROM checkouts WHERE id = $1`, [checkoutId]);
if (rows.length && rows[0].status !== 'completed') {
const { rows } = await pool.query<CheckoutStatusRow>(`SELECT status FROM checkouts WHERE id = $1`, [checkoutId]);
const [checkout] = rows;
if (checkout && checkout.status !== 'completed') {
const client = await pool.connect();
try {
await client.query('BEGIN');
@@ -272,6 +316,6 @@ webhookRouter.post('/', async (req: Request, res: Response) => {
console.error('webhook error', err);
res.status(500).end();
}
});
}));
export { router, webhookRouter };
+81
View File
@@ -0,0 +1,81 @@
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;
+500 -106
View File
@@ -1,103 +1,280 @@
import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import { PASSWORD_HASH_ROUNDS, passwordMatches } from '../passwordHashing';
import crypto from 'node:crypto';
import { pool } from '../db';
import { pool, requireRow } from '../db';
import { requireCustomer } from '../middleware/customerAuth';
import { sendMail } from '../mailer';
import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
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 { FAVORITE_ALERTS_CONSENT_TEXT } from '../favoriteAlerts';
import { asyncRoute } from '../asyncRoute';
import { passwordResetRequestLimiter } from '../rateLimit';
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';
const router = Router();
const SESSION_DAYS = 30;
// 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.
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
});
// 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;
}
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;
/**
* 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;
}
function publicCustomer(c: any) {
/** 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) {
return {
id: c.id,
email: c.email,
name: c.name,
first_name: c.first_name,
last_name: c.last_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', async (req: Request, res: Response) => {
const { email, password, name, marketingConsent } = req.body;
router.post('/register', asyncRoute(async (req: Request, res: Response) => {
const { email, password, firstName, lastName, marketingConsent, analyticsConsent: analyticsConsentGiven } = 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(`SELECT id FROM customers WHERE email = $1`, [normalizedEmail]);
const { rows: existing } = await pool.query<IdRow>(`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, 12);
const passwordHash = await bcrypt.hash(password, PASSWORD_HASH_ROUNDS);
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(
`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 *`,
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 *`,
[
normalizedEmail, passwordHash, name || null,
normalizedEmail, passwordHash, first, last,
consent, consent ? new Date() : null, consent ? MARKETING_CONSENT_TEXT : null,
analytics, analytics ? new Date() : null, analytics ? ANALYTICS_CONSENT_TEXT : null,
unsubscribeToken
]
);
const customer = rows[0];
const customer = requireRow(rows, 'the registration INSERT');
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));
await issueVerificationEmail(customer.id, customer.email, customer.first_name, customer.last_name);
const sessionToken = await createSession(customer.id);
setSessionCookie(res, sessionToken);
res.json(publicCustomer(customer));
});
}));
router.post('/verify-email', async (req: Request, res: Response) => {
router.post('/verify-email', asyncRoute(async (req: Request, res: Response) => {
const { token } = req.body;
const { rows } = await pool.query(
const { rows } = await pool.query<CustomerTokenRow>(
`SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'verify_email' AND expires_at > now()`,
[token]
);
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]);
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]);
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.
@@ -110,7 +287,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(`SELECT * FROM customers WHERE email = $1`, [email]);
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE email = $1`, [email]);
const customer = rows[0];
if (customer && !customer.disabled_at) {
@@ -118,20 +295,23 @@ 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() + RESET_TOKEN_TTL_MS)]
[token, customer.id, new Date(Date.now() + passwordResetHours * 60 * 60 * 1000)]
);
const resetUrl = `${process.env.PUBLIC_URL}/reset-password?token=${token}`;
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));
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));
}
res.json({ status: 'sent' });
@@ -151,21 +331,27 @@ 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(
const { rows } = await pool.query<CustomerTokenRow>(
`SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'password_reset' AND expires_at > now()`,
[token]
);
if (!rows.length) return res.status(400).json({ error: 'invalid or expired token' });
const customerId = rows[0].customer_id;
const [resetToken] = rows;
if (!resetToken) return res.status(400).json({ error: 'invalid or expired token' });
const customerId = resetToken.customer_id;
// A token issued before the account was disabled would otherwise still mint a
// fresh session.
const { rows: owner } = await pool.query(`SELECT disabled_at FROM customers WHERE id = $1`, [customerId]);
const { rows: owner } = await pool.query<DisabledAtRow>(`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), 12);
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 client = await pool.connect();
try {
@@ -182,6 +368,37 @@ 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');
@@ -190,17 +407,25 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
client.release();
}
const { rows: fresh } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [customerId]);
const { rows: fresh } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [customerId]);
const sessionToken = await createSession(customerId);
setSessionCookie(res, sessionToken);
res.json(publicCustomer(fresh[0]));
// 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
});
}));
router.post('/login', async (req: Request, res: Response) => {
router.post('/login', asyncRoute(async (req: Request, res: Response) => {
const { email, password } = req.body;
const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [String(email || '').toLowerCase().trim()]);
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE email = $1`, [String(email || '').toLowerCase().trim()]);
const customer = rows[0];
if (!customer || !(await bcrypt.compare(password || '', customer.password_hash))) {
if (!customer || !(await passwordMatches(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
@@ -211,17 +436,17 @@ router.post('/login', async (req: Request, res: Response) => {
const sessionToken = await createSession(customer.id);
setSessionCookie(res, sessionToken);
res.json(publicCustomer(customer));
});
}));
router.post('/logout', async (req: Request, res: Response) => {
router.post('/logout', asyncRoute(async (req: Request, res: Response) => {
const token = req.cookies?.rd_session;
if (token) await pool.query(`DELETE FROM customer_sessions WHERE token = $1`, [token]);
res.clearCookie('rd_session');
res.status(204).end();
});
}));
router.get('/me/favorites', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query(
const { rows } = await pool.query<FavoriteRow>(
`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
@@ -232,7 +457,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(`SELECT id FROM items WHERE id = $1`, [req.params.itemId]);
const { rows: item } = await pool.query<IdRow>(`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.
@@ -254,7 +479,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(
const { rows } = await pool.query<CustomerRecord>(
`UPDATE customers
SET favorite_alerts = $1,
favorite_alerts_at = $2,
@@ -262,74 +487,243 @@ 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(rows[0]));
res.json(publicCustomer(requireRow(rows, 'the favorite-alerts UPDATE')));
}));
router.get('/me', requireCustomer, async (req: Request, res: Response) => {
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]));
});
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));
}));
router.put('/me', requireCustomer, async (req: Request, res: Response) => {
const { name } = req.body;
const { rows } = await pool.query(
`UPDATE customers SET name = $1 WHERE id = $2 RETURNING *`,
[name || null, req.customerId]
// 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]
);
res.json(publicCustomer(rows[0]));
});
res.json(publicCustomer(requireRow(rows, 'the name UPDATE')));
}));
router.post('/change-password', requireCustomer, async (req: Request, res: Response) => {
router.post('/change-password', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { currentPassword, newPassword } = req.body;
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(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
const customer = rows[0];
if (!(await bcrypt.compare(currentPassword || '', customer.password_hash))) {
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))) {
return res.status(401).json({ error: 'current password is incorrect' });
}
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, async (req: Request, res: Response) => {
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')));
}));
router.post('/me/consent', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const consent = !!req.body.marketingConsent;
await pool.query(
`UPDATE customers SET marketing_consent = $1, marketing_consent_at = now(), marketing_consent_text = $2 WHERE id = $3`,
[consent, consent ? MARKETING_CONSENT_TEXT : 'Withdrew consent via account settings', req.customerId]
);
res.status(204).end();
});
}));
router.get('/me/orders', requireCustomer, async (req: Request, res: Response) => {
const { rows } = await pool.query(
/**
* 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>(
`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`,
[req.customerId]
);
res.json(rows);
});
}));
router.get('/me/export', requireCustomer, async (req: Request, res: Response) => {
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]);
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]);
res.setHeader('Content-Disposition', 'attachment; filename="my-data.json"');
res.json({
customer: publicCustomer(customerRows[0]),
customer: publicCustomer(requireRow(customerRows, 'the signed-in customer')),
orders: orderRows,
exported_at: new Date().toISOString()
});
});
}));
router.delete('/me', requireCustomer, async (req: Request, res: Response) => {
router.delete('/me', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
await pool.query(`UPDATE orders SET customer_id = NULL WHERE customer_id = $1`, [req.customerId]);
await pool.query(`DELETE FROM customers WHERE id = $1`, [req.customerId]);
res.clearCookie('rd_session');
res.status(204).end();
});
}));
export default router;
+10 -2
View File
@@ -12,18 +12,26 @@ 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(it.item_id)::int AS item_count
`SELECT t.id, t.name, t.color, COUNT(i.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`
FROM items WHERE status <> 'pending'`
)
]);
+303
View File
@@ -0,0 +1,303 @@
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;
+242
View File
@@ -0,0 +1,242 @@
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;
+139
View File
@@ -0,0 +1,139 @@
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;
+90 -7
View File
@@ -1,8 +1,29 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
import { PUBLIC_ITEM_SELECT } from '../itemSelect';
import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters';
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');
}
const router = Router();
@@ -19,14 +40,76 @@ router.get('/', asyncRoute(async (req: Request, res: Response) => {
throw err;
}
const { clauses, params } = buildItemFilterSql(filters, 1);
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params);
// 401 rather than an empty list: a signed-out visitor asking for "my
// favorites" has no favorites to be empty of, and answering with [] would
// render as "no items match these filters" — a plausible-looking lie. The
// storefront prompts for sign-in instead of sending this, so reaching here
// means a bookmarked link outlived its session.
if (filters.favoritesOnly && !req.customerId) {
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();
res.json(rows);
}));
router.get('/:id', asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} WHERE i.id = $1`, [req.params.id]);
// 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();
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
}));
+202
View File
@@ -0,0 +1,202 @@
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;
+292
View File
@@ -0,0 +1,292 @@
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;
+10 -5
View File
@@ -1,11 +1,16 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { pool, requireRow } from '../db';
import { asyncRoute } from '../asyncRoute';
interface IdRow {
id: number;
}
const router = Router();
router.get('/unsubscribe', async (req: Request, res: Response) => {
router.get('/unsubscribe', asyncRoute(async (req: Request, res: Response) => {
const token = req.query.token as string;
const { rows } = await pool.query(`SELECT id FROM customers WHERE unsubscribe_token = $1`, [token]);
const { rows } = await pool.query<IdRow>(`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;
@@ -13,9 +18,9 @@ router.get('/unsubscribe', 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`,
[rows[0].id]
[requireRow(rows, 'the unsubscribe-token lookup').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>');
});
}));
export default router;
+41 -14
View File
@@ -1,19 +1,42 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
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, async (req: Request, res: Response) => {
const { rows } = await pool.query(
router.get('/', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query<ShippingAddressRow>(
`SELECT * FROM shipping_addresses WHERE customer_id = $1 ORDER BY is_default DESC, created_at DESC`,
[req.customerId]
);
res.json(rows);
});
}));
router.post('/', requireCustomer, async (req: Request, res: Response) => {
router.post('/', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { fullName, addressLine1, addressLine2, city, state, postalCode, country, isDefault } = req.body;
if (!fullName || !addressLine1 || !city || !state || !postalCode) {
return res.status(400).json({ error: 'fullName, addressLine1, city, state, and postalCode are required' });
@@ -30,7 +53,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(
const { rows } = await client.query<ShippingAddressRow>(
`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 *`,
@@ -48,9 +71,9 @@ if ((country || 'US') === 'US') {
} finally {
client.release();
}
});
}));
router.put('/:id', requireCustomer, async (req: Request, res: Response) => {
router.put('/:id', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { fullName, addressLine1, addressLine2, city, state, postalCode, country, isDefault } = req.body;
const client = await pool.connect();
try {
@@ -58,7 +81,7 @@ router.put('/:id', requireCustomer, async (req: Request, res: Response) => {
if (isDefault) {
await client.query(`UPDATE shipping_addresses SET is_default = false WHERE customer_id = $1`, [req.customerId]);
}
const { rows } = await client.query(
const { rows } = await client.query<ShippingAddressRow>(
`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
@@ -75,19 +98,19 @@ router.put('/:id', requireCustomer, async (req: Request, res: Response) => {
} finally {
client.release();
}
});
}));
router.delete('/:id', requireCustomer, async (req: Request, res: Response) => {
router.delete('/:id', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
await pool.query(`DELETE FROM shipping_addresses WHERE id = $1 AND customer_id = $2`, [req.params.id, req.customerId]);
res.status(204).end();
});
}));
router.post('/:id/set-default', requireCustomer, async (req: Request, res: Response) => {
router.post('/:id/set-default', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const client = await pool.connect();
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(
const { rows } = await client.query<ShippingAddressRow>(
`UPDATE shipping_addresses SET is_default = true WHERE id = $1 AND customer_id = $2 RETURNING *`,
[req.params.id, req.customerId]
);
@@ -95,10 +118,14 @@ router.post('/:id/set-default', requireCustomer, async (req: Request, res: Respo
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();
}
});
}));
export default router;
+94 -20
View File
@@ -2,11 +2,16 @@ 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, every 5 minutes.
setInterval(async () => {
// Release cart holds whose expiry has passed.
async function sweepExpiredCarts(): Promise<void> {
try {
const { rows } = await pool.query(
const { rows } = await pool.query<ExpiredCartItemRow>(
`DELETE FROM cart_items WHERE expires_at < now() RETURNING item_id`
);
for (const row of rows) {
@@ -15,13 +20,14 @@ setInterval(async () => {
} catch (err) {
console.error('cart expiry sweep failed:', (err as Error).message);
}
}, 5 * 60 * 1000);
}
// Daily cart reminder emails at 9am server time, for customers who opted into marketing email.
cron.schedule('0 9 * * *', async () => {
// Remind customers who opted into marketing email about items still held in
// their cart.
async function sendCartReminders(): Promise<void> {
try {
const { rows } = await pool.query(`
SELECT c.email, c.name, i.name AS item_name, ci.expires_at, ci.id AS cart_item_id
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
FROM cart_items ci
JOIN carts ca ON ca.id = ci.cart_id
JOIN customers c ON c.id = ca.customer_id
@@ -31,29 +37,97 @@ cron.schedule('0 9 * * *', async () => {
AND ci.expires_at > now()
`);
const byEmail = new Map<string, { name: string | null; items: { name: string; expiresAt: Date; cartItemId: number }[] }>();
const byEmail = new Map<string, { firstName: string | null; lastName: string | null; items: { name: string; expiresAt: Date; cartItemId: number }[] }>();
for (const row of rows) {
if (!byEmail.has(row.email)) byEmail.set(row.email, { name: row.name, items: [] });
if (!byEmail.has(row.email)) byEmail.set(row.email, { firstName: row.first_name, lastName: row.last_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) {
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>`
);
// 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 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]);
}
} catch (err) {
console.error('daily cart reminder job failed:', (err as Error).message);
}
});
}
/** 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
// Node terminates the process on those by default, so a database blip during
// the sweep would take the container down with it.
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}`));
+19 -1
View File
@@ -1,4 +1,16 @@
export type ItemStatus = 'available' | 'reserved' | 'sold';
/**
* 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 interface ItemImage {
id: number;
@@ -6,6 +18,12 @@ export interface ItemImage {
sort_order: number;
}
export interface ItemTag {
id: number;
name: string;
color: string;
}
export interface Item {
id: number;
name: string;
+43
View File
@@ -0,0 +1,43 @@
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');
}
+106
View File
@@ -0,0 +1,106 @@
/**
* 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;
}
}
+104
View File
@@ -0,0 +1,104 @@
/**
* 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;
}
+92 -2
View File
@@ -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 = [
export const TAG_COLORS: [string, ...string[]] = [
'magenta', 'red', 'volcano', 'orange', 'gold', 'lime',
'green', 'cyan', 'blue', 'geekblue', 'purple'
];
@@ -61,8 +61,98 @@ export function tagColorFor(name: string): string {
for (let i = 0; i < normalized.length; i++) {
hash = ((hash << 5) + hash + normalized.charCodeAt(i)) | 0;
}
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length];
// 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];
}
/**
* 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;
}
+91
View File
@@ -0,0 +1,91 @@
/**
* 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}`);
}

Some files were not shown because too many files have changed in this diff Show More