Commit Graph
546 Commits
Author SHA1 Message Date
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