Compare commits
59
Commits
271078ff20
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0361ef35d0 | ||
|
|
f5a29127fb | ||
|
|
c9dccfe4a2 | ||
|
|
903a1d8b76 | ||
|
|
838749df64 | ||
|
|
3958bda489 | ||
|
|
b82b989cc8 | ||
|
|
c2ee0b0c5d | ||
|
|
843c51dd91 | ||
|
|
2c6ac4d2be | ||
|
|
0014a8db8a | ||
|
|
dcb3c7c91b | ||
|
|
c5014d5342 | ||
|
|
44df0bd2d9 | ||
|
|
8a5f6eb08c | ||
|
|
25078417e5 | ||
|
|
79a2b606ea | ||
|
|
022ab8edcf | ||
|
|
9177b54dea | ||
|
|
87f07baaff | ||
|
|
607881b711 | ||
|
|
9cc82002b8 | ||
|
|
d69091d5ad | ||
|
|
f9c40146d4 | ||
|
|
401cacaac0 | ||
|
|
90e372d6bd | ||
|
|
20554a4f1d | ||
|
|
36dbf18916 | ||
|
|
a0238ea4ed | ||
|
|
72e8090fd1 | ||
|
|
1da4dc7acc | ||
|
|
1c8763f30f | ||
|
|
36bbaf99e4 | ||
|
|
f95013850b | ||
|
|
fc674561e9 | ||
|
|
88f76926d5 | ||
|
|
c6f6ddc6dd | ||
|
|
6d320fd867 | ||
|
|
6a2143696a | ||
|
|
0c297f8465 | ||
|
|
f1600774db | ||
|
|
4b1ffffc25 | ||
|
|
83b5b5e287 | ||
|
|
2bc9440b38 | ||
|
|
de979f4bb6 | ||
|
|
d7bfd47797 | ||
|
|
40c5623cdc | ||
|
|
5bba28bfda | ||
|
|
8c9f28f731 | ||
|
|
8956b9f122 | ||
|
|
530a2e14db | ||
|
|
5b40ac25db | ||
|
|
0ec6064391 | ||
|
|
218be6d298 | ||
|
|
946ea4de1a | ||
|
|
e7196f440b | ||
|
|
25bec50902 | ||
|
|
955049eac9 | ||
|
|
ac3f6e91f5 |
@@ -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
|
||||
@@ -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;
|
||||
`);
|
||||
};
|
||||
Generated
+338
-2
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"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",
|
||||
@@ -102,6 +103,7 @@
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -1304,6 +1306,12 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hexagon/base64": {
|
||||
"version": "1.1.28",
|
||||
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
|
||||
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
@@ -2257,6 +2265,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@levischuck/tiny-cbor": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
|
||||
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
@@ -2280,6 +2294,260 @@
|
||||
"@noble/hashes": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-android": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.9.4.tgz",
|
||||
"integrity": "sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-asym-key": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-asym-key/-/asn1-asym-key-2.9.4.tgz",
|
||||
"integrity": "sha512-s7SJfjcXlR3MYMngDTeMLCy3VjGaIxeEy905CQ0j09pDbeYhNBvBGA7r7cAvCZYkVFo9kVg9RAki0K2iUz7SGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-pkcs8": "^2.9.4",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-cms": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz",
|
||||
"integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"@peculiar/asn1-x509-attr": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-csr": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz",
|
||||
"integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-ecc": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz",
|
||||
"integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pfx": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz",
|
||||
"integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.9.4",
|
||||
"@peculiar/asn1-pkcs8": "^2.9.4",
|
||||
"@peculiar/asn1-rsa": "^2.9.4",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs8": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz",
|
||||
"integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs9": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz",
|
||||
"integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.9.4",
|
||||
"@peculiar/asn1-pfx": "^2.9.4",
|
||||
"@peculiar/asn1-pkcs8": "^2.9.4",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"@peculiar/asn1-x509-attr": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-rsa": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz",
|
||||
"integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz",
|
||||
"integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz",
|
||||
"integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509-attr": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz",
|
||||
"integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509-post-quantum": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-post-quantum/-/asn1-x509-post-quantum-2.9.4.tgz",
|
||||
"integrity": "sha512-GD0k7BY3dsEIeai7C7bVRPddj/PzZu+sTawRPRxdHKbdy1f9b58WQyk0eFJdr28GShyLVTU8poyi4+bTAZfEtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-asym-key": "^2.9.4",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/utils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
|
||||
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/x509": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.1.0.tgz",
|
||||
"integrity": "sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.9.4",
|
||||
"@peculiar/asn1-csr": "^2.9.4",
|
||||
"@peculiar/asn1-ecc": "^2.9.4",
|
||||
"@peculiar/asn1-pkcs9": "^2.9.4",
|
||||
"@peculiar/asn1-rsa": "^2.9.4",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"@peculiar/asn1-x509-post-quantum": "^2.9.4",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"tslib": "^2.8.1",
|
||||
"tsyringe": "^4.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simplewebauthn/server": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.1.tgz",
|
||||
"integrity": "sha512-kjWgcm9NdSv+Tobo4Swxd1WnEawsBogpYFMjg9pKsVLPN3FdCmR6BipkL+DcPDgmQ7UKbbBZqRqn3E3YaA7NBw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hexagon/base64": "^1.1.27",
|
||||
"@levischuck/tiny-cbor": "^0.2.2",
|
||||
"@peculiar/asn1-android": "^2.6.0",
|
||||
"@peculiar/asn1-ecc": "^2.6.1",
|
||||
"@peculiar/asn1-rsa": "^2.6.1",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.1",
|
||||
"@peculiar/asn1-x509-post-quantum": "^2.9.4",
|
||||
"@peculiar/x509": "^2.1.0",
|
||||
"reflect-metadata": "^0.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.27.12",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz",
|
||||
@@ -2415,6 +2683,7 @@
|
||||
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^4.17.33",
|
||||
@@ -2575,6 +2844,7 @@
|
||||
"integrity": "sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
@@ -2721,6 +2991,7 @@
|
||||
"integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.67.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
@@ -3090,6 +3361,7 @@
|
||||
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3207,6 +3479,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
||||
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.5",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
@@ -3424,6 +3710,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
@@ -4245,6 +4532,7 @@
|
||||
"integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -4635,6 +4923,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -5556,6 +5845,7 @@
|
||||
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "^29.7.0",
|
||||
"@jest/types": "^29.6.3",
|
||||
@@ -6268,6 +6558,7 @@
|
||||
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz",
|
||||
"integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
@@ -7102,6 +7393,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
||||
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
@@ -7385,6 +7677,24 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pvtsutils": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pvutils": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz",
|
||||
"integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
@@ -7466,6 +7776,12 @@
|
||||
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/regexp-ast-analysis": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/regexp-ast-analysis/-/regexp-ast-analysis-0.7.1.tgz",
|
||||
@@ -8154,6 +8470,7 @@
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -8291,8 +8608,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.12",
|
||||
@@ -8313,6 +8629,24 @@
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
|
||||
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^1.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -8374,6 +8708,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -8696,6 +9031,7 @@
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz",
|
||||
"integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
},
|
||||
"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",
|
||||
@@ -31,6 +32,7 @@
|
||||
},
|
||||
"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",
|
||||
|
||||
+33
-1
@@ -17,6 +17,10 @@ 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';
|
||||
@@ -60,7 +64,25 @@ app.get('/api/config', (_req, res) => {
|
||||
//
|
||||
// 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 ?? '')
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,6 +110,16 @@ 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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -9,6 +9,8 @@ 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[];
|
||||
@@ -71,7 +73,22 @@ export interface Checkouts {
|
||||
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;
|
||||
@@ -85,10 +102,28 @@ export interface Customers {
|
||||
marketing_consent: Generated<boolean>;
|
||||
marketing_consent_at: Timestamp | null;
|
||||
marketing_consent_text: string | null;
|
||||
password_hash: string;
|
||||
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;
|
||||
@@ -209,6 +244,13 @@ export interface UploadLinks {
|
||||
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;
|
||||
@@ -216,6 +258,9 @@ export interface DB {
|
||||
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;
|
||||
@@ -228,4 +273,5 @@ export interface DB {
|
||||
shipping_addresses: ShippingAddresses;
|
||||
tags: Tags;
|
||||
upload_links: UploadLinks;
|
||||
webauthn_challenges: WebauthnChallenges;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export type TemplateKey =
|
||||
| 'favoriteWithdrawn'
|
||||
| 'cartReminder'
|
||||
| 'emailChanged'
|
||||
| 'emailChangedByAdmin'
|
||||
| 'intakeDraft'
|
||||
| 'uploadLink';
|
||||
|
||||
@@ -68,6 +69,12 @@ export const TEMPLATES: Record<TemplateKey, TemplateDefinition> = {
|
||||
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."
|
||||
},
|
||||
|
||||
@@ -113,6 +120,40 @@ export const TEMPLATES: Record<TemplateKey, TemplateDefinition> = {
|
||||
'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'],
|
||||
|
||||
@@ -152,6 +152,57 @@ function checkMail(env: NodeJS.ProcessEnv): EnvValidation {
|
||||
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 [];
|
||||
@@ -227,6 +278,7 @@ function checkUploadsOrigin(env: NodeJS.ProcessEnv): EnvValidation {
|
||||
export function validateEnv(env: NodeJS.ProcessEnv): EnvValidation {
|
||||
const mail = checkMail(env);
|
||||
const uploads = checkUploadsOrigin(env);
|
||||
const google = checkGoogleSignIn(env);
|
||||
|
||||
return {
|
||||
errors: [
|
||||
@@ -234,8 +286,16 @@ export function validateEnv(env: NodeJS.ProcessEnv): EnvValidation {
|
||||
...checkDemoMode(env),
|
||||
...checkPayPal(env),
|
||||
...mail.errors,
|
||||
...uploads.errors
|
||||
...uploads.errors,
|
||||
...google.errors
|
||||
],
|
||||
warnings: [...mail.warnings, ...checkAdminGate(env), ...uploads.warnings, ...checkDraftingKey(env), ...checkIntakeActionSecret(env)]
|
||||
warnings: [
|
||||
...mail.warnings,
|
||||
...checkAdminGate(env),
|
||||
...uploads.warnings,
|
||||
...checkDraftingKey(env),
|
||||
...checkIntakeActionSecret(env),
|
||||
...google.warnings
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 !== ''
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]
|
||||
};
|
||||
}
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
/**
|
||||
* How expensive a password hash is, and why that differs under test.
|
||||
*
|
||||
@@ -47,3 +49,30 @@ export function hashRoundsFor(nodeEnv: string | undefined): number {
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
@@ -184,3 +184,26 @@ export const intakeSubmitLimiter = rateLimit({
|
||||
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' }
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
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.
|
||||
@@ -67,6 +73,15 @@ 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) => {
|
||||
@@ -205,6 +220,173 @@ router.post('/:id/reserved/:itemId/release', asyncRoute(async (req: Request, res
|
||||
}
|
||||
}));
|
||||
|
||||
/** The reason the operator typed, or null if it is not usable as one. */
|
||||
function readReason(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
// A length floor rather than merely non-empty. The record exists to
|
||||
// distinguish a verified recovery from a takeover afterwards, and "ok" cannot
|
||||
// do that — but no floor high enough to be gamed is worth having either, so
|
||||
// this asks for a sentence and trusts the person writing it.
|
||||
if (trimmed.length < 10) return null;
|
||||
// Bounded because it is free text going into a TEXT column from a form.
|
||||
return trimmed.slice(0, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moving an account to an address its owner can actually reach (#337).
|
||||
*
|
||||
* This is the third step of the only recovery route a customer who has lost
|
||||
* their mailbox has, and there is deliberately no self-service equivalent: the
|
||||
* email address is the root of trust for every other route, this shop holds no
|
||||
* second proof of identity, and anything invented to fill that gap would be a
|
||||
* weaker credential than the one it replaced. So the route is manual, and
|
||||
* `docs/ops/account-recovery.md` describes the verification that has to happen
|
||||
* before this endpoint is called.
|
||||
*
|
||||
* The uncomfortable part, stated plainly: this operation and an account takeover
|
||||
* are the same operation. They differ only in whether the verification was
|
||||
* sound, and nothing here can check that. What this can do is make the change
|
||||
* recorded, announced, and reversible in its effects — which is what everything
|
||||
* below is for.
|
||||
*
|
||||
* No current-password check, unlike the customer's own change. There is no
|
||||
* password to ask for; the whole premise is that the person asking cannot prove
|
||||
* anything the system can verify. The admin gate is the only authorisation, and
|
||||
* the operator's judgement is the only verification.
|
||||
*/
|
||||
router.put('/:id/email', asyncRoute(async (req: Request, res: Response) => {
|
||||
const id = readId(req.params.id);
|
||||
if (id === null) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const { email, reason } = req.body ?? {};
|
||||
|
||||
const normalized = String(email ?? '').toLowerCase().trim();
|
||||
if (!normalized || !isValidEmail(normalized)) {
|
||||
return res.status(400).json({ error: 'a valid email is required' });
|
||||
}
|
||||
|
||||
const stated = readReason(reason);
|
||||
if (stated === null) {
|
||||
return res.status(400).json({
|
||||
error: 'say why this account is being moved — a sentence naming how the customer was verified'
|
||||
});
|
||||
}
|
||||
|
||||
const { rows } = await pool.query<{ id: number; email: string; first_name: string | null; last_name: string | null }>(
|
||||
`SELECT id, email, first_name, last_name FROM customers WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
const customer = rows[0];
|
||||
if (!customer) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
if (normalized === customer.email) {
|
||||
return res.status(400).json({ error: 'that is already this customer’s email address' });
|
||||
}
|
||||
|
||||
const { rows: taken } = await pool.query<IdRow>(`SELECT id FROM customers WHERE email = $1`, [normalized]);
|
||||
if (taken.length) {
|
||||
return res.status(409).json({ error: 'another account already uses this email address' });
|
||||
}
|
||||
|
||||
// Captured before the update, because it is where the notice has to go and
|
||||
// the row will not be able to answer for it a moment from now.
|
||||
const previousEmail = customer.email;
|
||||
|
||||
let passkeysRemoved = 0;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
await client.query(
|
||||
// Unverified, exactly as the self-service change leaves it. Nobody has
|
||||
// demonstrated receiving mail at this address yet — a customer describing
|
||||
// it over the phone is not that, and it is the commonest way this goes
|
||||
// wrong harmlessly.
|
||||
`UPDATE customers SET email = $1, email_verified = false WHERE id = $2`,
|
||||
[normalized, id]
|
||||
);
|
||||
|
||||
// Everything the previous holder of this account had, on the reasoning #42
|
||||
// settled for password reset. An account being moved to a recovered address
|
||||
// is in the same position as one being recovered by reset, and the same
|
||||
// argument applies with more force: here somebody the system cannot
|
||||
// identify has asked for the change, so a session or a credential surviving
|
||||
// it would be one the new owner cannot see and cannot revoke.
|
||||
await client.query(`DELETE FROM customer_sessions WHERE customer_id = $1`, [id]);
|
||||
const removed = await client.query(`DELETE FROM customer_credentials WHERE customer_id = $1`, [id]);
|
||||
passkeysRemoved = removed.rowCount ?? 0;
|
||||
await client.query(`DELETE FROM webauthn_challenges WHERE customer_id = $1`, [id]);
|
||||
|
||||
// Reset links already sent are addressed to the old mailbox, which is the
|
||||
// one this change is taking away. Leaving them live would let whoever still
|
||||
// reads it take the account straight back.
|
||||
await client.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [id]);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO customer_email_changes (customer_id, previous_email, new_email, reason)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[id, previousEmail, normalized, stated]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
// Both sends happen after the row is written, never before, so a change that
|
||||
// failed cannot produce mail saying it succeeded.
|
||||
await issueVerificationEmail(id, normalized, customer.first_name, customer.last_name);
|
||||
|
||||
// To the address being replaced, which is the whole point. If the recovery
|
||||
// was sound this reaches nobody, and that costs nothing. If it was not, it
|
||||
// reaches the real owner — who is the only person who can say so, and the
|
||||
// only reason this endpoint is safe to have at all.
|
||||
const { greetingFormat, greetingFallback } = await getSettings();
|
||||
const notice = renderTemplate('emailChangedByAdmin', await loadStoredTemplate('emailChangedByAdmin'), {
|
||||
greeting: greeting(customer.first_name, greetingFormat, greetingFallback, customer.last_name),
|
||||
firstName: customer.first_name ?? '',
|
||||
lastName: customer.last_name ?? '',
|
||||
newEmail: normalized
|
||||
});
|
||||
sendMail(previousEmail, notice.subject, notice.html)
|
||||
.catch(err => console.error('admin email change notice send failed', err));
|
||||
|
||||
const { rows: updated } = await pool.query<CustomerDetailRow>(
|
||||
`SELECT id, email, nullif(btrim(concat_ws(' ', first_name, last_name)), '') AS name,
|
||||
email_verified, marketing_consent, marketing_consent_at, created_at
|
||||
FROM customers WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
customer: requireRow(updated, 'the customer after the admin email change'),
|
||||
previousEmail,
|
||||
// Reported so the operator can tell the customer what they will have to set
|
||||
// up again, and so a surprising number is visible at the moment it happens
|
||||
// rather than never.
|
||||
passkeysRemoved
|
||||
});
|
||||
}));
|
||||
|
||||
/** What has been done to this account's address, and why (#337). */
|
||||
router.get('/:id/email-changes', asyncRoute(async (req: Request, res: Response) => {
|
||||
const id = readId(req.params.id);
|
||||
if (id === null) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const { rows } = await pool.query<EmailChangeRow>(
|
||||
`SELECT id, previous_email, new_email, reason, changed_at
|
||||
FROM customer_email_changes
|
||||
WHERE customer_id = $1
|
||||
ORDER BY changed_at DESC`,
|
||||
[id]
|
||||
);
|
||||
res.json(rows);
|
||||
}));
|
||||
|
||||
router.get('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows: customerRows } = await pool.query<CustomerDetailRow>(
|
||||
`SELECT id, email, nullif(btrim(concat_ws(' ', first_name, last_name)), '') AS name,
|
||||
|
||||
+209
-69
@@ -1,6 +1,6 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { PASSWORD_HASH_ROUNDS } from '../passwordHashing';
|
||||
import { PASSWORD_HASH_ROUNDS, passwordMatches } from '../passwordHashing';
|
||||
import crypto from 'node:crypto';
|
||||
import { pool, requireRow } from '../db';
|
||||
import { requireCustomer } from '../middleware/customerAuth';
|
||||
@@ -8,71 +8,24 @@ import { sendMail } from '../mailer';
|
||||
import { renderTemplate, greeting, formatDuration } from '../emailTemplates';
|
||||
import { getSettings } from '../adminSettings';
|
||||
import { loadStoredTemplate } from './adminEmailTemplates';
|
||||
import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
|
||||
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, 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;
|
||||
|
||||
// Registration, changing an address, and resending all need the same three
|
||||
// steps: supersede any outstanding link, mint a new one, send it. Written out
|
||||
// three times they would drift, and the step most likely to be forgotten is the
|
||||
// first — which is the one that matters, since an un-superseded link means an
|
||||
// older message in the inbox still verifies.
|
||||
//
|
||||
// Sending is fire-and-forget by the same rule the rest of this file 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.
|
||||
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));
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// 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.
|
||||
|
||||
// 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
|
||||
@@ -104,11 +57,17 @@ interface CustomerRow {
|
||||
* Kept in step with the schema by hand; nothing checks this against Postgres.
|
||||
*/
|
||||
interface CustomerRecord extends CustomerRow {
|
||||
password_hash: string;
|
||||
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;
|
||||
}
|
||||
@@ -176,7 +135,41 @@ interface CustomerOrderRow {
|
||||
item_name: string;
|
||||
}
|
||||
|
||||
function publicCustomer(c: CustomerRow) {
|
||||
/**
|
||||
* 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,
|
||||
@@ -184,13 +177,23 @@ function publicCustomer(c: CustomerRow) {
|
||||
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', asyncRoute(async (req: Request, res: Response) => {
|
||||
const { email, password, firstName, lastName, marketingConsent } = req.body;
|
||||
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' });
|
||||
}
|
||||
@@ -211,13 +214,19 @@ router.post('/register', asyncRoute(async (req: Request, res: Response) => {
|
||||
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<CustomerRecord>(
|
||||
`INSERT INTO customers (email, password_hash, first_name, last_name, marketing_consent, marketing_consent_at, marketing_consent_text, unsubscribe_token)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
`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, first, last,
|
||||
consent, consent ? new Date() : null, consent ? MARKETING_CONSENT_TEXT : null,
|
||||
analytics, analytics ? new Date() : null, analytics ? ANALYTICS_CONSENT_TEXT : null,
|
||||
unsubscribeToken
|
||||
]
|
||||
);
|
||||
@@ -339,6 +348,11 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
|
||||
|
||||
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 {
|
||||
await client.query('BEGIN');
|
||||
@@ -354,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');
|
||||
@@ -365,14 +410,22 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
|
||||
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(requireRow(fresh, 'the customer whose password was just reset')));
|
||||
// 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', asyncRoute(async (req: Request, res: Response) => {
|
||||
const { email, password } = req.body;
|
||||
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE email = $1`, [String(email || '').toLowerCase().trim()]);
|
||||
const 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
|
||||
@@ -473,9 +526,26 @@ router.post('/change-password', requireCustomer, asyncRoute(async (req: Request,
|
||||
}
|
||||
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
||||
const customer = requireRow(rows, 'the signed-in customer');
|
||||
if (!(await bcrypt.compare(currentPassword || '', customer.password_hash))) {
|
||||
return res.status(401).json({ error: 'current password is incorrect' });
|
||||
|
||||
// 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]);
|
||||
|
||||
@@ -506,7 +576,25 @@ router.put('/me/email', requireCustomer, asyncRoute(async (req: Request, res: Re
|
||||
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
||||
const customer = requireRow(rows, 'the signed-in customer');
|
||||
|
||||
if (!(await bcrypt.compare(String(currentPassword ?? ''), customer.password_hash))) {
|
||||
// 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' });
|
||||
}
|
||||
|
||||
@@ -558,6 +646,58 @@ router.post('/me/consent', requireCustomer, asyncRoute(async (req: Request, res:
|
||||
res.status(204).end();
|
||||
}));
|
||||
|
||||
/**
|
||||
* Analytics consent, on its own route rather than as a second field on
|
||||
* `/me/consent` (#56).
|
||||
*
|
||||
* Separate because the two are separate purposes and must be separately
|
||||
* refusable. One endpoint taking both would make it possible for a single call
|
||||
* to change an answer the customer did not touch — which is the bundling
|
||||
* problem again, moved from the form into the API.
|
||||
*
|
||||
* Withdrawal writes the reason rather than the consent sentence, so the stored
|
||||
* text never claims agreement to something that was declined. Same convention
|
||||
* as marketing consent above.
|
||||
*/
|
||||
router.post('/me/analytics-consent', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const consent = !!req.body.analyticsConsent;
|
||||
await pool.query(
|
||||
`UPDATE customers SET analytics_consent = $1, analytics_consent_at = now(), analytics_consent_text = $2 WHERE id = $3`,
|
||||
[consent, consent ? ANALYTICS_CONSENT_TEXT : 'Withdrew analytics consent via account settings', req.customerId]
|
||||
);
|
||||
res.status(204).end();
|
||||
}));
|
||||
|
||||
/**
|
||||
* Which identity providers this account is signed in with (#343).
|
||||
*
|
||||
* Linking happens automatically when Google vouches for an address that already
|
||||
* has an account, which is defensible but not obvious. A customer who signed up
|
||||
* with a password and later used Google has had two credentials joined without
|
||||
* being asked, and a silent link is indistinguishable from a bug when they
|
||||
* later wonder why the password is no longer needed.
|
||||
*
|
||||
* So it is shown, beside the passkeys, for the reason the passkey list exists
|
||||
* at all: a customer cannot manage credentials they cannot see.
|
||||
*
|
||||
* No unlinking yet. Removing the only way into an account is the question #344
|
||||
* settles, and offering the button before that check runs would be the fastest
|
||||
* possible way to lock somebody out of their own orders.
|
||||
*/
|
||||
router.get('/me/identities', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query<{ provider: string; created_at: Date; last_used_at: Date | null }>(
|
||||
// No provider_sub. The customer cannot act on it, and it is the one value
|
||||
// that identifies them to the provider — the same reasoning that keeps
|
||||
// credential ids out of the passkey list.
|
||||
`SELECT provider, created_at, last_used_at
|
||||
FROM customer_identities
|
||||
WHERE customer_id = $1
|
||||
ORDER BY created_at`,
|
||||
[req.customerId]
|
||||
);
|
||||
res.json(rows);
|
||||
}));
|
||||
|
||||
router.get('/me/orders', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query<CustomerOrderRow>(
|
||||
`SELECT o.id, o.processor, o.amount_cents, o.status, o.created_at, i.name AS item_name
|
||||
|
||||
@@ -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;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { publicItemQuery, PublicItemRow, ItemContext } from '../itemSelect';
|
||||
import { readId } from '../utils';
|
||||
import {
|
||||
parseItemFilters,
|
||||
itemFilterExpressions,
|
||||
@@ -93,16 +94,20 @@ router.get('/', asyncRoute(async (req: Request, res: Response) => {
|
||||
}));
|
||||
|
||||
router.get('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||
// Number() rather than readId(), and that is deliberate rather than an
|
||||
// oversight. readId would be stricter and would match every other id-taking
|
||||
// route (#207) — but errorHandling.integration.test.ts drives this exact
|
||||
// route with a non-numeric id to prove that asyncRoute plus the error
|
||||
// middleware answer 500 rather than leaving the request hanging, and a
|
||||
// stricter parse here would leave that test green while removing the thing
|
||||
// it tests. Switching this over means giving that test another trigger in
|
||||
// the same change. See #307.
|
||||
// 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', '=', Number(req.params.id))
|
||||
.where('i.id', '=', id)
|
||||
.where((eb) => notPending(eb))
|
||||
.execute();
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
+50
-3
@@ -71,9 +71,39 @@ export function tagColorFor(name: string): string {
|
||||
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.
|
||||
*
|
||||
@@ -103,9 +133,26 @@ export function trimTrailingSlashes(value: string): string {
|
||||
*
|
||||
* 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 || value.trim() === '') return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import request from 'supertest';
|
||||
import app from '../../src/app';
|
||||
import { pool, requireRow } from '../../src/db';
|
||||
import { createSession } from '../../src/customerSession';
|
||||
import { resetDb, closeDb } from './setup/testDb';
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
await closeDb();
|
||||
});
|
||||
|
||||
const PASSWORD = 'supersecret123';
|
||||
const REASON = 'Named the last two items bought and the shipping address on file.';
|
||||
|
||||
async function register(email: string): Promise<number> {
|
||||
const res = await request(app)
|
||||
.post('/api/customers/register')
|
||||
.send({ firstName: 'Test', lastName: 'Customer', email, password: PASSWORD });
|
||||
expect(res.status).toBe(200);
|
||||
const { rows } = await pool.query<{ id: number }>(`SELECT id FROM customers WHERE email = $1`, [email]);
|
||||
return requireRow(rows, 'the customer this test just registered').id;
|
||||
}
|
||||
|
||||
function moveTo(id: number, email: string, reason: string = REASON) {
|
||||
return request(app).put(`/api/admin/customers/${id}/email`).send({ email, reason });
|
||||
}
|
||||
|
||||
/**
|
||||
* #337. The third step of the only recovery route available to a customer who
|
||||
* has lost their mailbox — and, structurally, the same operation as an account
|
||||
* takeover. These tests are mostly about the second half of that sentence.
|
||||
*/
|
||||
describe('PUT /api/admin/customers/:id/email', () => {
|
||||
it('moves the account, and leaves the new address unverified', async () => {
|
||||
const id = await register('lost@example.com');
|
||||
|
||||
const res = await moveTo(id, 'Recovered@Example.com ');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.customer.email).toBe('recovered@example.com');
|
||||
expect(res.body.previousEmail).toBe('lost@example.com');
|
||||
// Nobody has demonstrated receiving mail at the new address. A customer
|
||||
// reading it out over the phone is not that, and it is the commonest way
|
||||
// this goes wrong harmlessly.
|
||||
expect(res.body.customer.email_verified).toBe(false);
|
||||
});
|
||||
|
||||
it('records the change with the reason, which is the point of the endpoint', async () => {
|
||||
const id = await register('recorded@example.com');
|
||||
|
||||
await moveTo(id, 'new@example.com');
|
||||
|
||||
const { rows } = await pool.query<{ previous_email: string; new_email: string; reason: string }>(
|
||||
`SELECT previous_email, new_email, reason FROM customer_email_changes WHERE customer_id = $1`,
|
||||
[id]
|
||||
);
|
||||
// A hand edit to the database leaves nothing behind. This row is the only
|
||||
// thing that distinguishes a verified recovery from a takeover afterwards.
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toEqual({
|
||||
previous_email: 'recorded@example.com',
|
||||
new_email: 'new@example.com',
|
||||
reason: REASON
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses without a stated reason, rather than recording an empty one', async () => {
|
||||
const id = await register('noreason@example.com');
|
||||
|
||||
const res = await moveTo(id, 'new@example.com', '');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const { rows } = await pool.query<{ email: string }>(`SELECT email FROM customers WHERE id = $1`, [id]);
|
||||
// Refused entirely, not performed and left unexplained.
|
||||
expect(requireRow(rows, 'the unchanged customer').email).toBe('noreason@example.com');
|
||||
});
|
||||
|
||||
it('refuses a reason too short to be one', async () => {
|
||||
const id = await register('terse@example.com');
|
||||
|
||||
// "ok" cannot tell a recovery from a takeover, which is the only thing the
|
||||
// field is for.
|
||||
const res = await moveTo(id, 'new@example.com', 'ok');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('signs the customer out everywhere', async () => {
|
||||
const id = await register('sessions@example.com');
|
||||
const token = await createSession(id);
|
||||
const asCustomer = () => request(app).get('/api/customers/me').set('Cookie', `rd_session=${token}`);
|
||||
expect((await asCustomer()).status).toBe(200);
|
||||
|
||||
await moveTo(id, 'new@example.com');
|
||||
|
||||
// Somebody the system cannot identify asked for this change. A session
|
||||
// surviving it is one the new owner cannot see and cannot revoke.
|
||||
expect((await asCustomer()).status).toBe(401);
|
||||
});
|
||||
|
||||
it('removes every passkey, and says how many', async () => {
|
||||
const id = await register('keys@example.com');
|
||||
await pool.query(
|
||||
`INSERT INTO customer_credentials (customer_id, credential_id, public_key, name)
|
||||
VALUES ($1, 'credential-a', 'not-a-real-key', 'Phone'),
|
||||
($1, 'credential-b', 'not-a-real-key', 'Laptop')`,
|
||||
[id]
|
||||
);
|
||||
|
||||
const res = await moveTo(id, 'new@example.com');
|
||||
|
||||
expect(res.body.passkeysRemoved).toBe(2);
|
||||
const { rows } = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM customer_credentials WHERE customer_id = $1`,
|
||||
[id]
|
||||
);
|
||||
expect(requireRow(rows, 'a count of credentials').n).toBe(0);
|
||||
});
|
||||
|
||||
it('cancels reset links already sent to the old address', async () => {
|
||||
const id = await register('resetlink@example.com');
|
||||
await request(app)
|
||||
.post('/api/customers/request-password-reset')
|
||||
.send({ email: 'resetlink@example.com' });
|
||||
const before = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`,
|
||||
[id]
|
||||
);
|
||||
expect(requireRow(before.rows, 'a count of reset tokens').n).toBe(1);
|
||||
|
||||
await moveTo(id, 'new@example.com');
|
||||
|
||||
// That link is addressed to the mailbox this change is taking away. Leaving
|
||||
// it live would let whoever still reads it take the account straight back.
|
||||
const after = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`,
|
||||
[id]
|
||||
);
|
||||
expect(requireRow(after.rows, 'a count of reset tokens').n).toBe(0);
|
||||
});
|
||||
|
||||
it('issues a verification link to the new address', async () => {
|
||||
const id = await register('verify@example.com');
|
||||
|
||||
await moveTo(id, 'new@example.com');
|
||||
|
||||
const { rows } = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM customer_tokens WHERE customer_id = $1 AND kind = 'verify_email'`,
|
||||
[id]
|
||||
);
|
||||
// Exactly one: registration issued a link to the old address, and issuing
|
||||
// this one has to supersede it, or a message in the mailbox being taken
|
||||
// away could still verify.
|
||||
expect(requireRow(rows, 'a count of verification tokens').n).toBe(1);
|
||||
});
|
||||
|
||||
it('refuses an address another account already uses', async () => {
|
||||
const id = await register('mover@example.com');
|
||||
await register('occupied@example.com');
|
||||
|
||||
const res = await moveTo(id, 'occupied@example.com');
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('refuses the address the account already has', async () => {
|
||||
const id = await register('same@example.com');
|
||||
|
||||
const res = await moveTo(id, 'same@example.com');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('refuses a malformed address', async () => {
|
||||
const id = await register('malformed@example.com');
|
||||
|
||||
const res = await moveTo(id, 'not-an-email');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('is a 404 for a customer who does not exist', async () => {
|
||||
const res = await moveTo(999999, 'new@example.com');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('leaves the customer able to sign in with their existing password', async () => {
|
||||
const id = await register('stillworks@example.com');
|
||||
|
||||
await moveTo(id, 'moved@example.com');
|
||||
|
||||
// The move is not a password reset. The customer knows their password —
|
||||
// what they lost was the mailbox — so demanding a new one would add a step
|
||||
// for no gain.
|
||||
const login = await request(app)
|
||||
.post('/api/customers/login')
|
||||
.send({ email: 'moved@example.com', password: PASSWORD });
|
||||
expect(login.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/admin/customers/:id/email-changes', () => {
|
||||
it('lists what has been done to this account, newest first', async () => {
|
||||
const id = await register('history@example.com');
|
||||
await moveTo(id, 'second@example.com', 'First recovery, verified against order history.');
|
||||
await moveTo(id, 'third@example.com', 'Second recovery, verified against the shipping address.');
|
||||
|
||||
const res = await request(app).get(`/api/admin/customers/${id}/email-changes`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(2);
|
||||
// Newest first, because an account that has been moved twice is the one
|
||||
// worth looking at and the most recent move is the one in question.
|
||||
expect(res.body[0].new_email).toBe('third@example.com');
|
||||
expect(res.body[1].new_email).toBe('second@example.com');
|
||||
});
|
||||
|
||||
it('is empty for a customer whose address has never been moved', async () => {
|
||||
const id = await register('untouched@example.com');
|
||||
|
||||
const res = await request(app).get(`/api/admin/customers/${id}/email-changes`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -81,8 +81,11 @@ describe('POST /api/customers/register', () => {
|
||||
});
|
||||
|
||||
expect(Object.keys(res.body).sort()).toEqual([
|
||||
'created_at', 'email', 'email_verified', 'favorite_alerts',
|
||||
'first_name', 'id', 'last_name', 'marketing_consent'
|
||||
'analytics_consent', 'created_at', 'email', 'email_verified', 'favorite_alerts',
|
||||
// Whether, never what. Added in #344 so the account page can offer to set
|
||||
// a first password rather than to change one that does not exist; the
|
||||
// hash itself must never appear in this list.
|
||||
'first_name', 'has_password', 'id', 'last_name', 'marketing_consent'
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -106,6 +109,44 @@ describe('POST /api/customers/register', () => {
|
||||
expect(res.body.marketing_consent).toBe(true);
|
||||
});
|
||||
|
||||
// Quebec's Law 25 s.8.1 requires profiling to be off until the person turns
|
||||
// it on, so this is a compliance property rather than a default worth
|
||||
// debating. Asserted end to end because the column default, the register
|
||||
// route and the stored wording all have to agree for it to hold.
|
||||
it('creates an account with analytics consent off by default', async () => {
|
||||
const res = await request(app).post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
||||
email: 'analytics-default@example.com',
|
||||
password: 'supersecret123'
|
||||
});
|
||||
expect(res.body.analytics_consent).toBe(false);
|
||||
});
|
||||
|
||||
// The two consents are separate purposes and must be separately refusable.
|
||||
// Taking the emails must not opt anybody into being tracked — that bundling
|
||||
// is what GDPR treats as invalid consent, and it is the mistake this branch
|
||||
// made once before it was caught.
|
||||
it('opting in to marketing alone does not opt in to analytics', async () => {
|
||||
const res = await request(app).post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
||||
email: 'marketing-only@example.com',
|
||||
password: 'supersecret123',
|
||||
marketingConsent: true
|
||||
});
|
||||
expect(res.body.marketing_consent).toBe(true);
|
||||
expect(res.body.analytics_consent).toBe(false);
|
||||
});
|
||||
|
||||
it('respects an explicit analytics opt-in, independently of marketing', async () => {
|
||||
const res = await request(app).post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
||||
email: 'analytics-only@example.com',
|
||||
password: 'supersecret123',
|
||||
analyticsConsent: true
|
||||
});
|
||||
expect(res.body.analytics_consent).toBe(true);
|
||||
// Refusing the emails while accepting the tracking has to be possible too,
|
||||
// or the consent is not granular in both directions.
|
||||
expect(res.body.marketing_consent).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a duplicate email', async () => {
|
||||
await request(app).post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
||||
email: 'dupe@example.com',
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('GET /api/admin/email-templates', () => {
|
||||
expect(res.body.map((t: { key: string }) => t.key).sort()).toEqual([
|
||||
'cartReminder',
|
||||
'emailChanged',
|
||||
'emailChangedByAdmin',
|
||||
'favoriteSold',
|
||||
'favoriteWithdrawn',
|
||||
'intakeDraft',
|
||||
|
||||
@@ -12,23 +12,86 @@ afterAll(async () => {
|
||||
await closeDb();
|
||||
});
|
||||
|
||||
describe('unexpected route failures', () => {
|
||||
it('answers with 500 instead of leaving the request hanging', async () => {
|
||||
// A non-numeric id reaches Postgres as `WHERE i.id = 'not-a-number'`, which
|
||||
// raises invalid-input-syntax. Express 4 does not forward a rejected async
|
||||
// handler on its own, so without the asyncRoute wrapper plus the error
|
||||
// middleware this request never gets a response at all — and a hung request
|
||||
// renders as an empty storefront rather than a visible failure.
|
||||
const res = await request(app).get('/api/items/not-a-number');
|
||||
/**
|
||||
* The message the failing query rejects with. Deliberately shaped like a real
|
||||
* Postgres error and deliberately containing both words the leak test looks
|
||||
* for, so that assertion is checking something rather than passing because the
|
||||
* words happened not to appear.
|
||||
*/
|
||||
const DB_FAILURE = 'invalid input syntax for type integer while reading items';
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toBe('internal error');
|
||||
/**
|
||||
* Fails the next database call, whichever route makes it.
|
||||
*
|
||||
* This used to be done by asking `/api/items/not-a-number` and relying on that
|
||||
* route sending an unparseable id to Postgres. That worked, but it tied this
|
||||
* test to one route declining to validate its input: fixing that route — which
|
||||
* #307 wanted, and #207 had already done everywhere else — would have left this
|
||||
* test green while removing the thing it tests.
|
||||
*
|
||||
* A rejected query is the failure the error middleware actually exists for, and
|
||||
* it does not depend on any route being wrong. `pool` is a real object, so this
|
||||
* spy is not sensitive to how the module is transpiled.
|
||||
*/
|
||||
function failNextQuery() {
|
||||
// `as never` is the type system, not a shortcut. `pg` declares `query` with
|
||||
// several overloads, and `jest.spyOn` resolves the mock's argument against
|
||||
// the last of them, whose parameter list is empty — so the inferred type for
|
||||
// a rejection value is `never` and nothing can be assigned to it. Rejecting
|
||||
// is what all of the overloads do on failure; the cast only says which one to
|
||||
// check against.
|
||||
return jest.spyOn(pool, 'query').mockRejectedValueOnce(new Error(DB_FAILURE) as never);
|
||||
}
|
||||
|
||||
describe('unexpected route failures', () => {
|
||||
// Express 4 does not forward a rejected async handler on its own. Without the
|
||||
// asyncRoute wrapper plus the error middleware this request never gets a
|
||||
// response at all — and a hung request renders as an empty storefront rather
|
||||
// than a visible failure, which is exactly how the 2026-08-17 incident
|
||||
// presented.
|
||||
it('answers with 500 instead of leaving the request hanging', async () => {
|
||||
const spy = failNextQuery();
|
||||
try {
|
||||
const res = await request(app).get('/api/filters');
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toBe('internal error');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not leak the underlying database error to the client', async () => {
|
||||
const spy = failNextQuery();
|
||||
try {
|
||||
const res = await request(app).get('/api/filters');
|
||||
|
||||
const body = JSON.stringify(res.body);
|
||||
expect(body).not.toContain('syntax');
|
||||
expect(body).not.toContain('items');
|
||||
// The whole message, not only the words above, so a future error format
|
||||
// cannot slip through by wording it differently.
|
||||
expect(body).not.toContain(DB_FAILURE);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// The route that used to be this file's trigger. It answers 404 now rather
|
||||
// than 500, because an id that cannot be read identifies nothing — asserted
|
||||
// here so the behaviour that replaced the trigger is itself covered.
|
||||
it('answers 404, not 500, for an id that cannot be read', async () => {
|
||||
const res = await request(app).get('/api/items/not-a-number');
|
||||
|
||||
expect(JSON.stringify(res.body)).not.toContain('syntax');
|
||||
expect(JSON.stringify(res.body)).not.toContain('items');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBe('not found');
|
||||
});
|
||||
|
||||
// These parse to positive integers and used to fetch real rows — /items/5.0
|
||||
// answered with item 5 (#307). Never a crash, which is why it went unnoticed.
|
||||
it.each(['5.0', '1e2', '0x10'])('answers 404 for %p rather than fetching a row', async (id) => {
|
||||
const res = await request(app).get(`/api/items/${id}`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,783 @@
|
||||
import request from 'supertest';
|
||||
import app from '../../src/app';
|
||||
import { pool, requireRow } from '../../src/db';
|
||||
import { createSession } from '../../src/customerSession';
|
||||
import { resetDb, closeDb } from './setup/testDb';
|
||||
|
||||
const CLIENT_ID = 'test-client.apps.googleusercontent.com';
|
||||
const SUB = 'google-subject-1234567890';
|
||||
|
||||
let fetchSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
process.env.GOOGLE_CLIENT_ID = CLIENT_ID;
|
||||
process.env.GOOGLE_CLIENT_SECRET = 'test-secret';
|
||||
process.env.PUBLIC_URL = 'http://localhost:3000';
|
||||
// Nothing here talks to Google. The exchange is the only network call in the
|
||||
// flow, so stubbing it leaves every decision this suite cares about — the
|
||||
// cookie, the state check, the claim checks, the lookup — running for real.
|
||||
fetchSpy = jest.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
delete process.env.GOOGLE_CLIENT_ID;
|
||||
delete process.env.GOOGLE_CLIENT_SECRET;
|
||||
delete process.env.PUBLIC_URL;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
await closeDb();
|
||||
});
|
||||
|
||||
/** An unsigned id token. Nothing in this flow reads a signature — see google/oauth.ts. */
|
||||
function idToken(claims: Record<string, unknown>): string {
|
||||
const part = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
return `${part({ alg: 'RS256' })}.${part(claims)}.not-a-signature`;
|
||||
}
|
||||
|
||||
function claimsFor(nonce: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
iss: 'https://accounts.google.com',
|
||||
aud: CLIENT_ID,
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
sub: SUB,
|
||||
nonce,
|
||||
email: 'customer@example.com',
|
||||
email_verified: true,
|
||||
given_name: 'Test',
|
||||
family_name: 'Customer',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function respondWithToken(token: string): void {
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response(JSON.stringify({ id_token: token }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** The attempt cookie the start route set, as a header for the callback. */
|
||||
function cookieHeader(setCookie: string[]): string {
|
||||
const attempt = setCookie.find((c) => c.startsWith('rd_oauth='));
|
||||
if (!attempt) throw new Error('the start route set no attempt cookie');
|
||||
// Non-null: the header was just matched, so it has at least one segment.
|
||||
return attempt.split(';')[0] as string;
|
||||
}
|
||||
|
||||
/** One Set-Cookie value, as a request header. Throws rather than typing around its absence. */
|
||||
function requireCookie(setCookie: string[], prefix: string): string {
|
||||
const found = setCookie.find((c) => c.startsWith(prefix));
|
||||
if (!found) throw new Error(`no ${prefix} cookie was set`);
|
||||
return found.split(';')[0] as string;
|
||||
}
|
||||
|
||||
/** Reads the secrets back out of the cookie, which is the only place they exist. */
|
||||
function attemptFrom(setCookie: string[]): { state: string; nonce: string; returnTo: string } {
|
||||
const value = cookieHeader(setCookie).slice('rd_oauth='.length);
|
||||
return JSON.parse(Buffer.from(decodeURIComponent(value), 'base64url').toString('utf8'));
|
||||
}
|
||||
|
||||
/** Starts a sign-in and hands back what the callback needs to finish it. */
|
||||
async function startSignIn(returnTo?: string) {
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/start')
|
||||
.query(returnTo === undefined ? {} : { returnTo });
|
||||
const setCookie = res.headers['set-cookie'] as unknown as string[];
|
||||
return { res, cookie: cookieHeader(setCookie), ...attemptFrom(setCookie) };
|
||||
}
|
||||
|
||||
async function createCustomer(email: string): Promise<number> {
|
||||
const { rows } = await pool.query<{ id: number }>(
|
||||
`INSERT INTO customers (email, password_hash, first_name, last_name, unsubscribe_token)
|
||||
VALUES ($1, 'not-a-real-hash', 'Test', 'Customer', $2) RETURNING id`,
|
||||
[email, `unsub-${email}`]
|
||||
);
|
||||
return requireRow(rows, 'the customer this test just created').id;
|
||||
}
|
||||
|
||||
async function linkGoogle(customerId: number, sub = SUB): Promise<void> {
|
||||
await pool.query(
|
||||
`INSERT INTO customer_identities (customer_id, provider, provider_sub)
|
||||
VALUES ($1, 'google', $2)`,
|
||||
[customerId, sub]
|
||||
);
|
||||
}
|
||||
|
||||
/** A session cookie for a customer, without going through any sign-in flow. */
|
||||
async function sessionFor(customerId: number): Promise<string> {
|
||||
return `rd_session=${await createSession(customerId)}`;
|
||||
}
|
||||
|
||||
describe('GET /api/auth/google/start', () => {
|
||||
it('sends the customer to Google with the code flow and PKCE', async () => {
|
||||
const { res } = await startSignIn();
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
const target = new URL(res.headers.location as string);
|
||||
expect(target.origin).toBe('https://accounts.google.com');
|
||||
expect(target.searchParams.get('response_type')).toBe('code');
|
||||
expect(target.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
});
|
||||
|
||||
it('sets an httpOnly attempt cookie, which is the whole security of the callback', async () => {
|
||||
const res = await request(app).get('/api/auth/google/start');
|
||||
const attempt = (res.headers['set-cookie'] as unknown as string[]).find((c) =>
|
||||
c.startsWith('rd_oauth=')
|
||||
);
|
||||
|
||||
expect(attempt).toMatch(/HttpOnly/i);
|
||||
// Lax and not Strict. Strict withholds the cookie on the cross-site
|
||||
// top-level navigation back from Google, and every sign-in then fails the
|
||||
// state check in a way that looks exactly like tampering.
|
||||
expect(attempt).toMatch(/SameSite=Lax/i);
|
||||
});
|
||||
|
||||
it('never puts the client secret anywhere the browser can see', async () => {
|
||||
const res = await request(app).get('/api/auth/google/start');
|
||||
|
||||
expect(res.headers.location).not.toContain('test-secret');
|
||||
expect(JSON.stringify(res.headers['set-cookie'])).not.toContain('test-secret');
|
||||
});
|
||||
|
||||
it('carries a local return path through the round trip', async () => {
|
||||
const { returnTo } = await startSignIn('/?max_price=50000');
|
||||
|
||||
expect(returnTo).toBe('/?max_price=50000');
|
||||
});
|
||||
|
||||
it('refuses an off-site return path rather than becoming an open redirect', async () => {
|
||||
const { returnTo } = await startSignIn('//evil.test');
|
||||
|
||||
expect(returnTo).toBe('/');
|
||||
});
|
||||
|
||||
it('sends the customer to the storefront when Google sign-in is switched off', async () => {
|
||||
delete process.env.GOOGLE_CLIENT_ID;
|
||||
delete process.env.GOOGLE_CLIENT_SECRET;
|
||||
|
||||
const res = await request(app).get('/api/auth/google/start');
|
||||
|
||||
// A stale bookmark or a hand-typed URL, and the storefront answers both.
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe('/');
|
||||
expect(res.headers['set-cookie']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/auth/google/callback', () => {
|
||||
it('signs in a customer whose Google identity is already linked', async () => {
|
||||
const customerId = await createCustomer('linked@example.com');
|
||||
await linkGoogle(customerId);
|
||||
const { cookie, state, nonce } = await startSignIn('/cart');
|
||||
respondWithToken(idToken(claimsFor(nonce)));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe('/cart');
|
||||
const session = (res.headers['set-cookie'] as unknown as string[]).find((c) =>
|
||||
c.startsWith('rd_session=')
|
||||
);
|
||||
expect(session).toBeDefined();
|
||||
});
|
||||
|
||||
it('produces a session the rest of the application accepts', async () => {
|
||||
const customerId = await createCustomer('session@example.com');
|
||||
await linkGoogle(customerId);
|
||||
const { cookie, state, nonce } = await startSignIn();
|
||||
respondWithToken(idToken(claimsFor(nonce)));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state })
|
||||
.set('Cookie', cookie);
|
||||
const setCookie = res.headers['set-cookie'] as unknown as string[];
|
||||
const session = requireCookie(setCookie, 'rd_session=');
|
||||
|
||||
// The point of sharing signIn with the password and passkey paths: the
|
||||
// session is not merely present, it is the same kind of session.
|
||||
const me = await request(app).get('/api/customers/me').set('Cookie', session);
|
||||
expect(me.status).toBe(200);
|
||||
expect(me.body.email).toBe('session@example.com');
|
||||
});
|
||||
|
||||
it('stamps last_used_at, which is what tells two identities apart', async () => {
|
||||
const customerId = await createCustomer('stamped@example.com');
|
||||
await linkGoogle(customerId);
|
||||
const { cookie, state, nonce } = await startSignIn();
|
||||
respondWithToken(idToken(claimsFor(nonce)));
|
||||
|
||||
await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
const { rows } = await pool.query<{ last_used_at: Date | null }>(
|
||||
`SELECT last_used_at FROM customer_identities WHERE provider_sub = $1`,
|
||||
[SUB]
|
||||
);
|
||||
expect(requireRow(rows, 'the identity just used').last_used_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('clears the attempt cookie, so one attempt cannot be replayed', async () => {
|
||||
const customerId = await createCustomer('once@example.com');
|
||||
await linkGoogle(customerId);
|
||||
const { cookie, state, nonce } = await startSignIn();
|
||||
respondWithToken(idToken(claimsFor(nonce)));
|
||||
|
||||
const first = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
const cleared = (first.headers['set-cookie'] as unknown as string[]).find((c) =>
|
||||
c.startsWith('rd_oauth=')
|
||||
);
|
||||
expect(cleared).toMatch(/rd_oauth=;/);
|
||||
});
|
||||
|
||||
it('refuses a state that does not match the attempt cookie', async () => {
|
||||
const { cookie } = await startSignIn();
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state: 'not-the-state-we-issued' })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
expect(res.headers.location).toBe('/login?auth=google-failed');
|
||||
// Refused before any network call: a mismatched state is not worth a token
|
||||
// exchange, and spending the code would be handing it to whoever forged it.
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a callback carrying no attempt cookie at all', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state: 'anything' });
|
||||
|
||||
expect(res.headers.location).toBe('/login?auth=google-failed');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats a declined consent screen as a cancellation, not a failure', async () => {
|
||||
const { cookie } = await startSignIn('/cart');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ error: 'access_denied' })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
// Back where they were, with nothing said. A customer who changed their
|
||||
// mind has not encountered an error, which is the distinction #41 draws
|
||||
// for a dismissed passkey prompt.
|
||||
expect(res.headers.location).toBe('/cart');
|
||||
});
|
||||
|
||||
it('refuses an id token minted for a different application', async () => {
|
||||
const customerId = await createCustomer('wrongaud@example.com');
|
||||
await linkGoogle(customerId);
|
||||
const { cookie, state, nonce } = await startSignIn();
|
||||
respondWithToken(idToken(claimsFor(nonce, { aud: 'someone-else.apps.googleusercontent.com' })));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
expect(res.headers.location).toBe('/login?auth=google-failed');
|
||||
expect(res.headers['set-cookie'] ?? []).not.toContainEqual(expect.stringContaining('rd_session='));
|
||||
});
|
||||
|
||||
it('refuses an id token from a different sign-in attempt', async () => {
|
||||
const customerId = await createCustomer('wrongnonce@example.com');
|
||||
await linkGoogle(customerId);
|
||||
const { cookie, state } = await startSignIn();
|
||||
respondWithToken(idToken(claimsFor('a-nonce-from-somewhere-else')));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
expect(res.headers.location).toBe('/login?auth=google-failed');
|
||||
});
|
||||
|
||||
it('refuses when Google declines the token exchange', async () => {
|
||||
const { cookie, state } = await startSignIn();
|
||||
fetchSpy.mockResolvedValue(new Response('{"error":"invalid_grant"}', { status: 400 }));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'a-spent-code', state })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
expect(res.headers.location).toBe('/login?auth=google-failed');
|
||||
});
|
||||
|
||||
it('refuses a disabled account, as the password and passkey paths do', async () => {
|
||||
const customerId = await createCustomer('disabled@example.com');
|
||||
await linkGoogle(customerId);
|
||||
await pool.query(`UPDATE customers SET disabled_at = now() WHERE id = $1`, [customerId]);
|
||||
const { cookie, state, nonce } = await startSignIn();
|
||||
respondWithToken(idToken(claimsFor(nonce)));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
// Enforcing it on some sign-in routes and not others is how a disabled
|
||||
// account keeps a way in.
|
||||
expect(res.headers.location).toBe('/login?auth=google-failed');
|
||||
expect(res.headers['set-cookie'] ?? []).not.toContainEqual(expect.stringContaining('rd_session='));
|
||||
});
|
||||
|
||||
it('refuses when the address already belongs to an account, and creates nothing', async () => {
|
||||
await createCustomer('unlinked@example.com');
|
||||
const { cookie, state, nonce } = await startSignIn();
|
||||
respondWithToken(idToken(claimsFor(nonce, { email: 'unlinked@example.com' })));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
// Joining those two accounts is linking, which is #343. Doing it here on
|
||||
// the strength of a matching address is the takeover path that decision
|
||||
// exists to reason about carefully.
|
||||
expect(res.headers.location).toBe('/login?auth=google-failed');
|
||||
const { rows } = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM customer_identities`
|
||||
);
|
||||
expect(requireRow(rows, 'a count of identities').n).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #342. A Google account nobody here has seen becomes a customer.
|
||||
*
|
||||
* The consent behaviour is most of what these assert, because it is the part
|
||||
* that is easy to get quietly wrong: an account created with a consent nobody
|
||||
* gave, or with wording that does not match what the customer was shown, is
|
||||
* still an account that works.
|
||||
*/
|
||||
describe('signing up with Google', () => {
|
||||
async function signUpWith(overrides: Record<string, unknown> = {}) {
|
||||
const { cookie, state, nonce } = await startSignIn();
|
||||
respondWithToken(idToken(claimsFor(nonce, overrides)));
|
||||
return request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state })
|
||||
.set('Cookie', cookie);
|
||||
}
|
||||
|
||||
async function customerBy(email: string) {
|
||||
const { rows } = await pool.query<{
|
||||
id: number;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
email_verified: boolean;
|
||||
password_hash: string | null;
|
||||
marketing_consent: boolean;
|
||||
marketing_consent_text: string | null;
|
||||
analytics_consent: boolean;
|
||||
analytics_consent_text: string | null;
|
||||
}>(`SELECT * FROM customers WHERE email = $1`, [email]);
|
||||
return requireRow(rows, `the customer for ${email}`);
|
||||
}
|
||||
|
||||
async function countOf(table: 'customers' | 'customer_identities'): Promise<number> {
|
||||
const { rows } = await pool.query<{ n: number }>(`SELECT count(*)::int AS n FROM ${table}`);
|
||||
return requireRow(rows, `a count of ${table}`).n;
|
||||
}
|
||||
|
||||
async function verificationTokens(customerId: number): Promise<number> {
|
||||
const { rows } = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM customer_tokens WHERE customer_id = $1 AND kind = 'verify_email'`,
|
||||
[customerId]
|
||||
);
|
||||
return requireRow(rows, 'a count of verification tokens').n;
|
||||
}
|
||||
|
||||
it('creates a customer and an identity, and signs them in', async () => {
|
||||
const res = await signUpWith({ email: 'brandnew@example.com' });
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
const customer = await customerBy('brandnew@example.com');
|
||||
const { rows } = await pool.query<{ customer_id: number }>(
|
||||
`SELECT customer_id FROM customer_identities WHERE provider_sub = $1`,
|
||||
[SUB]
|
||||
);
|
||||
expect(requireRow(rows, 'the new identity').customer_id).toBe(customer.id);
|
||||
expect(res.headers['set-cookie'] as unknown as string[]).toContainEqual(
|
||||
expect.stringContaining('rd_session=')
|
||||
);
|
||||
});
|
||||
|
||||
it('lands the new customer on the consent step rather than the storefront', async () => {
|
||||
// The one moment the two consent sentences can honestly be shown: the
|
||||
// redirect to Google happened before anyone knew this person was new.
|
||||
const res = await signUpWith({ email: 'consentstep@example.com' });
|
||||
|
||||
expect(res.headers.location).toBe('/welcome');
|
||||
});
|
||||
|
||||
it('creates the account with no password at all', async () => {
|
||||
await signUpWith({ email: 'nopassword@example.com' });
|
||||
|
||||
// The first accounts in this project's history without one. #344 is where
|
||||
// the routes that assumed otherwise learn to cope.
|
||||
expect((await customerBy('nopassword@example.com')).password_hash).toBeNull();
|
||||
});
|
||||
|
||||
it('gives both consents as false, with no stored wording', async () => {
|
||||
await signUpWith({ email: 'noconsent@example.com' });
|
||||
const customer = await customerBy('noconsent@example.com');
|
||||
|
||||
// Nobody agreed to anything, so nothing is recorded as though they had. A
|
||||
// stored wording against a false consent would be a record of a
|
||||
// conversation that never happened.
|
||||
expect(customer.marketing_consent).toBe(false);
|
||||
expect(customer.analytics_consent).toBe(false);
|
||||
expect(customer.marketing_consent_text).toBeNull();
|
||||
expect(customer.analytics_consent_text).toBeNull();
|
||||
});
|
||||
|
||||
it('takes the names from the Google profile', async () => {
|
||||
await signUpWith({ email: 'named@example.com' });
|
||||
const customer = await customerBy('named@example.com');
|
||||
|
||||
expect(customer.first_name).toBe('Test');
|
||||
expect(customer.last_name).toBe('Customer');
|
||||
});
|
||||
|
||||
it('creates the account anyway when Google sends no names', async () => {
|
||||
// Registration demands both because every email greets by first name, but
|
||||
// Google may return neither and refusing over it would be absurd — the
|
||||
// greeting already has a fallback for exactly this.
|
||||
const { cookie, state, nonce } = await startSignIn();
|
||||
const claims = claimsFor(nonce, { email: 'nameless@example.com' }) as Record<string, unknown>;
|
||||
delete claims.given_name;
|
||||
delete claims.family_name;
|
||||
respondWithToken(idToken(claims));
|
||||
|
||||
await request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state })
|
||||
.set('Cookie', cookie);
|
||||
|
||||
const customer = await customerBy('nameless@example.com');
|
||||
expect(customer.first_name).toBeNull();
|
||||
expect(customer.last_name).toBeNull();
|
||||
});
|
||||
|
||||
describe('the verified address', () => {
|
||||
it('is marked verified, and sends no confirmation email, when Google vouches', async () => {
|
||||
await signUpWith({ email: 'vouched@example.com', email_verified: true });
|
||||
const customer = await customerBy('vouched@example.com');
|
||||
|
||||
expect(customer.email_verified).toBe(true);
|
||||
// The confirmation email exists to prove the customer receives mail at
|
||||
// the address. Google has just proved exactly that, so sending one would
|
||||
// ask them to do a thing that is already done.
|
||||
expect(await verificationTokens(customer.id)).toBe(0);
|
||||
});
|
||||
|
||||
it('is unverified, and does send one, when Google does not', async () => {
|
||||
await signUpWith({ email: 'unvouched@example.com', email_verified: false });
|
||||
const customer = await customerBy('unvouched@example.com');
|
||||
|
||||
// An unverified assertion is worth nothing, so this account goes through
|
||||
// the ordinary confirmation exactly as a password sign-up would.
|
||||
expect(customer.email_verified).toBe(false);
|
||||
expect(await verificationTokens(customer.id)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('reaches the same customer on a second sign-in, not a second account', async () => {
|
||||
await signUpWith({ email: 'returning@example.com' });
|
||||
const first = await customerBy('returning@example.com');
|
||||
|
||||
const second = await signUpWith({ email: 'returning@example.com' });
|
||||
|
||||
// Signed in, and back to the storefront rather than the consent step —
|
||||
// which is shown once, to somebody who has just been created.
|
||||
expect(second.headers.location).toBe('/');
|
||||
expect(await countOf('customers')).toBe(1);
|
||||
expect((await customerBy('returning@example.com')).id).toBe(first.id);
|
||||
});
|
||||
|
||||
it('reaches the same customer even after they change their Google address', async () => {
|
||||
await signUpWith({ email: 'was@example.com' });
|
||||
const original = await customerBy('was@example.com');
|
||||
|
||||
// Matched on the subject, which is the whole reason that column exists. An
|
||||
// email match would have created a second account here — and an address
|
||||
// that had since been reassigned would have handed this one to a stranger.
|
||||
const second = await signUpWith({ email: 'now@example.com' });
|
||||
|
||||
expect(second.headers.location).toBe('/');
|
||||
expect(await countOf('customers')).toBe(1);
|
||||
expect((await customerBy('was@example.com')).id).toBe(original.id);
|
||||
});
|
||||
|
||||
it('still refuses a disabled account, which a sign-up must not route around', async () => {
|
||||
const customerId = await createCustomer('blocked@example.com');
|
||||
await linkGoogle(customerId, SUB);
|
||||
await pool.query(`UPDATE customers SET disabled_at = now() WHERE id = $1`, [customerId]);
|
||||
|
||||
const res = await signUpWith({ email: 'blocked@example.com' });
|
||||
|
||||
// The identity exists, so this takes the sign-in path and is refused
|
||||
// there. No second account is created as a way around it.
|
||||
expect(res.headers.location).toBe('/login?auth=google-failed');
|
||||
expect(await countOf('customers')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #343. The most security-sensitive phase of this feature.
|
||||
*
|
||||
* Every test here is about the same question asked from a different angle: when
|
||||
* is it right to hand somebody an account they have not proved they own?
|
||||
*/
|
||||
describe('linking a Google identity to an existing account', () => {
|
||||
async function attempt(overrides: Record<string, unknown> = {}, returnTo?: string) {
|
||||
const { cookie, state, nonce } = await startSignIn(returnTo);
|
||||
respondWithToken(idToken(claimsFor(nonce, overrides)));
|
||||
return request(app)
|
||||
.get('/api/auth/google/callback')
|
||||
.query({ code: 'an-auth-code', state })
|
||||
.set('Cookie', cookie);
|
||||
}
|
||||
|
||||
async function identityCount(customerId: number): Promise<number> {
|
||||
const { rows } = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM customer_identities WHERE customer_id = $1`,
|
||||
[customerId]
|
||||
);
|
||||
return requireRow(rows, 'a count of identities').n;
|
||||
}
|
||||
|
||||
it('links when Google vouches for an address an account already holds', async () => {
|
||||
const customerId = await createCustomer('haspassword@example.com');
|
||||
|
||||
const res = await attempt({ email: 'haspassword@example.com', email_verified: true }, '/cart');
|
||||
|
||||
// Whoever completed that sign-in demonstrably controls the mailbox, which
|
||||
// is already the root of trust for a password reset on this account. So
|
||||
// linking grants nothing that was not already reachable.
|
||||
expect(res.headers.location).toBe('/cart');
|
||||
expect(await identityCount(customerId)).toBe(1);
|
||||
expect(res.headers['set-cookie'] as unknown as string[]).toContainEqual(
|
||||
expect.stringContaining('rd_session=')
|
||||
);
|
||||
});
|
||||
|
||||
it('signs the linked customer into their existing account, not a new one', async () => {
|
||||
const customerId = await createCustomer('same@example.com');
|
||||
|
||||
const res = await attempt({ email: 'same@example.com', email_verified: true });
|
||||
const session = requireCookie(res.headers['set-cookie'] as unknown as string[], 'rd_session=');
|
||||
const me = await request(app).get('/api/customers/me').set('Cookie', session);
|
||||
|
||||
expect(me.body.id).toBe(customerId);
|
||||
const { rows } = await pool.query<{ n: number }>(`SELECT count(*)::int AS n FROM customers`);
|
||||
expect(requireRow(rows, 'a count of customers').n).toBe(1);
|
||||
});
|
||||
|
||||
it('refuses when Google does not vouch for the address', async () => {
|
||||
const customerId = await createCustomer('unverified@example.com');
|
||||
|
||||
const res = await attempt({ email: 'unverified@example.com', email_verified: false });
|
||||
|
||||
// The whole policy in one assertion. Linking on an unverified assertion is
|
||||
// not a degraded version of the same thing — it is an account takeover with
|
||||
// extra steps, because nobody has checked the claim.
|
||||
expect(res.headers.location).toBe('/login?auth=google-use-password');
|
||||
expect(await identityCount(customerId)).toBe(0);
|
||||
expect(res.headers['set-cookie'] ?? []).not.toContainEqual(
|
||||
expect.stringContaining('rd_session=')
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses on a merely truthy email_verified, which is the trap', async () => {
|
||||
// The string "false" is truthy. If this check ever becomes a truthiness
|
||||
// test, every unverified Google account links to whatever account holds
|
||||
// its address.
|
||||
const customerId = await createCustomer('trap@example.com');
|
||||
|
||||
const res = await attempt({ email: 'trap@example.com', email_verified: 'false' });
|
||||
|
||||
expect(res.headers.location).toBe('/login?auth=google-use-password');
|
||||
expect(await identityCount(customerId)).toBe(0);
|
||||
});
|
||||
|
||||
it('sends the refused customer somewhere they can act on', async () => {
|
||||
// They have an account and simply cannot reach it this way. Telling them to
|
||||
// use the password they already have beats "that did not work", and reveals
|
||||
// nothing: they arrived holding a Google account for this address.
|
||||
await createCustomer('actionable@example.com');
|
||||
|
||||
const res = await attempt({ email: 'actionable@example.com', email_verified: false });
|
||||
|
||||
expect(res.headers.location).toBe('/login?auth=google-use-password');
|
||||
});
|
||||
|
||||
it('refuses to link to a disabled account', async () => {
|
||||
const customerId = await createCustomer('disabledlink@example.com');
|
||||
await pool.query(`UPDATE customers SET disabled_at = now() WHERE id = $1`, [customerId]);
|
||||
|
||||
const res = await attempt({ email: 'disabledlink@example.com', email_verified: true });
|
||||
|
||||
// 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.
|
||||
expect(await identityCount(customerId)).toBe(0);
|
||||
expect(res.headers['set-cookie'] ?? []).not.toContainEqual(
|
||||
expect.stringContaining('rd_session=')
|
||||
);
|
||||
});
|
||||
|
||||
it('matches the address case-insensitively, as registration stores it', async () => {
|
||||
const customerId = await createCustomer('mixedcase@example.com');
|
||||
|
||||
const res = await attempt({ email: 'MixedCase@Example.COM', email_verified: true });
|
||||
|
||||
// A stricter comparison than registration's would silently fail to match
|
||||
// and produce a second account for one person, rather than an error anyone
|
||||
// sees.
|
||||
expect(await identityCount(customerId)).toBe(1);
|
||||
expect(res.headers.location).toBe('/');
|
||||
});
|
||||
|
||||
it('prefers the identity over the address once linked', async () => {
|
||||
const withIdentity = await createCustomer('theirs@example.com');
|
||||
await linkGoogle(withIdentity, SUB);
|
||||
// A second customer now holds the address this Google account reports.
|
||||
const withAddress = await createCustomer('moved@example.com');
|
||||
|
||||
const res = await attempt({ email: 'moved@example.com', email_verified: true });
|
||||
const session = requireCookie(res.headers['set-cookie'] as unknown as string[], 'rd_session=');
|
||||
const me = await request(app).get('/api/customers/me').set('Cookie', session);
|
||||
|
||||
// The identity lookup runs first and nothing else is consulted. An identity
|
||||
// that has signed in before keeps working even when the address on either
|
||||
// side has since changed — and the account matching the address is somebody
|
||||
// else's, which is exactly why the order matters.
|
||||
expect(me.body.id).toBe(withIdentity);
|
||||
expect(await identityCount(withAddress)).toBe(0);
|
||||
});
|
||||
|
||||
it('does not link twice when the same customer signs in again', async () => {
|
||||
const customerId = await createCustomer('twice@example.com');
|
||||
|
||||
await attempt({ email: 'twice@example.com', email_verified: true });
|
||||
await attempt({ email: 'twice@example.com', email_verified: true });
|
||||
|
||||
expect(await identityCount(customerId)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/customers/me/identities', () => {
|
||||
it('shows the customer what they are linked to', async () => {
|
||||
const customerId = await createCustomer('shown@example.com');
|
||||
await linkGoogle(customerId);
|
||||
const session = await sessionFor(customerId);
|
||||
|
||||
const res = await request(app).get('/api/customers/me/identities').set('Cookie', session);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(res.body[0].provider).toBe('google');
|
||||
});
|
||||
|
||||
it('never returns the provider subject', async () => {
|
||||
const customerId = await createCustomer('opaque@example.com');
|
||||
await linkGoogle(customerId);
|
||||
const session = await sessionFor(customerId);
|
||||
|
||||
const res = await request(app).get('/api/customers/me/identities').set('Cookie', session);
|
||||
|
||||
// The customer cannot act on it, and it is the one value that identifies
|
||||
// them to Google — the same reasoning that keeps credential ids out of the
|
||||
// passkey list.
|
||||
expect(JSON.stringify(res.body)).not.toContain(SUB);
|
||||
expect(res.body[0].provider_sub).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is empty for a customer who has never used a provider', async () => {
|
||||
const customerId = await createCustomer('none@example.com');
|
||||
const session = await sessionFor(customerId);
|
||||
|
||||
const res = await request(app).get('/api/customers/me/identities').set('Cookie', session);
|
||||
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses without a session', async () => {
|
||||
const res = await request(app).get('/api/customers/me/identities');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the storefront offers a Google button at all (#345).
|
||||
*
|
||||
* A boolean and never the client id: the browser does not need one, because
|
||||
* the whole flow is a redirect the server builds.
|
||||
*/
|
||||
describe('GET /api/config, google sign-in', () => {
|
||||
// The suite-wide beforeEach configures Google so the flow above can run.
|
||||
// These tests are about the unconfigured case too, so they start from clean.
|
||||
beforeEach(() => {
|
||||
delete process.env.GOOGLE_CLIENT_ID;
|
||||
delete process.env.GOOGLE_CLIENT_SECRET;
|
||||
});
|
||||
|
||||
it('is false when the environment has no credentials', async () => {
|
||||
const res = await request(app).get('/api/config');
|
||||
|
||||
// Which is the state of local development, and of QA until #313 moves it
|
||||
// off a hostname whose domain nobody can prove they own.
|
||||
expect(res.body.googleSignIn).toBe(false);
|
||||
});
|
||||
|
||||
it('is true when both credentials are set', async () => {
|
||||
process.env.GOOGLE_CLIENT_ID = 'id.apps.googleusercontent.com';
|
||||
process.env.GOOGLE_CLIENT_SECRET = 'shh';
|
||||
|
||||
const res = await request(app).get('/api/config');
|
||||
|
||||
expect(res.body.googleSignIn).toBe(true);
|
||||
});
|
||||
|
||||
it('is false with only one of the pair, matching what the backend refuses to boot on', async () => {
|
||||
process.env.GOOGLE_CLIENT_ID = 'id.apps.googleusercontent.com';
|
||||
|
||||
const res = await request(app).get('/api/config');
|
||||
|
||||
expect(res.body.googleSignIn).toBe(false);
|
||||
});
|
||||
|
||||
it('never sends the client id or secret to the browser', async () => {
|
||||
process.env.GOOGLE_CLIENT_ID = 'id.apps.googleusercontent.com';
|
||||
process.env.GOOGLE_CLIENT_SECRET = 'a-real-looking-secret';
|
||||
|
||||
const res = await request(app).get('/api/config');
|
||||
|
||||
const body = JSON.stringify(res.body);
|
||||
expect(body).not.toContain('a-real-looking-secret');
|
||||
expect(body).not.toContain('googleusercontent');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import request from 'supertest';
|
||||
import app from '../../src/app';
|
||||
import { pool } from '../../src/db';
|
||||
import { pool, requireRow } from '../../src/db';
|
||||
import { createSession } from '../../src/customerSession';
|
||||
import { resetDb, closeDb } from './setup/testDb';
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -204,4 +205,131 @@ describe('POST /api/customers/reset-password', () => {
|
||||
const retry = await request(app).post('/api/customers/reset-password').send({ token, password: 'long-enough-password' });
|
||||
expect(retry.status).toBe(200);
|
||||
});
|
||||
|
||||
// #42. A reset is the recovery path, so it has to leave the account with no
|
||||
// way in that the customer did not just establish. Sessions were already
|
||||
// covered above; a passkey outlives a session without bound.
|
||||
describe('and the passkeys on the account', () => {
|
||||
async function customerId(email: string): Promise<number> {
|
||||
const { rows } = await pool.query<{ id: number }>(`SELECT id FROM customers WHERE email = $1`, [email]);
|
||||
return requireRow(rows, 'the customer this test just registered').id;
|
||||
}
|
||||
|
||||
// Registering one for real needs an authenticator, which no test has. The
|
||||
// row is what the reset acts on, so the row is what these insert.
|
||||
async function giveAPasskey(id: number, credentialId: string): Promise<void> {
|
||||
await pool.query(
|
||||
`INSERT INTO customer_credentials (customer_id, credential_id, public_key, name)
|
||||
VALUES ($1, $2, 'not-a-real-key', 'Test key')`,
|
||||
[id, credentialId]
|
||||
);
|
||||
}
|
||||
|
||||
async function passkeyCount(id: number): Promise<number> {
|
||||
const { rows } = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM customer_credentials WHERE customer_id = $1`,
|
||||
[id]
|
||||
);
|
||||
return requireRow(rows, 'a count of credentials').n;
|
||||
}
|
||||
|
||||
it('removes every passkey, so one an intruder registered does not survive it', async () => {
|
||||
await register('haskeys@example.com');
|
||||
const id = await customerId('haskeys@example.com');
|
||||
await giveAPasskey(id, 'credential-one');
|
||||
await giveAPasskey(id, 'credential-two');
|
||||
|
||||
const token = await requestReset('haskeys@example.com');
|
||||
const res = await request(app)
|
||||
.post('/api/customers/reset-password')
|
||||
.send({ token, password: 'a-brand-new-password' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await passkeyCount(id)).toBe(0);
|
||||
});
|
||||
|
||||
it('says how many it removed, because nothing else can report it afterwards', async () => {
|
||||
await register('counted@example.com');
|
||||
const id = await customerId('counted@example.com');
|
||||
await giveAPasskey(id, 'credential-counted');
|
||||
|
||||
const token = await requestReset('counted@example.com');
|
||||
const res = await request(app)
|
||||
.post('/api/customers/reset-password')
|
||||
.send({ token, password: 'a-brand-new-password' });
|
||||
|
||||
// The rows are gone by the time the customer could go and look, so a
|
||||
// reset that removed something and said nothing would hide exactly the
|
||||
// case worth knowing about.
|
||||
expect(res.body.passkeysRemoved).toBe(1);
|
||||
});
|
||||
|
||||
it('reports zero for a customer who never registered one', async () => {
|
||||
await register('nokeys@example.com');
|
||||
|
||||
const token = await requestReset('nokeys@example.com');
|
||||
const res = await request(app)
|
||||
.post('/api/customers/reset-password')
|
||||
.send({ token, password: 'a-brand-new-password' });
|
||||
|
||||
// The notice on the reset form is shown on this number, so zero has to
|
||||
// mean zero rather than undefined.
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.passkeysRemoved).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves another customer’s passkeys alone', async () => {
|
||||
await register('mine@example.com');
|
||||
await register('theirs@example.com');
|
||||
const mine = await customerId('mine@example.com');
|
||||
const theirs = await customerId('theirs@example.com');
|
||||
await giveAPasskey(mine, 'credential-mine');
|
||||
await giveAPasskey(theirs, 'credential-theirs');
|
||||
|
||||
const token = await requestReset('mine@example.com');
|
||||
await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' });
|
||||
|
||||
expect(await passkeyCount(theirs)).toBe(1);
|
||||
});
|
||||
|
||||
it('clears a challenge in flight, so a registration cannot land after the reset', async () => {
|
||||
await register('inflight@example.com');
|
||||
const id = await customerId('inflight@example.com');
|
||||
await pool.query(
|
||||
`INSERT INTO webauthn_challenges (challenge, customer_id, kind, expires_at)
|
||||
VALUES ('challenge-in-flight', $1, 'registration', now() + interval '5 minutes')`,
|
||||
[id]
|
||||
);
|
||||
|
||||
const token = await requestReset('inflight@example.com');
|
||||
await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' });
|
||||
|
||||
// Otherwise an intruder who pressed "add a passkey" moments earlier could
|
||||
// finish the ceremony afterwards and put a credential back on the account
|
||||
// the reset had just cleared.
|
||||
const { rows } = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM webauthn_challenges WHERE customer_id = $1`,
|
||||
[id]
|
||||
);
|
||||
expect(requireRow(rows, 'a count of challenges').n).toBe(0);
|
||||
});
|
||||
|
||||
it('terminates a session established by a passkey, not only one from a password', async () => {
|
||||
await register('passkeysession@example.com');
|
||||
const id = await customerId('passkeysession@example.com');
|
||||
|
||||
// The call the passkey login route makes. Not an imitation of it — the
|
||||
// same function, so this asserts the shared session path rather than
|
||||
// asserting that two paths happen to agree today.
|
||||
const passkeySession = await createSession(id);
|
||||
const asPasskeyHolder = () =>
|
||||
request(app).get('/api/customers/me').set('Cookie', `rd_session=${passkeySession}`);
|
||||
expect((await asPasskeyHolder()).status).toBe(200);
|
||||
|
||||
const token = await requestReset('passkeysession@example.com');
|
||||
await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' });
|
||||
|
||||
expect((await asPasskeyHolder()).status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import request from 'supertest';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import app from '../../src/app';
|
||||
import { pool, requireRow } from '../../src/db';
|
||||
import { createSession } from '../../src/customerSession';
|
||||
import { PASSWORD_HASH_ROUNDS } from '../../src/passwordHashing';
|
||||
import { resetDb, closeDb } from './setup/testDb';
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
await closeDb();
|
||||
});
|
||||
|
||||
const PASSWORD = 'supersecret123';
|
||||
|
||||
/**
|
||||
* A customer who signed up with Google: no password at all (#344).
|
||||
*
|
||||
* Inserted rather than driven through the OAuth flow, because what these tests
|
||||
* are about is the state, not how it was reached. The flow that produces it has
|
||||
* its own suite.
|
||||
*/
|
||||
async function passwordlessCustomer(email: string): Promise<number> {
|
||||
const { rows } = await pool.query<{ id: number }>(
|
||||
`INSERT INTO customers (email, password_hash, first_name, last_name, email_verified, unsubscribe_token)
|
||||
VALUES ($1, NULL, 'Test', 'Customer', true, $2) RETURNING id`,
|
||||
[email, `unsub-${email}`]
|
||||
);
|
||||
const id = requireRow(rows, 'the passwordless customer').id;
|
||||
await pool.query(
|
||||
`INSERT INTO customer_identities (customer_id, provider, provider_sub) VALUES ($1, 'google', $2)`,
|
||||
[id, `sub-${email}`]
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function customerWithPassword(email: string): Promise<number> {
|
||||
const { rows } = await pool.query<{ id: number }>(
|
||||
`INSERT INTO customers (email, password_hash, first_name, last_name, email_verified, unsubscribe_token)
|
||||
VALUES ($1, $2, 'Test', 'Customer', true, $3) RETURNING id`,
|
||||
[email, await bcrypt.hash(PASSWORD, PASSWORD_HASH_ROUNDS), `unsub-${email}`]
|
||||
);
|
||||
return requireRow(rows, 'the customer with a password').id;
|
||||
}
|
||||
|
||||
async function sessionFor(customerId: number): Promise<string> {
|
||||
return `rd_session=${await createSession(customerId)}`;
|
||||
}
|
||||
|
||||
async function storedHash(customerId: number): Promise<string | null> {
|
||||
const { rows } = await pool.query<{ password_hash: string | null }>(
|
||||
`SELECT password_hash FROM customers WHERE id = $1`,
|
||||
[customerId]
|
||||
);
|
||||
return requireRow(rows, 'the customer').password_hash;
|
||||
}
|
||||
|
||||
describe('an account with no password', () => {
|
||||
describe('setting a first one', () => {
|
||||
it('takes no current password, because there is none to give', async () => {
|
||||
const id = await passwordlessCustomer('first@example.com');
|
||||
const session = await sessionFor(id);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/customers/change-password')
|
||||
.set('Cookie', session)
|
||||
.send({ newPassword: 'a-brand-new-password' });
|
||||
|
||||
// Asking for a value that was never set is a dead end. The session they
|
||||
// are already holding is what authorises this, exactly as it authorises
|
||||
// every other setting on the account page.
|
||||
expect(res.status).toBe(204);
|
||||
expect(await storedHash(id)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('lets them sign in with it afterwards', async () => {
|
||||
const id = await passwordlessCustomer('cansignin@example.com');
|
||||
|
||||
await request(app)
|
||||
.post('/api/customers/change-password')
|
||||
.set('Cookie', await sessionFor(id))
|
||||
.send({ newPassword: 'a-brand-new-password' });
|
||||
|
||||
const login = await request(app)
|
||||
.post('/api/customers/login')
|
||||
.send({ email: 'cansignin@example.com', password: 'a-brand-new-password' });
|
||||
expect(login.status).toBe(200);
|
||||
});
|
||||
|
||||
it('enforces the same minimum length as registration', async () => {
|
||||
const id = await passwordlessCustomer('short@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/customers/change-password')
|
||||
.set('Cookie', await sessionFor(id))
|
||||
.send({ newPassword: 'short' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(await storedHash(id)).toBeNull();
|
||||
});
|
||||
|
||||
it('still demands the current one from an account that has a password', async () => {
|
||||
// The branch is on the stored hash, never on what the caller sends, so a
|
||||
// request cannot talk its way into the first-password case by omitting a
|
||||
// field.
|
||||
const id = await customerWithPassword('haspassword@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/customers/change-password')
|
||||
.set('Cookie', await sessionFor(id))
|
||||
.send({ newPassword: 'a-brand-new-password' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signing in with a password', () => {
|
||||
it('is refused exactly as a wrong password is', async () => {
|
||||
await passwordlessCustomer('oracle@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/customers/login')
|
||||
.send({ email: 'oracle@example.com', password: 'anything-at-all' });
|
||||
|
||||
// Answering "this account has no password" would turn the login form into
|
||||
// an oracle for which customers use Google. One refusal for every cause,
|
||||
// and the account page is where a signed-in customer learns what they
|
||||
// have.
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe('invalid email or password');
|
||||
});
|
||||
|
||||
it('is refused for a blank password too, rather than matching an absent hash', async () => {
|
||||
await passwordlessCustomer('blank@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/customers/login')
|
||||
.send({ email: 'blank@example.com', password: '' });
|
||||
|
||||
// Both sides missing is the combination most tempting to call a match,
|
||||
// and calling it one would let anyone sign in as any Google-only customer.
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changing the email address', () => {
|
||||
it('is refused, and says why rather than claiming a password was wrong', async () => {
|
||||
const id = await passwordlessCustomer('moving@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/customers/me/email')
|
||||
.set('Cookie', await sessionFor(id))
|
||||
.send({ email: 'somewhere-else@example.com' });
|
||||
|
||||
// Changing the address is a change to where recovery goes: whoever holds
|
||||
// the new one 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 accounts that cannot meet it would remove the
|
||||
// protection from exactly the ones that need it.
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toMatch(/no password/);
|
||||
});
|
||||
|
||||
it('works once they have set one', async () => {
|
||||
const id = await passwordlessCustomer('thenmoving@example.com');
|
||||
const session = await sessionFor(id);
|
||||
await request(app)
|
||||
.post('/api/customers/change-password')
|
||||
.set('Cookie', session)
|
||||
.send({ newPassword: 'a-brand-new-password' });
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/customers/me/email')
|
||||
.set('Cookie', session)
|
||||
.send({ email: 'moved@example.com', currentPassword: 'a-brand-new-password' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleting the account', () => {
|
||||
it('works, because deletion never asked for a password', async () => {
|
||||
const id = await passwordlessCustomer('deleting@example.com');
|
||||
|
||||
const res = await request(app).delete('/api/customers/me').set('Cookie', await sessionFor(id));
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
const { rows } = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM customers WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
expect(requireRow(rows, 'a count of customers').n).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the passkey lockout guard, which becomes reachable here', () => {
|
||||
async function givePasskey(customerId: number, credentialId: string): Promise<number> {
|
||||
const { rows } = await pool.query<{ id: number }>(
|
||||
`INSERT INTO customer_credentials (customer_id, credential_id, public_key, name)
|
||||
VALUES ($1, $2, 'not-a-real-key', 'Phone') RETURNING id`,
|
||||
[customerId, credentialId]
|
||||
);
|
||||
return requireRow(rows, 'the credential just created').id;
|
||||
}
|
||||
|
||||
it('refuses to remove the last way into an account with no password', async () => {
|
||||
// Written in #40 against the condition rather than the schema, and
|
||||
// unreachable until now because password_hash was NOT NULL. This is the
|
||||
// first test that actually exercises it.
|
||||
const id = await passwordlessCustomer('lastway@example.com');
|
||||
const credentialId = await givePasskey(id, 'only-credential');
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/customers/me/passkeys/${credentialId}`)
|
||||
.set('Cookie', await sessionFor(id));
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toMatch(/only way you can sign in/);
|
||||
});
|
||||
|
||||
it('allows it when a second passkey remains', async () => {
|
||||
const id = await passwordlessCustomer('twokeys@example.com');
|
||||
const first = await givePasskey(id, 'credential-one');
|
||||
await givePasskey(id, 'credential-two');
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/customers/me/passkeys/${first}`)
|
||||
.set('Cookie', await sessionFor(id));
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it('allows it once a password has been set', async () => {
|
||||
const id = await passwordlessCustomer('nowhaspassword@example.com');
|
||||
const credentialId = await givePasskey(id, 'credential-with-password');
|
||||
const session = await sessionFor(id);
|
||||
await request(app)
|
||||
.post('/api/customers/change-password')
|
||||
.set('Cookie', session)
|
||||
.send({ newPassword: 'a-brand-new-password' });
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/customers/me/passkeys/${credentialId}`)
|
||||
.set('Cookie', session);
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetting a password that was never set', () => {
|
||||
it('gives them one, which is a reasonable answer rather than an error', async () => {
|
||||
await passwordlessCustomer('resetting@example.com');
|
||||
await request(app)
|
||||
.post('/api/customers/request-password-reset')
|
||||
.send({ email: 'resetting@example.com' });
|
||||
const { rows } = await pool.query<{ token: string }>(
|
||||
`SELECT t.token FROM customer_tokens t JOIN customers c ON c.id = t.customer_id
|
||||
WHERE c.email = $1 AND t.kind = 'password_reset'`,
|
||||
['resetting@example.com']
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/customers/reset-password')
|
||||
.send({ token: requireRow(rows, 'the reset token').token, password: 'a-brand-new-password' });
|
||||
|
||||
// The reset path sets a hash and does not care whether one was there
|
||||
// before. A customer who reaches for "forgot password" without ever
|
||||
// having had one gets a working password, which is what they were asking
|
||||
// for.
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('removes their passkeys, which is worth knowing rather than assuming', async () => {
|
||||
// #42 made a reset remove every passkey, on the reasoning that recovery
|
||||
// has to be complete. That still holds here: nothing about this path
|
||||
// identifies who asked, and a Google-only customer resetting a password
|
||||
// they never had is not obviously in a better position than one who did.
|
||||
const id = await passwordlessCustomer('resetkeys@example.com');
|
||||
await pool.query(
|
||||
`INSERT INTO customer_credentials (customer_id, credential_id, public_key, name)
|
||||
VALUES ($1, 'reset-credential', 'not-a-real-key', 'Phone')`,
|
||||
[id]
|
||||
);
|
||||
await request(app)
|
||||
.post('/api/customers/request-password-reset')
|
||||
.send({ email: 'resetkeys@example.com' });
|
||||
const { rows } = await pool.query<{ token: string }>(
|
||||
`SELECT t.token FROM customer_tokens t JOIN customers c ON c.id = t.customer_id
|
||||
WHERE c.email = $1 AND t.kind = 'password_reset'`,
|
||||
['resetkeys@example.com']
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/customers/reset-password')
|
||||
.send({ token: requireRow(rows, 'the reset token').token, password: 'a-brand-new-password' });
|
||||
|
||||
expect(res.body.passkeysRemoved).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves the Google identity attached, so they keep both ways in', async () => {
|
||||
const id = await passwordlessCustomer('keepsgoogle@example.com');
|
||||
await request(app)
|
||||
.post('/api/customers/request-password-reset')
|
||||
.send({ email: 'keepsgoogle@example.com' });
|
||||
const { rows } = await pool.query<{ token: string }>(
|
||||
`SELECT t.token FROM customer_tokens t JOIN customers c ON c.id = t.customer_id
|
||||
WHERE c.email = $1 AND t.kind = 'password_reset'`,
|
||||
['keepsgoogle@example.com']
|
||||
);
|
||||
|
||||
await request(app)
|
||||
.post('/api/customers/reset-password')
|
||||
.send({ token: requireRow(rows, 'the reset token').token, password: 'a-brand-new-password' });
|
||||
|
||||
// Deliberately not removed alongside the passkeys. A passkey is a
|
||||
// credential this shop issued and can revoke; a Google identity is one
|
||||
// Google holds, and severing 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.
|
||||
const { rows: identities } = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM customer_identities WHERE customer_id = $1`,
|
||||
[id]
|
||||
);
|
||||
expect(requireRow(identities, 'a count of identities').n).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what the account page is told', () => {
|
||||
it('reports has_password false for a Google-only customer', async () => {
|
||||
const id = await passwordlessCustomer('told@example.com');
|
||||
|
||||
const res = await request(app).get('/api/customers/me').set('Cookie', await sessionFor(id));
|
||||
|
||||
expect(res.body.has_password).toBe(false);
|
||||
});
|
||||
|
||||
it('reports it true once one is set', async () => {
|
||||
const id = await passwordlessCustomer('nowtrue@example.com');
|
||||
const session = await sessionFor(id);
|
||||
await request(app)
|
||||
.post('/api/customers/change-password')
|
||||
.set('Cookie', session)
|
||||
.send({ newPassword: 'a-brand-new-password' });
|
||||
|
||||
const res = await request(app).get('/api/customers/me').set('Cookie', session);
|
||||
|
||||
expect(res.body.has_password).toBe(true);
|
||||
});
|
||||
|
||||
it('never returns the hash itself', async () => {
|
||||
const id = await customerWithPassword('nohash@example.com');
|
||||
|
||||
const res = await request(app).get('/api/customers/me').set('Cookie', await sessionFor(id));
|
||||
|
||||
expect(res.body.password_hash).toBeUndefined();
|
||||
expect(JSON.stringify(res.body)).not.toContain('$2');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,7 @@ describe('when the database loses its schema', () => {
|
||||
|
||||
// The count and a few names, not the whole list: the point is that a reader
|
||||
// can tell at a glance this is a missing schema rather than a logic bug.
|
||||
await expect(assertSchemaPresent('in a test')).rejects.toThrow(/Missing 18 of 18 tables/);
|
||||
await expect(assertSchemaPresent('in a test')).rejects.toThrow(/Missing 22 of 22 tables/);
|
||||
await expect(assertSchemaPresent('in a test')).rejects.toThrow(/items/);
|
||||
|
||||
await migrate();
|
||||
|
||||
@@ -23,9 +23,52 @@ async function waitForDb(retries = 20): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every address the database host resolves to, and which server answered (#154).
|
||||
*
|
||||
* This is the measurement that issue has needed twice and never had. The
|
||||
* schema in a failing run comes back — a suite fails on a missing `orders`,
|
||||
* and a later suite truncating that same table passes — which a database
|
||||
* losing its schema cannot do. More than one server answering to one name can,
|
||||
* and Docker's 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`.
|
||||
*
|
||||
* **More than one address printed here is the answer outright.** One address,
|
||||
* and this reading is wrong too — the next suspect is a single container being
|
||||
* restarted with a fresh data directory, which the postmaster start time
|
||||
* recorded alongside will show.
|
||||
*
|
||||
* Logged unconditionally rather than only on failure: a passing run's addresses
|
||||
* are the control, and without them a failing run's have nothing to be compared
|
||||
* against. Never throws — a diagnostic that can fail the run it was added to
|
||||
* explain is worse than none.
|
||||
*/
|
||||
async function reportDatabaseIdentity(): Promise<void> {
|
||||
const host = process.env.TEST_PGHOST || 'localhost';
|
||||
try {
|
||||
const { lookup } = await import('dns/promises');
|
||||
const addresses = await lookup(host, { all: true });
|
||||
const rendered = addresses.map((a) => a.address).join(', ');
|
||||
console.info(
|
||||
`[#154] "${host}" resolves to ${addresses.length} address(es): ${rendered}` +
|
||||
(addresses.length > 1 ? ' <-- more than one server can answer; this is the bug' : '')
|
||||
);
|
||||
} catch (err) {
|
||||
console.info(`[#154] could not resolve "${host}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default async function globalSetup(): Promise<void> {
|
||||
await waitForDb();
|
||||
const { migrate, closeDb, assertSchemaPresent } = await import('./testDb');
|
||||
const { migrate, closeDb, assertSchemaPresent, describeBackend } = await import('./testDb');
|
||||
|
||||
await reportDatabaseIdentity();
|
||||
// Recorded before migrating so the run's baseline is in the log even if the
|
||||
// migration is the thing that fails. A suite that later reports a different
|
||||
// postmaster start time is talking to a different instance.
|
||||
console.info(`[#154] ${await describeBackend('globalSetup reached')}`);
|
||||
|
||||
await migrate();
|
||||
|
||||
// Cheap, once, and it establishes the fact the rest of the run depends on:
|
||||
|
||||
@@ -37,6 +37,9 @@ const REQUIRED_TABLES = [
|
||||
'categories',
|
||||
'checkout_items',
|
||||
'checkouts',
|
||||
'customer_credentials',
|
||||
'customer_email_changes',
|
||||
'customer_identities',
|
||||
'customer_sessions',
|
||||
'customer_tokens',
|
||||
'customers',
|
||||
@@ -48,7 +51,8 @@ const REQUIRED_TABLES = [
|
||||
'orders',
|
||||
'shipping_addresses',
|
||||
'tags',
|
||||
'upload_links'
|
||||
'upload_links',
|
||||
'webauthn_challenges'
|
||||
] as const;
|
||||
|
||||
/** Which of them the database does not currently have. */
|
||||
@@ -75,21 +79,68 @@ async function missingTables(): Promise<string[]> {
|
||||
* can be fixed from here: whatever the cause, the next occurrence should read as
|
||||
* "the database lost its schema" on the first line.
|
||||
*/
|
||||
/**
|
||||
* Which Postgres actually answered, rather than which one we asked for (#154).
|
||||
*
|
||||
* The reason this exists: the schema in a failing run **comes back**. A suite
|
||||
* fails because `orders` does not exist, and a later suite truncating the same
|
||||
* table passes. A dropped database does not un-drop itself, so more than one
|
||||
* server must be answering to the same name — Docker's embedded DNS round-robins
|
||||
* every container sharing an alias, so a leftover service container from an
|
||||
* earlier run would produce exactly this.
|
||||
*
|
||||
* `pg_postmaster_start_time()` is what settles it and needs no special rights:
|
||||
* two Postgres instances cannot share one. Differing values across a single run
|
||||
* are proof outright, where a differing `inet_server_addr` alone could be argued
|
||||
* to be one container that moved.
|
||||
*
|
||||
* Never throws. This is a diagnostic, and a diagnostic that can fail a run it
|
||||
* was added to explain is worse than no diagnostic.
|
||||
*/
|
||||
export async function describeBackend(label: string): Promise<string> {
|
||||
try {
|
||||
const { rows } = await testPool.query<{
|
||||
addr: string | null;
|
||||
started: string;
|
||||
pid: number;
|
||||
db: string;
|
||||
}>(
|
||||
`SELECT inet_server_addr()::text AS addr,
|
||||
pg_postmaster_start_time()::text AS started,
|
||||
pg_backend_pid() AS pid,
|
||||
current_database() AS db`
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return `${label}: no row returned`;
|
||||
return `${label}: db=${row.db} addr=${row.addr ?? 'local'} postmaster_start=${row.started} pid=${row.pid}`;
|
||||
} catch (err) {
|
||||
return `${label}: could not be identified (${err instanceof Error ? err.message : String(err)})`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertSchemaPresent(context: string): Promise<void> {
|
||||
const missing = await missingTables();
|
||||
if (missing.length === 0) return;
|
||||
|
||||
// Gathered only on the failure path, which is the one worth paying for, and
|
||||
// is where #154 has repeatedly lacked the one fact that would identify it.
|
||||
const backend = await describeBackend('Answering server');
|
||||
|
||||
throw new Error(
|
||||
`The test database has no schema (${context}).
|
||||
|
||||
` +
|
||||
`Missing ${missing.length} of ${REQUIRED_TABLES.length} tables: ${missing.join(', ')}.
|
||||
|
||||
` +
|
||||
`${backend}
|
||||
|
||||
` +
|
||||
`Migrations ran at the start of this run, so the schema existed and has since gone. ` +
|
||||
`Nothing in this suite drops tables — TRUNCATE does not — so the database itself was ` +
|
||||
`replaced or restarted underneath the run. On CI the likeliest cause is the Postgres ` +
|
||||
`service container being recreated, which comes back with an empty data directory. ` +
|
||||
`Nothing in this suite drops tables — TRUNCATE does not. Compare the postmaster start ` +
|
||||
`time above against the one globalSetup logged: if they differ, this is a different ` +
|
||||
`Postgres instance answering to the same name rather than one database losing its ` +
|
||||
`schema, and the schema was never lost at all. ` +
|
||||
`See #154.`
|
||||
);
|
||||
}
|
||||
@@ -104,6 +155,7 @@ export async function resetDb(): Promise<void> {
|
||||
await testPool.query(`
|
||||
TRUNCATE TABLE item_drafts, upload_links, orders, checkout_items, checkouts,
|
||||
shipping_addresses, cart_items, carts, customer_tokens, customer_sessions, favorites,
|
||||
webauthn_challenges, customer_credentials, customer_email_changes, customer_identities,
|
||||
customers, item_tags, item_images, items, tags, categories
|
||||
RESTART IDENTITY CASCADE
|
||||
`);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { analyticsConsent } from '../../src/routes/customers';
|
||||
import { ANALYTICS_CONSENT_TEXT, MARKETING_CONSENT_TEXT } from '../../src/utils';
|
||||
|
||||
/**
|
||||
* The rule this file exists for: the Brevo tracker runs only for a customer who
|
||||
* agreed to the *analytics* sentence, and marketing consent has nothing to do
|
||||
* with it (#56).
|
||||
*
|
||||
* Both halves matter and both are compliance requirements rather than taste.
|
||||
* GDPR requires consent to be granular — email and tracking are separate
|
||||
* purposes with separate recipients, and current EDPB guidance treats bundling
|
||||
* them as invalid. Quebec's Law 25 s.8.1 requires profiling to be off until the
|
||||
* person switches it on, which is why the column defaults to false.
|
||||
*
|
||||
* The failure mode is silent: reading the wrong flag tracks people whose
|
||||
* marketing consent really is `true` and who never agreed to any of this.
|
||||
*/
|
||||
describe('analyticsConsent', () => {
|
||||
it('is true for a customer who agreed to the current analytics wording', () => {
|
||||
expect(
|
||||
analyticsConsent({ analytics_consent: true, analytics_consent_text: ANALYTICS_CONSENT_TEXT })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when analytics consent was never given', () => {
|
||||
// Where the migration leaves every existing customer, and where Law 25
|
||||
// requires a new one to start.
|
||||
expect(
|
||||
analyticsConsent({ analytics_consent: false, analytics_consent_text: null })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when the stored wording is not the current one', () => {
|
||||
// Re-wording the sentence re-asks rather than assuming. Anyone who agreed
|
||||
// to a previous version stops qualifying until they agree to this one.
|
||||
expect(
|
||||
analyticsConsent({ analytics_consent: true, analytics_consent_text: 'some older sentence' })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when the flag is set but no wording was recorded', () => {
|
||||
expect(
|
||||
analyticsConsent({ analytics_consent: true, analytics_consent_text: null })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when withdrawal wrote its reason rather than the consent text', () => {
|
||||
expect(
|
||||
analyticsConsent({
|
||||
analytics_consent: false,
|
||||
analytics_consent_text: 'Withdrew analytics consent via account settings'
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not accept a near-miss', () => {
|
||||
expect(
|
||||
analyticsConsent({
|
||||
analytics_consent: true,
|
||||
analytics_consent_text: `${ANALYTICS_CONSENT_TEXT} `
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
describe('the two consents stay separate', () => {
|
||||
// Not a tautology. These fail if anyone re-bundles the purposes — either by
|
||||
// folding tracking back into the marketing sentence, or by pointing this
|
||||
// function at the marketing columns — which is the specific pattern GDPR
|
||||
// and Law 25 both reject, and which this project shipped once already
|
||||
// before it was caught.
|
||||
it('marketing consent alone does not authorise tracking', () => {
|
||||
expect(
|
||||
analyticsConsent({ analytics_consent: false, analytics_consent_text: MARKETING_CONSENT_TEXT })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('the marketing sentence says nothing about tracking', () => {
|
||||
expect(MARKETING_CONSENT_TEXT).not.toContain('browse');
|
||||
expect(MARKETING_CONSENT_TEXT).not.toContain('Brevo');
|
||||
});
|
||||
|
||||
it('the analytics sentence names the recipient and says it is optional', () => {
|
||||
// Informed consent means the customer can tell who receives their data;
|
||||
// "our email provider" is not something they can act on.
|
||||
expect(ANALYTICS_CONSENT_TEXT).toContain('Brevo');
|
||||
expect(ANALYTICS_CONSENT_TEXT).toContain('optional');
|
||||
});
|
||||
|
||||
it('the two sentences are not the same string', () => {
|
||||
expect(ANALYTICS_CONSENT_TEXT).not.toBe(MARKETING_CONSENT_TEXT);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
defaultCredentialName,
|
||||
readCredentialName,
|
||||
GENERIC_CREDENTIAL_NAME,
|
||||
MAX_CREDENTIAL_NAME_LENGTH
|
||||
} from '../../src/passkeys/credentialName';
|
||||
|
||||
/**
|
||||
* The management screen (#40) lists credentials and offers to revoke them, so a
|
||||
* default that says nothing produces a list of identical rows and a customer
|
||||
* removing a device at random. These are about that list being readable.
|
||||
*/
|
||||
describe('defaultCredentialName', () => {
|
||||
it('calls a cross-device authenticator a phone or tablet', () => {
|
||||
expect(defaultCredentialName(['hybrid'])).toBe('Phone or tablet');
|
||||
});
|
||||
|
||||
it('prefers hybrid over internal when both are reported', () => {
|
||||
// A phone used as a cross-device passkey commonly reports both, and "Phone
|
||||
// or tablet" is the more useful reading — `internal` on its own means the
|
||||
// authenticator built into the machine in front of the customer.
|
||||
expect(defaultCredentialName(['internal', 'hybrid'])).toBe('Phone or tablet');
|
||||
});
|
||||
|
||||
it('calls a platform authenticator this device', () => {
|
||||
expect(defaultCredentialName(['internal'])).toBe('This device');
|
||||
});
|
||||
|
||||
it.each([['usb'], ['nfc'], ['ble']])('calls %s a security key', (transport) => {
|
||||
expect(defaultCredentialName([transport])).toBe('Security key');
|
||||
});
|
||||
|
||||
it.each([[[]], [null], [undefined], [['something-new']]])(
|
||||
'falls back to the generic name for %p',
|
||||
(transports) => {
|
||||
// Transports are a hint the authenticator supplies, so an unrecognised
|
||||
// one is expected rather than exceptional — the list grows.
|
||||
expect(defaultCredentialName(transports)).toBe(GENERIC_CREDENTIAL_NAME);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('readCredentialName', () => {
|
||||
it('takes a name the customer gave', () => {
|
||||
expect(readCredentialName('Work laptop')).toBe('Work laptop');
|
||||
});
|
||||
|
||||
it('trims, because a padded name is not a different name', () => {
|
||||
expect(readCredentialName(' Work laptop ')).toBe('Work laptop');
|
||||
});
|
||||
|
||||
it.each([[' '], [''], [null], [undefined], [42], [{}]])(
|
||||
'returns null for %p so the caller falls back to a default',
|
||||
(value) => {
|
||||
// A name of spaces is a row the customer cannot read, and a non-string is
|
||||
// a client sending something unexpected. Both mean "no name given".
|
||||
expect(readCredentialName(value)).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it('bounds the length, because this is rendered in a table cell', () => {
|
||||
const long = 'a'.repeat(MAX_CREDENTIAL_NAME_LENGTH + 50);
|
||||
|
||||
expect(readCredentialName(long)).toHaveLength(MAX_CREDENTIAL_NAME_LENGTH);
|
||||
});
|
||||
});
|
||||
@@ -265,7 +265,7 @@ describe('greeting, built from the configured format', () => {
|
||||
});
|
||||
|
||||
describe('every template can address the customer', () => {
|
||||
// Not KEYS: this is an invariant of the six customer-facing templates only.
|
||||
// Not KEYS: this is an invariant of the customer-facing templates only.
|
||||
// intakeDraft and uploadLink notify the shop and a contributor respectively,
|
||||
// not a customer with a name on file, so they are deliberately not held to
|
||||
// it — a hardcoded list is correct here rather than a staleness risk,
|
||||
@@ -277,7 +277,8 @@ describe('every template can address the customer', () => {
|
||||
'favoriteSold',
|
||||
'favoriteWithdrawn',
|
||||
'cartReminder',
|
||||
'emailChanged'
|
||||
'emailChanged',
|
||||
'emailChangedByAdmin'
|
||||
];
|
||||
|
||||
it.each(CUSTOMER_FACING_KEYS)('%s offers greeting, firstName and lastName', (key) => {
|
||||
|
||||
@@ -251,4 +251,44 @@ describe('UPLOADS_BASE_URL', () => {
|
||||
expect(warnings.join(' ')).not.toMatch(/INTAKE_ACTION_SECRET/);
|
||||
});
|
||||
});
|
||||
|
||||
// #340. All or nothing, and half-configured is the case worth catching: the
|
||||
// failure would otherwise arrive when a customer presses the button.
|
||||
describe('Google sign-in credentials', () => {
|
||||
const BOTH = { GOOGLE_CLIENT_ID: 'id', GOOGLE_CLIENT_SECRET: 'shh', PUBLIC_URL: 'https://x.test' };
|
||||
|
||||
it('are not required', () => {
|
||||
expect(validateEnv(MINIMAL).errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('warn when absent, so a missing button has a stated reason', () => {
|
||||
expect(validateEnv(MINIMAL).warnings.join(' ')).toMatch(/Google sign-in is not configured/);
|
||||
});
|
||||
|
||||
it('refuse to start with only the client id', () => {
|
||||
const { errors } = validateEnv(withEnv({ GOOGLE_CLIENT_ID: 'id' }));
|
||||
expect(errors.join(' ')).toMatch(/GOOGLE_CLIENT_SECRET is required/);
|
||||
});
|
||||
|
||||
it('refuse to start with only the client secret', () => {
|
||||
const { errors } = validateEnv(withEnv({ GOOGLE_CLIENT_SECRET: 'shh' }));
|
||||
expect(errors.join(' ')).toMatch(/GOOGLE_CLIENT_ID is required/);
|
||||
});
|
||||
|
||||
it('are accepted when both are set alongside PUBLIC_URL', () => {
|
||||
const { errors, warnings } = validateEnv(withEnv(BOTH));
|
||||
expect(errors).toEqual([]);
|
||||
expect(warnings.join(' ')).not.toMatch(/Google sign-in/);
|
||||
});
|
||||
|
||||
it('warn when configured without PUBLIC_URL, because the callback falls back to localhost', () => {
|
||||
// Correct locally and wrong everywhere else, which is precisely the shape
|
||||
// that needs saying out loud rather than failing at Google later.
|
||||
const { errors, warnings } = validateEnv(
|
||||
withEnv({ GOOGLE_CLIENT_ID: 'id', GOOGLE_CLIENT_SECRET: 'shh' })
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
expect(warnings.join(' ')).toMatch(/redirect URI falls back to localhost/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { googleConfig, GOOGLE_CALLBACK_PATH } from '../../src/google/config';
|
||||
|
||||
const CREDENTIALS = { GOOGLE_CLIENT_ID: 'id.apps.googleusercontent.com', GOOGLE_CLIENT_SECRET: 'shh' };
|
||||
|
||||
/**
|
||||
* The redirect URI is compared by Google as an exact string, and a mismatch
|
||||
* answers `redirect_uri_mismatch` — accurate, and silent about which half is
|
||||
* wrong. So these assert the derivation itself rather than that the function
|
||||
* returns something, and most of them are about what must *not* end up in it.
|
||||
*/
|
||||
describe('googleConfig', () => {
|
||||
it('builds the redirect URI from PUBLIC_URL', () => {
|
||||
const config = googleConfig({ ...CREDENTIALS, PUBLIC_URL: 'https://redefined-designs.com' });
|
||||
|
||||
expect(config.redirectUri).toBe(`https://redefined-designs.com${GOOGLE_CALLBACK_PATH}`);
|
||||
});
|
||||
|
||||
it('normalises a trailing slash away, rather than producing a double slash', () => {
|
||||
// The failure this prevents is worth naming: "https://x.com//api/..." is a
|
||||
// different string from the one registered in the console, and it is an
|
||||
// easy way to write PUBLIC_URL.
|
||||
const config = googleConfig({ ...CREDENTIALS, PUBLIC_URL: 'https://redefined-designs.com/' });
|
||||
|
||||
expect(config.redirectUri).toBe(`https://redefined-designs.com${GOOGLE_CALLBACK_PATH}`);
|
||||
expect(config.redirectUri).not.toContain('//api');
|
||||
});
|
||||
|
||||
it('discards a path on PUBLIC_URL', () => {
|
||||
const config = googleConfig({ ...CREDENTIALS, PUBLIC_URL: 'https://redefined-designs.com/shop' });
|
||||
|
||||
expect(config.redirectUri).toBe(`https://redefined-designs.com${GOOGLE_CALLBACK_PATH}`);
|
||||
});
|
||||
|
||||
it('keeps a non-default port, because the console entry carries one too', () => {
|
||||
const config = googleConfig({ ...CREDENTIALS, PUBLIC_URL: 'http://localhost:5173' });
|
||||
|
||||
expect(config.redirectUri).toBe(`http://localhost:5173${GOOGLE_CALLBACK_PATH}`);
|
||||
});
|
||||
|
||||
it('falls back to localhost when PUBLIC_URL is unset', () => {
|
||||
// Local development legitimately has no PUBLIC_URL: envValidation only
|
||||
// demands it alongside SMTP. localhost is also the one host Google accepts
|
||||
// without an authorized domain, so the fallback is the only value that
|
||||
// could work here anyway.
|
||||
const config = googleConfig({ ...CREDENTIALS });
|
||||
|
||||
expect(config.redirectUri).toBe(`http://localhost:3000${GOOGLE_CALLBACK_PATH}`);
|
||||
});
|
||||
|
||||
it('refuses a PUBLIC_URL that is not a URL', () => {
|
||||
expect(() => googleConfig({ ...CREDENTIALS, PUBLIC_URL: 'redefined-designs.com' })).toThrow(
|
||||
/not a URL/
|
||||
);
|
||||
});
|
||||
|
||||
describe('enabled', () => {
|
||||
it('is true only when both credentials are present', () => {
|
||||
expect(googleConfig(CREDENTIALS).enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when neither is set, which is a legitimate environment', () => {
|
||||
// A developer without credentials gets a storefront that works and does
|
||||
// not offer the button, rather than one that offers it and fails.
|
||||
expect(googleConfig({}).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['only the id', { GOOGLE_CLIENT_ID: 'id' }],
|
||||
['only the secret', { GOOGLE_CLIENT_SECRET: 'shh' }]
|
||||
])('is false with %s', (_label, env) => {
|
||||
expect(googleConfig(env).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('treats whitespace as absent', () => {
|
||||
// A stack variable set to an empty string is the same as one never set,
|
||||
// and it is the shape a copied-and-blanked entry takes.
|
||||
expect(googleConfig({ GOOGLE_CLIENT_ID: ' ', GOOGLE_CLIENT_SECRET: 'shh' }).enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { authorizationUrl, codeChallenge, newAttempt, verifiedIdentity } from '../../src/google/oauth';
|
||||
import type { GoogleConfig } from '../../src/google/config';
|
||||
|
||||
const CONFIG: GoogleConfig = {
|
||||
clientId: 'id.apps.googleusercontent.com',
|
||||
clientSecret: 'shh',
|
||||
redirectUri: 'https://redefined-designs.com/api/auth/google/callback',
|
||||
enabled: true
|
||||
};
|
||||
|
||||
const NONCE = 'the-nonce-for-this-attempt';
|
||||
|
||||
/** An id token with the given claims. Unsigned, because nothing here reads a signature. */
|
||||
function idToken(claims: Record<string, unknown>): string {
|
||||
const part = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
return `${part({ alg: 'RS256' })}.${part(claims)}.not-a-signature`;
|
||||
}
|
||||
|
||||
const VALID = {
|
||||
iss: 'https://accounts.google.com',
|
||||
aud: CONFIG.clientId,
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
sub: '1234567890',
|
||||
nonce: NONCE,
|
||||
email: 'Customer@Example.com',
|
||||
email_verified: true,
|
||||
given_name: 'Test',
|
||||
family_name: 'Customer'
|
||||
};
|
||||
|
||||
/** VALID minus one claim, for the tests about a claim being absent. */
|
||||
function without(claim: keyof typeof VALID): Record<string, unknown> {
|
||||
const copy: Record<string, unknown> = { ...VALID };
|
||||
delete copy[claim];
|
||||
return copy;
|
||||
}
|
||||
|
||||
|
||||
describe('authorizationUrl', () => {
|
||||
const attempt = { state: 'st', nonce: 'no', codeVerifier: 'ver' };
|
||||
|
||||
it('sends the customer to Google with the code flow and PKCE', () => {
|
||||
const url = new URL(authorizationUrl(CONFIG, attempt));
|
||||
|
||||
expect(url.origin + url.pathname).toBe('https://accounts.google.com/o/oauth2/v2/auth');
|
||||
expect(url.searchParams.get('response_type')).toBe('code');
|
||||
expect(url.searchParams.get('client_id')).toBe(CONFIG.clientId);
|
||||
expect(url.searchParams.get('redirect_uri')).toBe(CONFIG.redirectUri);
|
||||
expect(url.searchParams.get('state')).toBe('st');
|
||||
expect(url.searchParams.get('nonce')).toBe('no');
|
||||
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
expect(url.searchParams.get('code_challenge')).toBe(codeChallenge('ver'));
|
||||
});
|
||||
|
||||
it('asks for exactly the three non-sensitive scopes', () => {
|
||||
// Anything beyond these turns publishing into a verification review with a
|
||||
// video walkthrough and a wait measured in weeks.
|
||||
const url = new URL(authorizationUrl(CONFIG, attempt));
|
||||
|
||||
expect(url.searchParams.get('scope')?.split(' ').sort()).toEqual(['email', 'openid', 'profile']);
|
||||
});
|
||||
|
||||
it('never asks for offline access', () => {
|
||||
// A refresh token would be a long-lived credential with nothing to spend it
|
||||
// on. Google issues one only when asked, so the check is that we do not ask.
|
||||
const url = new URL(authorizationUrl(CONFIG, attempt));
|
||||
|
||||
expect(url.searchParams.get('access_type')).toBeNull();
|
||||
expect(url.searchParams.get('prompt')).toBeNull();
|
||||
});
|
||||
|
||||
it('never puts the client secret in a URL the browser follows', () => {
|
||||
expect(authorizationUrl(CONFIG, attempt)).not.toContain(CONFIG.clientSecret);
|
||||
});
|
||||
});
|
||||
|
||||
describe('newAttempt', () => {
|
||||
it('mints three different secrets', () => {
|
||||
// Three rather than one reused: they are checked by different parties at
|
||||
// different moments, and a single value would mean anything learning it
|
||||
// from one check satisfies the others.
|
||||
const { state, nonce, codeVerifier } = newAttempt();
|
||||
|
||||
expect(new Set([state, nonce, codeVerifier]).size).toBe(3);
|
||||
});
|
||||
|
||||
it('does not repeat itself', () => {
|
||||
expect(newAttempt().state).not.toBe(newAttempt().state);
|
||||
});
|
||||
});
|
||||
|
||||
describe('codeChallenge', () => {
|
||||
it('is the base64url SHA-256 of the verifier, which is what S256 means', () => {
|
||||
const verifier = 'a-verifier';
|
||||
|
||||
expect(codeChallenge(verifier)).toBe(
|
||||
crypto.createHash('sha256').update(verifier).digest('base64url')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The security of the whole flow lives here.
|
||||
*
|
||||
* No signature is verified, because the token arrives on a direct TLS
|
||||
* connection to Google's token endpoint — the case OpenID Connect explicitly
|
||||
* permits skipping it. That makes every one of these claim checks load-bearing
|
||||
* rather than belt-and-braces, so each has a test naming what accepting it
|
||||
* blindly would allow.
|
||||
*/
|
||||
describe('verifiedIdentity', () => {
|
||||
const expected = { clientId: CONFIG.clientId, nonce: NONCE };
|
||||
|
||||
it('accepts a well-formed token and reports who signed in', () => {
|
||||
const identity = verifiedIdentity(idToken(VALID), expected);
|
||||
|
||||
expect(identity.sub).toBe('1234567890');
|
||||
expect(identity.emailVerified).toBe(true);
|
||||
expect(identity.firstName).toBe('Test');
|
||||
expect(identity.lastName).toBe('Customer');
|
||||
});
|
||||
|
||||
it('normalises the email the way registration does', () => {
|
||||
// A stricter comparison than registration's would silently fail to match an
|
||||
// existing customer and produce a duplicate account instead (#343).
|
||||
expect(verifiedIdentity(idToken(VALID), expected).email).toBe('customer@example.com');
|
||||
});
|
||||
|
||||
it('accepts both spellings of the issuer, because Google sends both', () => {
|
||||
// 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.
|
||||
for (const iss of ['https://accounts.google.com', 'accounts.google.com']) {
|
||||
expect(verifiedIdentity(idToken({ ...VALID, iss }), expected).sub).toBe('1234567890');
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses an issuer it was not told to trust', () => {
|
||||
expect(() => verifiedIdentity(idToken({ ...VALID, iss: 'https://evil.test' }), expected)).toThrow(
|
||||
/unexpected issuer/
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a token minted for a different application', () => {
|
||||
// Without this, a token obtained by any other Google app could be replayed
|
||||
// here and would sign somebody in.
|
||||
expect(() =>
|
||||
verifiedIdentity(idToken({ ...VALID, aud: 'someone-else.apps.googleusercontent.com' }), expected)
|
||||
).toThrow(/different client/);
|
||||
});
|
||||
|
||||
it('refuses an expired token', () => {
|
||||
const expired = { ...VALID, exp: Math.floor(Date.now() / 1000) - 3600 };
|
||||
|
||||
expect(() => verifiedIdentity(idToken(expired), expected)).toThrow(/expired/);
|
||||
});
|
||||
|
||||
it('allows a minute of clock skew, so a healthy host does not refuse valid tokens', () => {
|
||||
const justExpired = { ...VALID, exp: Math.floor(Date.now() / 1000) - 5 };
|
||||
|
||||
expect(verifiedIdentity(idToken(justExpired), expected).sub).toBe('1234567890');
|
||||
});
|
||||
|
||||
it('refuses a token with no expiry at all', () => {
|
||||
expect(() => verifiedIdentity(idToken(without('exp')), expected)).toThrow(/no expiry/);
|
||||
});
|
||||
|
||||
it('refuses a token from a different sign-in attempt', () => {
|
||||
// This is what stops a token captured from one attempt being replayed into
|
||||
// another, which is the whole reason the nonce exists.
|
||||
expect(() => verifiedIdentity(idToken({ ...VALID, nonce: 'someone-elses' }), expected)).toThrow(
|
||||
/different sign-in attempt/
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a token carrying no nonce', () => {
|
||||
expect(() => verifiedIdentity(idToken(without('nonce')), expected)).toThrow(
|
||||
/different sign-in attempt/
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a token with no subject, which is the identity itself', () => {
|
||||
expect(() => verifiedIdentity(idToken(without('sub')), expected)).toThrow(/no subject/);
|
||||
});
|
||||
|
||||
it('refuses a token with no email', () => {
|
||||
expect(() => verifiedIdentity(idToken(without('email')), expected)).toThrow(/no email/);
|
||||
});
|
||||
|
||||
describe('email_verified', () => {
|
||||
it('is true only for the boolean, never a truthy string', () => {
|
||||
// The linking policy turns entirely on this flag (#343). Treating the
|
||||
// string "false" as verified is exactly the mistake that would make
|
||||
// auto-linking an account-takeover path.
|
||||
expect(verifiedIdentity(idToken({ ...VALID, email_verified: 'false' }), expected).emailVerified)
|
||||
.toBe(false);
|
||||
expect(verifiedIdentity(idToken({ ...VALID, email_verified: 'true' }), expected).emailVerified)
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
it('is false when the claim is missing', () => {
|
||||
expect(verifiedIdentity(idToken(without('email_verified')), expected).emailVerified).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('tolerates a profile with no names, because Google may send none', () => {
|
||||
const anonymous = { ...without('given_name') };
|
||||
delete anonymous.family_name;
|
||||
const identity = verifiedIdentity(idToken(anonymous), expected);
|
||||
|
||||
expect(identity.firstName).toBeNull();
|
||||
expect(identity.lastName).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['not a JWT', 'nonsense'],
|
||||
['a JWT whose payload is not JSON', 'aGVhZGVy.bm90LWpzb24.sig']
|
||||
])('refuses %s', (_label, token) => {
|
||||
expect(() => verifiedIdentity(token, expected)).toThrow();
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,50 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { passwordMatches, TEST_ROUNDS } from '../../src/passwordHashing';
|
||||
|
||||
/**
|
||||
* `customers.password_hash` became nullable in #340, 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.
|
||||
*
|
||||
* The reason this is a function rather than a null check at each call site is
|
||||
* what the second test asserts: `bcrypt.compare` throws on a null hash instead
|
||||
* of returning false, so a forgotten check answers a sign-in attempt with a 500.
|
||||
* On the login route that is also an oracle, because it happens for exactly the
|
||||
* accounts that have no password.
|
||||
*/
|
||||
describe('passwordMatches', () => {
|
||||
it('matches a correct password against a real hash', async () => {
|
||||
const hash = await bcrypt.hash('supersecret123', TEST_ROUNDS);
|
||||
|
||||
await expect(passwordMatches('supersecret123', hash)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a wrong password against a real hash', async () => {
|
||||
const hash = await bcrypt.hash('supersecret123', TEST_ROUNDS);
|
||||
|
||||
await expect(passwordMatches('nope', hash)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
['an empty string', '']
|
||||
])('answers false rather than throwing when the stored hash is %s', async (_label, stored) => {
|
||||
// The whole point. bcrypt.compare throws "Illegal arguments" here.
|
||||
await expect(passwordMatches('anything', stored)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('answers false for a missing password rather than throwing', async () => {
|
||||
const hash = await bcrypt.hash('supersecret123', TEST_ROUNDS);
|
||||
|
||||
await expect(passwordMatches(undefined, hash)).resolves.toBe(false);
|
||||
await expect(passwordMatches(null, hash)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat an empty password as matching an absent hash', async () => {
|
||||
// Both sides missing is the combination that would be most tempting to call
|
||||
// a match, and it would let anyone sign in as any social-only customer by
|
||||
// submitting a blank password.
|
||||
await expect(passwordMatches('', null)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,31 @@ describe('readId', () => {
|
||||
}
|
||||
);
|
||||
|
||||
// These are the dangerous ones, because they were never a 500 and so never
|
||||
// announced themselves (#307). Number reads each as a positive integer, so
|
||||
// every check this function used to make passed and the route fetched a real
|
||||
// row for a URL nobody wrote: /items/5.0 answered with item 5.
|
||||
it.each(['5.0', '1e2', '0x10', '+5', '1_0'])(
|
||||
'refuses %p, which parses to a positive integer but is not an id',
|
||||
(value) => {
|
||||
expect(readId(value)).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
// The column is 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 this function was written to prevent.
|
||||
it('refuses an id past the top of a 32-bit serial', () => {
|
||||
expect(readId('2147483647')).toBe(2147483647);
|
||||
expect(readId('2147483648')).toBeNull();
|
||||
expect(readId('99999999999999999999')).toBeNull();
|
||||
});
|
||||
|
||||
// Surrounding whitespace is a URL artefact rather than a different id.
|
||||
it('reads an id with surrounding whitespace', () => {
|
||||
expect(readId(' 7 ')).toBe(7);
|
||||
});
|
||||
|
||||
it('refuses a missing param', () => {
|
||||
expect(readId(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { relyingParty, RELYING_PARTY_NAME } from '../../src/passkeys/relyingParty';
|
||||
|
||||
/**
|
||||
* The RP ID is the one value in this feature that cannot be corrected later: a
|
||||
* passkey is bound to it permanently, and a wrong one is only discovered when a
|
||||
* customer cannot sign in with a credential that no longer matches anything.
|
||||
*
|
||||
* So these assert the derivation itself rather than that the function returns
|
||||
* something. Most of them are about what must *not* end up in the ID.
|
||||
*/
|
||||
describe('relyingParty', () => {
|
||||
it('takes the RP ID from PUBLIC_URL', () => {
|
||||
const rp = relyingParty({ PUBLIC_URL: 'https://redefined-designs.com' });
|
||||
|
||||
expect(rp.id).toBe('redefined-designs.com');
|
||||
expect(rp.origins).toEqual(['https://redefined-designs.com']);
|
||||
expect(rp.name).toBe(RELYING_PARTY_NAME);
|
||||
});
|
||||
|
||||
it('keeps a subdomain, which is a different Relying Party', () => {
|
||||
// QA and production are separate RPs on purpose. A credential registered
|
||||
// against one does not work against the other, and collapsing them to the
|
||||
// registrable domain would silently make QA able to sign into production.
|
||||
const qa = relyingParty({ PUBLIC_URL: 'https://qa-redefined-designs.bermudalamb.synology.me' });
|
||||
const prod = relyingParty({ PUBLIC_URL: 'https://redefined-designs.bermudalamb.synology.me' });
|
||||
|
||||
expect(qa.id).toBe('qa-redefined-designs.bermudalamb.synology.me');
|
||||
expect(prod.id).toBe('redefined-designs.bermudalamb.synology.me');
|
||||
expect(qa.id).not.toBe(prod.id);
|
||||
});
|
||||
|
||||
it('strips the port, which is not part of an RP ID', () => {
|
||||
// An RP ID of "example.com:8443" matches nothing, and the failure is a
|
||||
// credential that cannot be used rather than an error at registration.
|
||||
const rp = relyingParty({ PUBLIC_URL: 'https://example.com:8443' });
|
||||
|
||||
expect(rp.id).toBe('example.com');
|
||||
expect(rp.origins).toEqual(['https://example.com:8443']);
|
||||
});
|
||||
|
||||
it('strips a path and a trailing slash from the origin', () => {
|
||||
const rp = relyingParty({ PUBLIC_URL: 'https://example.com/shop/' });
|
||||
|
||||
expect(rp.id).toBe('example.com');
|
||||
// The browser reports an origin, never a path, so a stored path would never
|
||||
// match what arrives.
|
||||
expect(rp.origins).toEqual(['https://example.com']);
|
||||
});
|
||||
|
||||
it.each(['', ' ', undefined])('falls back to localhost when PUBLIC_URL is %p', (value) => {
|
||||
// envValidation requires PUBLIC_URL only when SMTP is configured, so a local
|
||||
// setup that cannot send mail legitimately has none.
|
||||
const rp = relyingParty(value === undefined ? {} : { PUBLIC_URL: value });
|
||||
|
||||
expect(rp.id).toBe('localhost');
|
||||
// Both local origins, because the app is served from Vite during dev and
|
||||
// from Express once built, and they differ only by port.
|
||||
expect(rp.origins).toEqual(['http://localhost:5173', 'http://localhost:3000']);
|
||||
});
|
||||
|
||||
it('refuses a PUBLIC_URL that is not a URL rather than guessing', () => {
|
||||
// Guessing here would bind every passkey to something nobody chose, and the
|
||||
// binding is permanent.
|
||||
expect(() => relyingParty({ PUBLIC_URL: 'redefined-designs.com' })).toThrow(
|
||||
/Relying Party ID cannot be derived/
|
||||
);
|
||||
});
|
||||
|
||||
it('reads the environment on each call rather than at import', () => {
|
||||
// Captured at import, this would hold whatever the first suite to require
|
||||
// the module happened to have set.
|
||||
expect(relyingParty({ PUBLIC_URL: 'https://one.example' }).id).toBe('one.example');
|
||||
expect(relyingParty({ PUBLIC_URL: 'https://two.example' }).id).toBe('two.example');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { checkSignatureCounter } from '../../src/passkeys/signatureCounter';
|
||||
|
||||
/**
|
||||
* The rule #37 deferred to the ceremony that enforces it.
|
||||
*
|
||||
* Both halves are load-bearing and they pull in opposite directions. Requiring
|
||||
* an increase from every authenticator refuses the synced passkeys most people
|
||||
* actually use, which report zero forever by design. Requiring it from none
|
||||
* throws away the only signal that a hardware credential has been cloned, which
|
||||
* is the entire reason the column exists.
|
||||
*/
|
||||
describe('checkSignatureCounter', () => {
|
||||
describe('an authenticator that does not implement counters', () => {
|
||||
it('accepts zero against zero, and keeps accepting it', () => {
|
||||
// iCloud Keychain and Google Password Manager report this on every
|
||||
// assertion. Refusing it would refuse most real customers.
|
||||
expect(checkSignatureCounter(0, 0).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an authenticator that does', () => {
|
||||
it('accepts a counter that advanced', () => {
|
||||
expect(checkSignatureCounter(5, 6).ok).toBe(true);
|
||||
expect(checkSignatureCounter(0, 1).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses one that stalled', () => {
|
||||
// Equal is not an increase. Two copies of a credential used alternately
|
||||
// produce exactly this.
|
||||
const verdict = checkSignatureCounter(7, 7);
|
||||
|
||||
expect(verdict.ok).toBe(false);
|
||||
expect(verdict.reason).toMatch(/cloned/);
|
||||
});
|
||||
|
||||
it('refuses one that went backwards', () => {
|
||||
expect(checkSignatureCounter(9, 4).ok).toBe(false);
|
||||
});
|
||||
|
||||
// The asymmetry that stops the zero rule being an escape hatch. An
|
||||
// authenticator that has ever reported a real counter is held to the strict
|
||||
// rule from then on, so a clone cannot report zero to look like a synced
|
||||
// passkey and be waved through.
|
||||
it('refuses a drop to zero from a counter that was real', () => {
|
||||
const verdict = checkSignatureCounter(12, 0);
|
||||
|
||||
expect(verdict.ok).toBe(false);
|
||||
expect(verdict.reason).toMatch(/did not advance/);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the numbers, because a refusal is only actionable with them', () => {
|
||||
expect(checkSignatureCounter(12, 3).reason).toContain('stored 12');
|
||||
expect(checkSignatureCounter(12, 3).reason).toContain('received 3');
|
||||
});
|
||||
});
|
||||
@@ -104,6 +104,13 @@
|
||||
# INTAKE_ACTION_SECRET Optional. Signs the regenerate and discard links in the
|
||||
# intake notification email (#224). Absent, the email
|
||||
# still sends and carries no shortcuts.
|
||||
# GOOGLE_CLIENT_ID Optional, and all-or-nothing with the secret below —
|
||||
# GOOGLE_CLIENT_SECRET setting one without the other refuses to boot (#340).
|
||||
# Both unset means Google sign-in is simply not offered.
|
||||
# The redirect URI is derived from PUBLIC_URL and must
|
||||
# match what is registered in the Google Auth Platform
|
||||
# exactly, so changing the domain means updating that
|
||||
# console too (#313).
|
||||
# REMBG_URL Optional. The background-removal sidecar, e.g.
|
||||
# http://rembg-syn:7000. Unset turns the feature off.
|
||||
#
|
||||
@@ -244,11 +251,27 @@ services:
|
||||
# See docs/ops/image-background-removal-stack.md.
|
||||
- REMBG_URL=${REMBG_URL:-}
|
||||
|
||||
# Brevo's Marketing Automation key (#56), from
|
||||
# https://app.brevo.com/automation/parameters. Optional: unset means the
|
||||
# tracker is never loaded and no browsing is reported to anyone.
|
||||
#
|
||||
# Not a secret — it ships to the browser by design — but it is
|
||||
# per-environment, and this is the only place production's is named. A
|
||||
# key here is still not sufficient to track anybody: the script loads
|
||||
# only for a signed-in customer whose stored consent wording covers
|
||||
# analytics. See the note on MARKETING_CONSENT_TEXT in backend/src/utils.ts.
|
||||
- BREVO_TRACKER_KEY=${BREVO_TRACKER_KEY:-}
|
||||
|
||||
# Signs the regenerate and discard links in the intake notification email
|
||||
# (#224). Optional: absent, the notification still sends and links to the
|
||||
# review queue without shortcuts. Rotating it revokes every outstanding
|
||||
# link, which is how a leaked one is dealt with.
|
||||
- INTAKE_ACTION_SECRET=${INTAKE_ACTION_SECRET:-}
|
||||
# Both or neither. envValidation refuses to start on one without the
|
||||
# other, because the failure would otherwise arrive at the moment a
|
||||
# customer presses the button (#340).
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||
volumes:
|
||||
# Production's own uploads directory. QA writes to
|
||||
# /volume1/configs/redefined-designs-qa/uploads; sharing this one would
|
||||
|
||||
@@ -63,6 +63,12 @@
|
||||
# in the notification email (#224). Absent, the email still
|
||||
# sends and simply carries no shortcuts. Its own value, not
|
||||
# production's: a link signed with it acts without a login.
|
||||
# QA_GOOGLE_CLIENT_ID — optional, and all-or-nothing with the secret below:
|
||||
# QA_GOOGLE_CLIENT_SECRET setting one without the other refuses to boot
|
||||
# (#340). Both unset means the Google button is not offered
|
||||
# at all, which is the right answer until QA's callback URL
|
||||
# is registered in the Google Auth Platform. See the note
|
||||
# beside the values themselves for the exact URL (#345).
|
||||
# QA_REMBG_URL — optional. The background-removal sidecar, e.g.
|
||||
# http://rembg-syn:7000. Unset turns the feature off rather
|
||||
# than breaking anything. The sidecar must be on the same
|
||||
@@ -174,6 +180,20 @@ services:
|
||||
# unset stack variable cannot fail a deploy.
|
||||
- REMBG_URL=${QA_REMBG_URL:-}
|
||||
|
||||
# Deliberately empty, and deliberately not a stack variable (#56).
|
||||
#
|
||||
# This is what keeps QA browsing out of the live Brevo account. Written as
|
||||
# an empty literal rather than left out entirely so it cannot inherit a
|
||||
# value from the host environment, and with no `${...}` so there is no
|
||||
# stack variable anyone could set here by pasting production's in — the
|
||||
# same reasoning as QA_DB_PASSWORD and the QA_SMTP_* names above.
|
||||
#
|
||||
# QA runs against disposable fixtures. Reporting that browsing as though
|
||||
# it were customer behaviour would corrupt the segmentation the tracker
|
||||
# exists to feed, and it would be indistinguishable from real traffic
|
||||
# after the fact.
|
||||
- BREVO_TRACKER_KEY=
|
||||
|
||||
# Signs the regenerate and discard links in the intake notification email
|
||||
# (#224). Optional: absent, the notification still sends and simply links
|
||||
# to the review queue without shortcuts. Anyone holding a link can act on
|
||||
@@ -181,6 +201,28 @@ services:
|
||||
# rotating it revokes every outstanding link, which is the intended way to
|
||||
# deal with a leak.
|
||||
- INTAKE_ACTION_SECRET=${QA_INTAKE_ACTION_SECRET:-}
|
||||
# Read from the stack like every other QA secret, rather than hardcoded
|
||||
# empty as they were in #340. Leaving them unreadable made this file the
|
||||
# odd one out and cost a QA deploy: the variables were set on the stack,
|
||||
# nothing read them, and the button stayed missing with no explanation.
|
||||
#
|
||||
# Setting these needs one thing done first: the QA callback registered
|
||||
# under Authorized redirect URIs for this client in the Google Auth
|
||||
# Platform, exactly as it appears below. Google compares the two as
|
||||
# strings and answers a mismatch with redirect_uri_mismatch.
|
||||
#
|
||||
# https://qa-redefined-designs.bermudalamb.synology.me/api/auth/google/callback
|
||||
#
|
||||
# An earlier version of this comment said that URI could never be
|
||||
# registered, because Synology owns the domain above it. That was wrong,
|
||||
# and the correction is left here rather than removed: it was inferred
|
||||
# from #285, which is a related but different problem, and it put QA
|
||||
# testing of this feature behind #313 for no reason.
|
||||
#
|
||||
# When #313 moves QA to qa.redefined-designs.com, point PUBLIC_URL at the
|
||||
# new host and register that callback too. No code change either way.
|
||||
- GOOGLE_CLIENT_ID=${QA_GOOGLE_CLIENT_ID:-}
|
||||
- GOOGLE_CLIENT_SECRET=${QA_GOOGLE_CLIENT_SECRET:-}
|
||||
volumes:
|
||||
# Separate uploads directory. Sharing production's would let a QA run
|
||||
# write into, and a QA teardown delete, real product images.
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Account recovery
|
||||
|
||||
What happens when a customer cannot get into their account, and what the shop
|
||||
can do about it. Written for #42, which exists because this is the piece most
|
||||
likely to be skipped and most expensive to discover missing.
|
||||
|
||||
Every other issue in the passkeys project adds a capability. This one is the
|
||||
safety net.
|
||||
|
||||
## The short version
|
||||
|
||||
| The customer has lost | They can recover by | Self-service |
|
||||
| --- | --- | --- |
|
||||
| Their password | A reset link emailed to them | Yes |
|
||||
| Their passkey or the device holding it | Signing in with their password | Yes |
|
||||
| Every passkey and their password | A reset link emailed to them | Yes |
|
||||
| Access to their email address | Contacting the shop, which moves the account | No |
|
||||
|
||||
The email address is the root of trust. Every self-service route above ends at
|
||||
it, and none of them can work without it.
|
||||
|
||||
## A password reset removes every passkey
|
||||
|
||||
This is the decision #42 existed to make, and it is deliberate.
|
||||
|
||||
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 30 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 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 by that point.
|
||||
|
||||
Consequences worth knowing:
|
||||
|
||||
- The customer is told before they act. The reset email and the reset form both
|
||||
say it, unconditionally. The form is not signed in and is never told whether
|
||||
the account has passkeys, because answering that would make the reset page an
|
||||
oracle for it.
|
||||
- The customer is told after they act, with a count, and only when the count is
|
||||
more than zero. This is the one moment that 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.
|
||||
- Any WebAuthn challenge in flight goes too. An intruder who pressed "add a
|
||||
passkey" moments before the reset could otherwise finish the ceremony
|
||||
afterwards and put a credential straight back.
|
||||
|
||||
## Changing a password does not remove passkeys
|
||||
|
||||
The asymmetry with the paragraph above is intentional.
|
||||
|
||||
`change-password` requires the current password from someone already signed in.
|
||||
Nothing about that suggests a lockout or a compromise, and it already spares the
|
||||
current session for the same reason. A customer who suspects one particular
|
||||
device can revoke 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. A
|
||||
change knows the customer is present and in control, so it takes none.
|
||||
|
||||
## Losing the authenticator is not a lockout
|
||||
|
||||
A customer who loses the phone or key holding their passkey signs in with their
|
||||
password as normal, and revokes the lost credential from the account page. This
|
||||
needs no support involvement and no new capability, which is why #42 implements
|
||||
nothing for it.
|
||||
|
||||
This holds only while every account has a password. It stops holding when #332
|
||||
lands social sign-in, which creates the first customers with no password at all.
|
||||
Their recovery route is the identity provider, not a reset link, and #332 owns
|
||||
that question. The revocation guard in `backend/src/routes/passkeys.ts` already
|
||||
refuses to delete a customer's only way in, written against that condition
|
||||
rather than against today's schema, so it starts holding on its own the moment
|
||||
the condition changes.
|
||||
|
||||
## Losing the email address is a lockout
|
||||
|
||||
There is no self-service recovery, and there should not be. Recovering an
|
||||
account whose email is gone means proving identity some other way, and this shop
|
||||
holds no other way — no phone number, no security questions, no identity
|
||||
documents. Anything invented to fill that gap would be a weaker credential than
|
||||
the one it replaces, and would become the easiest way to take an account over.
|
||||
|
||||
The route is manual, and it runs through the shop owner:
|
||||
|
||||
1. The customer makes contact by whatever means they have.
|
||||
2. The owner verifies them against order history — items bought, dates, the
|
||||
shipping address on file. A stranger has none of that.
|
||||
3. The owner opens the customer in Admin, Customers, and uses **Move to a new
|
||||
address** on the detail drawer.
|
||||
|
||||
Step 3 was added by #337. Before it existed the only answer was a database edit
|
||||
by hand, which left no record of who did it or why.
|
||||
|
||||
### What the move does, and why
|
||||
|
||||
Say plainly what it is: **this operation and an account takeover are the same
|
||||
operation.** They differ only in whether the verification in step 2 was sound,
|
||||
and nothing in the software can check that. Everything the move does is aimed at
|
||||
that fact.
|
||||
|
||||
- **It asks for a written reason, and refuses without one.** The reason is
|
||||
recorded against the account and never shown to the customer. It is the only
|
||||
thing that distinguishes a genuine recovery from a takeover afterwards.
|
||||
- **It emails the address being replaced.** If the recovery was sound this
|
||||
reaches nobody, which costs nothing. If it was not, it reaches the real owner,
|
||||
who is the only person who can say so. That mail has its own wording, because
|
||||
the self-service notice says "if you did not make this change, contact us" and
|
||||
here somebody already did.
|
||||
- **It sends a confirmation link to the new address and marks it unverified.** A
|
||||
customer reading an address out over the phone has not demonstrated they can
|
||||
receive mail at it. This is the commonest way the move goes wrong harmlessly.
|
||||
- **It signs the customer out everywhere, removes every passkey, and cancels
|
||||
outstanding reset links.** Same reasoning as a password reset, with more
|
||||
force: somebody the system cannot identify asked for this, so a session or
|
||||
credential surviving it is one the new owner cannot see or revoke, and a reset
|
||||
link sitting in the old mailbox would let whoever reads it take the account
|
||||
straight back.
|
||||
- **It does not change the password.** What the customer lost was the mailbox,
|
||||
not the password, so demanding a new one adds a step for no gain.
|
||||
|
||||
The history of moves on an account is shown on the same drawer, with the reasons.
|
||||
It renders nothing at all for the overwhelming majority of customers, who have
|
||||
never been moved.
|
||||
|
||||
### What it still does not do
|
||||
|
||||
There is no per-admin identity to record. Admin access is one shared gate secret
|
||||
in front of a single operator, so a "who" column 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, the record gains a column then.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Feature flags: whether this project wants a manager
|
||||
|
||||
Findings for #318. Tool capabilities and pricing were checked on 2026-09-08; everything about this repository was read from the code on 2026-09-09.
|
||||
|
||||
**Status: do not adopt a feature flag manager. Not yet, and possibly not ever at this size.** This is written down so the question does not get reopened from scratch — including the conditions that would change the answer.
|
||||
|
||||
## The short version
|
||||
|
||||
This project already has two flag mechanisms, uses both idiomatically, and is not short of a third. What it lacks is a boolean in one of them, which is about twenty lines of work and needs a reason before it is worth doing.
|
||||
|
||||
No tool was compared until that was established, because a spike that starts by comparing platforms will always find one.
|
||||
|
||||
## What already exists
|
||||
|
||||
**Environment variables, for per-environment gating.** `DEMO_MODE`, `REMBG_URL`, `BREVO_TRACKER_KEY`, `ANTHROPIC_API_KEY`, the USPS and SMTP credentials. The convention is consistent and documented: 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 deploy-time gating, and it is genuinely a feature flag system — it just is not called one.
|
||||
|
||||
**`admin_settings`, for runtime tuning by whoever runs the shop.** A typed key/value store with an admin UI, ten settings, per-setting fallbacks, and one place to add another. Its own header says the point plainly: adding a setting means adding a row and nothing else. `drafting_model` and `intake_notify_email` are there rather than in the environment for exactly the reason a flag would be — they are changed by the person running the shop, not the person deploying it, and a redeploy to change an email address would be absurd.
|
||||
|
||||
The two are complementary and the split is already the right one.
|
||||
|
||||
## What is actually missing
|
||||
|
||||
`admin_settings` supports `hours`, `count`, `text` and `choice`. **There is no boolean.**
|
||||
|
||||
So the one thing a flag manager would give this project that it does not have — an admin-flippable on/off for a code path, without a rebuild — is a missing value type in a store that already does everything else. Adding it is a definition entry, a parse branch, and a control in the settings tab.
|
||||
|
||||
That is the whole gap. It is not a reason to run another service on the NAS.
|
||||
|
||||
## Is there anything to flag right now
|
||||
|
||||
No, and the issue said a spike that cannot name one should say so.
|
||||
|
||||
Nine issues are open. Seven are the Passkeys epic and #313, and neither wants a flag:
|
||||
|
||||
- **Passkeys (#37–#42)** is naturally incremental — schema, then registration, then the authentication ceremony, then management, then the login page. Nothing is user-visible until #41 puts it on the login form, so the ordering does the gating that a flag would otherwise do. The epic has also been untouched since 2026-08-21 and its parent #36 is closed, so its status is undecided in a way no tooling addresses.
|
||||
- **#313's `trust proxy` change cannot be flagged.** It is only correct once Cloudflare is actually in front, so it must ship with the DNS cutover. A runtime toggle would let it be wrong on purpose, which is worse than not having one.
|
||||
|
||||
**The strongest hypothetical is a kill switch for passkey login**, because #41 records that it is the one feature in the project that cannot be fully proven in QA — credentials bind to the RP ID, so production is the first place it runs for real. Being able to turn it off without a rebuild would have value there.
|
||||
|
||||
That is a real argument, and it is still not enough today: passkeys are not being built, and **production is not live**, so there is no customer-facing behaviour to protect. Revisit it when #41 is actually being written.
|
||||
|
||||
## What flags would buy here, honestly
|
||||
|
||||
The usual headline — decouple deploy from release — buys much less than it does elsewhere, because there is no CD. Every change already reaches production through a manual NAS rebuild, so the deploy is a deliberate act either way.
|
||||
|
||||
What is left is narrower:
|
||||
|
||||
- **A kill switch**, avoiding a rebuild. The strongest case, because the rebuild is this project's slowest and most error-prone step.
|
||||
- **Shipping half-finished work** behind a flag rather than holding a long branch. Weak here: branches are short and merged the same day.
|
||||
- **Per-environment behaviour** without another env var. Already solved, by the env vars.
|
||||
|
||||
Against all of it: every flag is a branch in the code that has to be removed later, and stale flags are their own debt. For a single-developer shop, that cost is paid by the same person who would benefit.
|
||||
|
||||
## If a tool is ever wanted
|
||||
|
||||
Constraints that decide it: the NAS is CPU and memory limited and already runs Gitea, the CI runner, QA and production; Portainer manages the stacks; cost should stay at zero.
|
||||
|
||||
| Tool | Fit |
|
||||
| --- | --- |
|
||||
| **Flipt** | Lightest — single binary, no heavy datastore. The only one that obviously fits this machine. |
|
||||
| **Flagsmith** | Open source, self-host free with no feature limits, Docker Compose. Heavier than Flipt. |
|
||||
| **GrowthBook** | Free self-hosted and a free cloud starter. Flags and experiments together, which is more than this project needs. |
|
||||
| **Unleash** | Best known, but **OSS Edge sunsets 2026-12-31**, after which self-hosters at scale need Enterprise Edge. A free tool acquiring a paid dependency inside a year is the wrong shape here. |
|
||||
|
||||
A hosted free tier is also possible and is worse for this specific project: it makes the storefront depend on a third party being reachable in order to decide its own behaviour, which is a new outage mode for a site that currently has none.
|
||||
|
||||
**Whatever is chosen, fail-safe behaviour matters more than features.** A flag service that takes the shop down when it is unreachable is worse than having no flags. `admin_settings` reads from the database the application already cannot run without, so it has no failure mode of its own — which is a real advantage over every option in that table.
|
||||
|
||||
## What would change this answer
|
||||
|
||||
Any one of these, and it is worth reopening:
|
||||
|
||||
- A concrete change that genuinely needs to be dark in production while it settles — passkey login being the likely first.
|
||||
- Percentage rollouts or per-customer targeting becoming something anyone actually wants. Nothing in the project has ever asked for either.
|
||||
- More than one person deploying, so "who turned that off" needs an audit trail.
|
||||
- CD arriving, which would make decoupling deploy from release mean something.
|
||||
|
||||
Until one of those, the answer is a boolean column type in `admin_settings`, written when there is a flag to put in it.
|
||||
@@ -0,0 +1,130 @@
|
||||
# Google sign-in
|
||||
|
||||
What has to be true outside the repository for the Google button to work, and
|
||||
what to do at the domain cutover. The code side is #332 and the six issues under
|
||||
it; this is only the parts that live in a browser tab at Google.
|
||||
|
||||
## Where it is configured
|
||||
|
||||
The **Google Auth Platform** in the Google Cloud Console, in one project. There
|
||||
is one consent screen per project and every OAuth client in it shares that
|
||||
screen, so what appears there is the production identity even while testing.
|
||||
|
||||
| Section | What it holds |
|
||||
| --- | --- |
|
||||
| Branding | App name, support email, authorized domains, the three app links |
|
||||
| Audience | External, publishing status, test users |
|
||||
| Clients | The OAuth client, its redirect URIs, the id and secret |
|
||||
| Data Access | Exactly `openid`, `email`, `profile` |
|
||||
| Verification Center | Nothing to submit, and it should stay that way |
|
||||
|
||||
## Redirect URIs, one per environment
|
||||
|
||||
Every environment sends a redirect URI derived from its own `PUBLIC_URL`, and
|
||||
each one has to exist verbatim under **Authorized redirect URIs** on the client
|
||||
this app uses. Google compares them as strings and answers a mismatch with
|
||||
`redirect_uri_mismatch`, which is accurate and says nothing about which half is
|
||||
wrong.
|
||||
|
||||
| Environment | Redirect URI |
|
||||
| --- | --- |
|
||||
| Local, Vite dev server | `http://localhost:5173/api/auth/google/callback` |
|
||||
| Local, backend serving a build | `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 entry, because that is where the dev server
|
||||
serves the app. The 3000 one applies only when the backend serves a built
|
||||
frontend, which local development does not produce.
|
||||
|
||||
### A correction
|
||||
|
||||
An earlier version of this document said the QA hostname **could never be
|
||||
registered**, because it sits under a domain Synology owns rather than one we
|
||||
do. That was wrong. Adding the URI works.
|
||||
|
||||
The claim is recorded here rather than quietly removed, because of what it
|
||||
cost. It was inferred from #285, where Cloudflare genuinely cannot be applied to
|
||||
that hostname, and asserted with far more confidence than the inference
|
||||
supported. On the strength of it, QA testing of Google sign-in was documented as
|
||||
blocked behind #313, the QA compose file hardcoded its credentials to empty, and
|
||||
two issues recorded it as fact.
|
||||
|
||||
What is true, and is all that was ever established: `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 of those actually applied to this
|
||||
hostname, and how, was never checked.
|
||||
|
||||
## Scopes, and why publishing needs no review
|
||||
|
||||
`openid` produces the id token carrying the subject claim, which is the identity
|
||||
stored. `email` carries the address and the `email_verified` flag the linking
|
||||
policy turns on. `profile` carries the names used when an account is created.
|
||||
|
||||
All three are non-sensitive. Requesting only them is what lets the app publish
|
||||
without verification and without customers seeing an unverified-app warning.
|
||||
**Add one sensitive scope and publishing becomes a review with a video
|
||||
walkthrough and a wait measured in weeks.** Nothing in this feature needs one.
|
||||
|
||||
Uploading an app logo also triggers a brand review, which is why Branding has
|
||||
none.
|
||||
|
||||
## Turning it on in QA
|
||||
|
||||
Already done, and recorded here because the order matters.
|
||||
|
||||
1. Register the QA callback under **Clients**, Authorized redirect URIs:
|
||||
`https://qa-redefined-designs.bermudalamb.synology.me/api/auth/google/callback`
|
||||
2. Set `QA_GOOGLE_CLIENT_ID` and `QA_GOOGLE_CLIENT_SECRET` on the QA stack. Both
|
||||
or neither — the backend refuses to start on one without the other, because
|
||||
the failure would otherwise arrive the moment a customer presses the button.
|
||||
3. Redeploy.
|
||||
|
||||
Registering first is the point. Setting the variables makes the button appear,
|
||||
and a button that appears before its callback exists fails at Google rather than
|
||||
in the storefront, where nothing in the logs explains it.
|
||||
|
||||
## The cutover checklist, for #313
|
||||
|
||||
1. Point QA at `qa.redefined-designs.com` and set its `PUBLIC_URL` to match.
|
||||
2. In **Clients**, add the new QA callback:
|
||||
`https://qa.redefined-designs.com/api/auth/google/callback`
|
||||
3. Confirm the production callback is registered:
|
||||
`https://redefined-designs.com/api/auth/google/callback`
|
||||
4. In **Audience**, move the publishing status from Testing to **In production**.
|
||||
Do it once the domain resolves, so the home page and privacy links Google
|
||||
shows actually answer.
|
||||
|
||||
The old QA callback can be left registered until the hostname is retired. An
|
||||
extra entry costs nothing and removing it early breaks QA for no gain.
|
||||
|
||||
No code changes at any step. The redirect URI is derived from `PUBLIC_URL`, so
|
||||
the environment variable and the console entry are the whole of it.
|
||||
|
||||
**Leaving it in Testing is the failure to watch for.** Only listed test users can
|
||||
sign in, and the refusal happens on Google's own page, so nothing reaches the
|
||||
storefront and nothing appears in its logs. A customer reports a broken button
|
||||
and the logs are silent.
|
||||
|
||||
## The production smoke test
|
||||
|
||||
The consent screen, the redirect and the domain are all environment-specific, so
|
||||
QA proves the flow and not the configuration. After the cutover:
|
||||
|
||||
1. Sign in with a Google account that has never been used on the site. A new
|
||||
customer is created and lands on the consent step.
|
||||
2. Sign in again with the same account. It reaches the same customer rather than
|
||||
a second one.
|
||||
3. Check the account page lists Google under connected accounts.
|
||||
|
||||
## What is not offered, and why
|
||||
|
||||
**Unlinking.** A customer cannot detach their Google account. Removing the only
|
||||
way into an account is guarded for passkeys and the same guard would be needed
|
||||
here first. Worth its own issue when somebody actually asks.
|
||||
|
||||
**Apple.** A separate decision with a materially different cost, set out on
|
||||
#332: a paid developer programme, a client secret that expires every six months,
|
||||
no `localhost` redirect URIs at all, and a name and email returned exactly once.
|
||||
Apple is required for iOS apps offering third-party sign-in, and this is a
|
||||
website, so that rule does not apply here.
|
||||
Generated
+25
-1
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.4.0",
|
||||
"@simplewebauthn/browser": "^14.0.0",
|
||||
"@uiw/react-md-editor": "^4.0.4",
|
||||
"antd": "^5.20.6",
|
||||
"react": "^18.3.1",
|
||||
@@ -37,6 +38,9 @@
|
||||
"vite": "^5.4.0",
|
||||
"vite-plugin-istanbul": "^6.0.2",
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ant-design/colors": {
|
||||
@@ -157,6 +161,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -1678,6 +1683,12 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-14.0.0.tgz",
|
||||
"integrity": "sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1804,6 +1815,7 @@
|
||||
"version": "18.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1868,6 +1880,7 @@
|
||||
"integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.67.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
@@ -2341,6 +2354,7 @@
|
||||
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2751,6 +2765,7 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
@@ -3199,7 +3214,8 @@
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.21",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
|
||||
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="
|
||||
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
@@ -3628,6 +3644,7 @@
|
||||
"integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -7133,6 +7150,7 @@
|
||||
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
@@ -7236,6 +7254,7 @@
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -8041,6 +8060,7 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -8052,6 +8072,7 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -9396,6 +9417,7 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -9629,6 +9651,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -10185,6 +10208,7 @@
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.4.0",
|
||||
"@simplewebauthn/browser": "^14.0.0",
|
||||
"@uiw/react-md-editor": "^4.0.4",
|
||||
"antd": "^5.20.6",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { fetchConfig } from './api';
|
||||
import { useCustomerAuth } from './customer/CustomerAuthContext';
|
||||
import { brevoPage, startBrevoTracking, stopBrevoTracking } from './brevo';
|
||||
|
||||
/**
|
||||
* Drives the Brevo tracker from the two things that decide whether it may run
|
||||
* at all: the environment's key and the signed-in customer's consent (#56).
|
||||
*
|
||||
* Renders nothing. It exists as a component rather than a hook because it needs
|
||||
* both the router and the auth context, and mounting it inside `AppRoutes` is
|
||||
* what guarantees it sits under both — a hook called from the wrong place would
|
||||
* fail at runtime instead of being impossible to misplace.
|
||||
*
|
||||
* ## Why the gate is a server-computed field
|
||||
*
|
||||
* `analytics_consent` is not `marketing_consent`. The consent sentence was
|
||||
* widened to mention analytics, and the server reports this flag by comparing
|
||||
* the wording each customer actually agreed to against the current text. A
|
||||
* customer who consented to the older, email-only wording is not tracked. Never
|
||||
* substitute `marketing_consent` here — that is the exact retroactive widening
|
||||
* the field exists to prevent.
|
||||
*
|
||||
* A signed-out visitor has no consent record, so no key is ever used and the
|
||||
* script is never injected. Anonymous browsing is not reported at all.
|
||||
*/
|
||||
export default function BrevoTracking() {
|
||||
const { customer } = useCustomerAuth();
|
||||
const location = useLocation();
|
||||
const [trackerKey, setTrackerKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// Swallowed for the same reason main.tsx swallows its warm-up call: a
|
||||
// config that cannot be fetched is a broken deployment every other request
|
||||
// will report, and losing analytics is not worth surfacing to a customer.
|
||||
void fetchConfig()
|
||||
.then(config => { if (!cancelled) setTrackerKey(config.brevoTrackerKey); })
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const consented = customer?.analytics_consent === true;
|
||||
const email = customer?.email;
|
||||
|
||||
// Start and stop are driven by the same effect so there is no state in which
|
||||
// consent has gone away and nothing has acted on it. Withdrawing consent,
|
||||
// signing out, and deleting the account all arrive here as `consented`
|
||||
// turning false, because each one ends with the customer no longer being a
|
||||
// consenting signed-in customer.
|
||||
useEffect(() => {
|
||||
if (consented && trackerKey && email) {
|
||||
startBrevoTracking(trackerKey, email);
|
||||
} else {
|
||||
stopBrevoTracking();
|
||||
}
|
||||
}, [consented, trackerKey, email]);
|
||||
|
||||
// Declared after the effect above so that on the render where consent or the
|
||||
// key first arrives, tracking is started before this reports the route.
|
||||
//
|
||||
// The storefront is a single-page app: without this, the tracker's own
|
||||
// initial call would be the only page view it ever saw, and every customer
|
||||
// would look like they viewed one page and left.
|
||||
useEffect(() => {
|
||||
brevoPage(location.pathname);
|
||||
}, [location.pathname, consented, trackerKey]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -51,7 +51,22 @@ const { Title } = Typography;
|
||||
// not a state of its own worth drawing the eye to.
|
||||
const STATUS_TAG_COLORS: Record<string, string> = { sold: 'red', reserved: 'orange', pending: 'default' };
|
||||
|
||||
function Inventory() {
|
||||
/**
|
||||
* `active` is whether the Inventory tab is the one on screen (#327).
|
||||
*
|
||||
* antd keeps a tab pane mounted once it has been rendered, and this pane is
|
||||
* rendered at page load because Inventory is the default tab. So switching away,
|
||||
* publishing something from the Review queue, and switching back re-runs no
|
||||
* effect and refetches nothing: the table goes on showing the list it built when
|
||||
* the admin was first opened. It read as publishing being broken, because the
|
||||
* item really was published and really was absent from the list.
|
||||
*
|
||||
* Refetching when the tab becomes visible is the narrow fix. Setting
|
||||
* `destroyInactiveTabPane` on the Tabs would also work, by remounting — but it
|
||||
* discards every tab's state on every switch, filters and half-filled forms
|
||||
* included, which is a much larger change than this bug is worth.
|
||||
*/
|
||||
function Inventory({ active }: Readonly<{ active: boolean }>) {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<Item | null>(null);
|
||||
@@ -108,8 +123,17 @@ function Inventory() {
|
||||
]).catch(() => message.error('Could not load categories and tags')), []);
|
||||
|
||||
// Refetch whenever the filters change — filtering is server-side so the
|
||||
// result stays correct regardless of how many items exist.
|
||||
useEffect(() => { void load(filters); }, [load, filters]);
|
||||
// result stays correct regardless of how many items exist — and whenever this
|
||||
// tab becomes the visible one, because anything published, discarded or
|
||||
// edited from a sibling tab happened while this list sat untouched (#327).
|
||||
//
|
||||
// Guarded on `active` rather than fetching unconditionally: this pane stays
|
||||
// mounted for the life of the page, so without the guard every filter change
|
||||
// would still refetch while the tab is hidden and nobody is looking.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
void load(filters);
|
||||
}, [active, load, filters]);
|
||||
useEffect(() => { void loadOptions(); }, [loadOptions]);
|
||||
|
||||
function applyFilters(next: ItemFilters) { setFilters(next); }
|
||||
@@ -481,6 +505,10 @@ function Inventory() {
|
||||
export default function Admin() {
|
||||
const { mode, toggle } = useThemeMode();
|
||||
const { token } = theme.useToken();
|
||||
// Controlled rather than defaultActiveKey, so a pane can be told whether it is
|
||||
// the one on screen. Panes stay mounted here, so "visible" is not something a
|
||||
// child can work out for itself (#327).
|
||||
const [activeTab, setActiveTab] = useState('inventory');
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
@@ -493,9 +521,10 @@ export default function Admin() {
|
||||
</Header>
|
||||
<Content style={{ padding: 24 }}>
|
||||
<Tabs
|
||||
defaultActiveKey="inventory"
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{ key: 'inventory', label: 'Inventory', children: <Inventory /> },
|
||||
{ key: 'inventory', label: 'Inventory', children: <Inventory active={activeTab === 'inventory'} /> },
|
||||
{ key: 'categories', label: 'Categories', children: <Categories /> },
|
||||
{ key: 'tags', label: 'Tags', children: <Tags /> },
|
||||
{ key: 'upload-links', label: 'Upload links', children: <UploadLinks /> },
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react';
|
||||
import Modal from 'antd/es/modal';
|
||||
import Form from 'antd/es/form';
|
||||
import Input from 'antd/es/input';
|
||||
import Alert from 'antd/es/alert';
|
||||
import Button from 'antd/es/button';
|
||||
import Typography from 'antd/es/typography';
|
||||
import message from 'antd/es/message';
|
||||
import { changeCustomerEmail } from './adminCustomersApi';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
type Props = Readonly<{
|
||||
customerId: number;
|
||||
currentEmail: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Called after a successful change, so the drawer and the table can refresh. */
|
||||
onChanged: () => void;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Moving a customer's account to an address they can reach (#337).
|
||||
*
|
||||
* The last step of the only recovery route available to someone who has lost
|
||||
* their mailbox. There is deliberately no self-service equivalent, because the
|
||||
* email address is the root of trust for every other route and this shop holds
|
||||
* no second proof of identity.
|
||||
*
|
||||
* The form leads with what this costs rather than burying it, because the
|
||||
* operator is about to make a decision on someone else's behalf and the
|
||||
* consequences land on that person, not on them.
|
||||
*/
|
||||
export default function ChangeCustomerEmail({
|
||||
customerId,
|
||||
currentEmail,
|
||||
open,
|
||||
onClose,
|
||||
onChanged
|
||||
}: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function submit(values: { email: string; reason: string }) {
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await changeCustomerEmail(customerId, values.email, values.reason);
|
||||
// Named rather than counted, because "moved to that address" is the fact
|
||||
// the operator has to repeat back to the customer on the phone.
|
||||
message.success(`Account moved to ${result.customer.email}`);
|
||||
if (result.passkeysRemoved > 0) {
|
||||
// Its own message and a long one. The customer will find their passkeys
|
||||
// gone and needs to be told why while they are still on the line —
|
||||
// finding out later looks like a second thing going wrong.
|
||||
message.warning(
|
||||
result.passkeysRemoved === 1
|
||||
? 'Their saved passkey was removed. They will need to set it up again.'
|
||||
: `Their ${result.passkeysRemoved} saved passkeys were removed. They will need to set them up again.`,
|
||||
10
|
||||
);
|
||||
}
|
||||
form.resetFields();
|
||||
onChanged();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
message.error((err as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Move this account to a new email address"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
style={{ maxWidth: 'calc(100vw - 32px)' }}
|
||||
>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="Verify the customer before doing this"
|
||||
description="This is the same operation as an account takeover, and nothing here can tell the difference. Check their answers against the order history on the account first — items bought, dates, the shipping address on file."
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Paragraph type="secondary">
|
||||
Moving the account signs the customer out everywhere, removes any saved passkeys, and
|
||||
cancels reset links already sent to <Text code>{currentEmail}</Text>. A notice goes to that
|
||||
address, and a confirmation link goes to the new one.
|
||||
</Paragraph>
|
||||
|
||||
<Form form={form} layout="vertical" onFinish={submit}>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="New email address"
|
||||
rules={[{ required: true, type: 'email', message: 'A valid email address is required' }]}
|
||||
>
|
||||
<Input autoComplete="off" placeholder="what they can actually reach" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="reason"
|
||||
label="How you verified them"
|
||||
// The server requires this too, and refuses without it. Collected here
|
||||
// as well so the refusal is not the first the operator hears of it.
|
||||
rules={[{ required: true, min: 10, message: 'A sentence, not a word — this is the record' }]}
|
||||
extra="Recorded against the account and never shown to the customer. This is what tells a genuine recovery from a takeover afterwards."
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="Named the last two items bought and the shipping address on file." />
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="primary" danger htmlType="submit" loading={saving} block>
|
||||
Move the account
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -12,12 +12,57 @@ import message from 'antd/es/message';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
fetchCustomers, fetchCustomerDetail, fetchReservedItems, releaseReservedItem,
|
||||
setCustomerDisabled,
|
||||
CustomerSummary, CustomerDetail, ReservedItem
|
||||
setCustomerDisabled, fetchCustomerEmailChanges,
|
||||
CustomerSummary, CustomerDetail, ReservedItem, CustomerEmailChange
|
||||
} from './adminCustomersApi';
|
||||
import ChangeCustomerEmail from './ChangeCustomerEmail';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* Every address this account has been moved between, and why (#337).
|
||||
*
|
||||
* Shown on the detail drawer rather than hidden behind a separate screen,
|
||||
* because the moment it matters is the moment someone is looking at this
|
||||
* customer wondering whether the account is in the right hands. Empty for
|
||||
* almost every customer, so it renders nothing at all rather than an empty
|
||||
* state that would appear on every drawer to say nothing happened.
|
||||
*/
|
||||
function EmailChangeHistory({ changes }: Readonly<{ changes: CustomerEmailChange[] }>) {
|
||||
if (changes.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<Title level={5} style={{ marginTop: 24 }}>Address changes</Title>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={changes}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: 'When',
|
||||
dataIndex: 'changed_at',
|
||||
render: (v: string) => new Date(v).toLocaleString()
|
||||
},
|
||||
{
|
||||
title: 'Moved',
|
||||
key: 'moved',
|
||||
render: (_, row: CustomerEmailChange) => (
|
||||
<span style={{ fontSize: 12 }}>
|
||||
{row.previous_email} → {row.new_email}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
// The whole reason the record exists, so it is not truncated behind a
|
||||
// tooltip. A reader deciding whether a change was legitimate needs the
|
||||
// sentence, not the first few words of it.
|
||||
{ title: 'Reason', dataIndex: 'reason' }
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// The two halves of the disable/re-enable confirm, as components rather than
|
||||
// branches inside the handler: every piece of copy differs between them, so
|
||||
// one decision up front reads better than the same condition asked five times.
|
||||
@@ -50,15 +95,32 @@ function ReEnableWarning() {
|
||||
// branches, and every one of them counted toward Customers().
|
||||
function CustomerDetailPanel({
|
||||
detail,
|
||||
loading
|
||||
}: Readonly<{ detail: CustomerDetail | null; loading: boolean }>) {
|
||||
loading,
|
||||
emailChanges,
|
||||
onChangeEmail
|
||||
}: Readonly<{
|
||||
detail: CustomerDetail | null;
|
||||
loading: boolean;
|
||||
emailChanges: CustomerEmailChange[];
|
||||
onChangeEmail: () => void;
|
||||
}>) {
|
||||
if (loading || !detail) {
|
||||
return <Spin />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="Email">{detail.customer.email}</Descriptions.Item>
|
||||
<Descriptions.Item label="Email">
|
||||
{detail.customer.email}
|
||||
{/* Next to the address rather than among the account actions: this is
|
||||
a thing done *to* this field, and it is reached by someone already
|
||||
looking at it because a customer told them they cannot. */}
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Button size="small" onClick={onChangeEmail} style={{ paddingInline: 0 }} type="link">
|
||||
Move to a new address
|
||||
</Button>
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Verified">
|
||||
<Tag color={detail.customer.email_verified ? 'green' : 'default'}>
|
||||
{detail.customer.email_verified ? 'Verified' : 'Unverified'}
|
||||
@@ -101,6 +163,8 @@ function CustomerDetailPanel({
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EmailChangeHistory changes={emailChanges} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -284,6 +348,8 @@ export default function Customers() {
|
||||
const [reservedLoading, setReservedLoading] = useState(false);
|
||||
const [releasing, setReleasing] = useState<number | null>(null);
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null);
|
||||
const [emailChanges, setEmailChanges] = useState<CustomerEmailChange[]>([]);
|
||||
const [movingEmail, setMovingEmail] = useState(false);
|
||||
|
||||
function load() {
|
||||
return fetchCustomers()
|
||||
@@ -298,9 +364,21 @@ export default function Customers() {
|
||||
async function openDetail(id: number) {
|
||||
setDrawerOpen(true);
|
||||
setDetailLoading(true);
|
||||
// Cleared rather than left standing: the drawer is reused for every row, and
|
||||
// one customer's address history showing under another's name is the worst
|
||||
// possible thing for this particular table to get wrong.
|
||||
setEmailChanges([]);
|
||||
const data = await fetchCustomerDetail(id);
|
||||
setDetail(data);
|
||||
setDetailLoading(false);
|
||||
// After the detail, and allowed to fail on its own. This is a rare extra
|
||||
// rather than part of the record, so a drawer that opens without it beats
|
||||
// one that does not open at all.
|
||||
try {
|
||||
setEmailChanges(await fetchCustomerEmailChanges(id));
|
||||
} catch {
|
||||
setEmailChanges([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function openReserved(customer: CustomerSummary) {
|
||||
@@ -428,9 +506,31 @@ export default function Customers() {
|
||||
onClose={() => { setDrawerOpen(false); setDetail(null); }}
|
||||
width={480}
|
||||
>
|
||||
<CustomerDetailPanel detail={detail} loading={detailLoading} />
|
||||
<CustomerDetailPanel
|
||||
detail={detail}
|
||||
loading={detailLoading}
|
||||
emailChanges={emailChanges}
|
||||
onChangeEmail={() => setMovingEmail(true)}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
{/* Mounted only with a customer in hand, so the modal cannot be opened
|
||||
against a drawer that has since been closed and emptied. */}
|
||||
{detail && (
|
||||
<ChangeCustomerEmail
|
||||
customerId={detail.customer.id}
|
||||
currentEmail={detail.customer.email}
|
||||
open={movingEmail}
|
||||
onClose={() => setMovingEmail(false)}
|
||||
onChanged={() => {
|
||||
// Both, and in this order. The drawer is what the operator is
|
||||
// looking at, and the table behind it still shows the old address.
|
||||
void openDetail(detail.customer.id);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={reservedFor ? `Items reserved by ${reservedFor.email}` : 'Reserved items'}
|
||||
open={reservedFor !== null}
|
||||
|
||||
@@ -43,6 +43,22 @@ export interface CustomerDetail {
|
||||
orders: CustomerOrder[];
|
||||
}
|
||||
|
||||
/** One recorded admin-initiated address change (#337). */
|
||||
export interface CustomerEmailChange {
|
||||
id: number;
|
||||
previous_email: string;
|
||||
new_email: string;
|
||||
reason: string;
|
||||
changed_at: string;
|
||||
}
|
||||
|
||||
export interface EmailChangeResult {
|
||||
customer: CustomerDetail['customer'];
|
||||
previousEmail: string;
|
||||
/** Removed by the change, and worth telling the customer about. */
|
||||
passkeysRemoved: number;
|
||||
}
|
||||
|
||||
export async function fetchCustomers(): Promise<CustomerSummary[]> {
|
||||
const res = await fetch('/api/admin/customers');
|
||||
return res.json();
|
||||
@@ -71,6 +87,38 @@ export async function releaseReservedItem(customerId: number, itemId: number): P
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves an account to an address its owner can reach (#337).
|
||||
*
|
||||
* The reason is required by the server, not merely collected by the form. This
|
||||
* operation and an account takeover are the same operation, and the recorded
|
||||
* reason is the only thing that tells them apart afterwards.
|
||||
*/
|
||||
export async function changeCustomerEmail(
|
||||
customerId: number,
|
||||
email: string,
|
||||
reason: string
|
||||
): Promise<EmailChangeResult> {
|
||||
const res = await fetch(`/api/admin/customers/${customerId}/email`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, reason })
|
||||
});
|
||||
// Reporting success for a change that failed would leave the operator telling
|
||||
// a customer to check an inbox nothing was sent to.
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => ({}));
|
||||
throw new Error(detail.error || 'failed to change the email address');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchCustomerEmailChanges(customerId: number): Promise<CustomerEmailChange[]> {
|
||||
const res = await fetch(`/api/admin/customers/${customerId}/email-changes`);
|
||||
if (!res.ok) throw new Error('failed to load the address history');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function setCustomerDisabled(customerId: number, disabled: boolean): Promise<void> {
|
||||
const res = await fetch(`/api/admin/customers/${customerId}/${disabled ? 'disable' : 'enable'}`, {
|
||||
method: 'POST'
|
||||
|
||||
@@ -55,6 +55,20 @@ export interface SiteConfig {
|
||||
currency: string;
|
||||
/** Origin for uploaded images. Empty means the app's own — see uploadUrl. */
|
||||
uploadsBaseUrl: string;
|
||||
/**
|
||||
* Brevo Marketing Automation key (#56). Null when the environment sets none,
|
||||
* which is how QA avoids reporting test browsing into the live Brevo account.
|
||||
* A key alone does not start tracking — see brevo.ts.
|
||||
*/
|
||||
brevoTrackerKey: string | null;
|
||||
/**
|
||||
* Whether Google sign-in is configured in this environment (#345).
|
||||
*
|
||||
* False locally without credentials, and false in QA until #313 moves it off
|
||||
* the Synology hostname — Google refuses a redirect URI whose domain nobody
|
||||
* can prove they own. The button is then absent rather than disabled.
|
||||
*/
|
||||
googleSignIn: boolean;
|
||||
}
|
||||
|
||||
export async function fetchConfig(): Promise<SiteConfig> {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Brevo's web tracker (#56).
|
||||
*
|
||||
* Same shape as paypal.ts: the script is injected at runtime, so there is no
|
||||
* package to import types from and this file declares the sliver actually used.
|
||||
* Deliberately narrow — it describes what is called here, not the whole SDK.
|
||||
*
|
||||
* Two rules hold everywhere in this module, and every exported function is
|
||||
* written so that breaking either is impossible rather than merely discouraged:
|
||||
*
|
||||
* 1. **Nothing loads without a key.** No key means no script, no cookie, no
|
||||
* request. That is what keeps QA out of the production Brevo account.
|
||||
* 2. **Nothing loads without consent.** `start` is the only thing that injects
|
||||
* the script, and its caller gates on the customer's `analytics_consent`,
|
||||
* which the server derives from the wording that customer actually agreed
|
||||
* to. A signed-out visitor is never tracked, because there is no consent
|
||||
* record to consult.
|
||||
*/
|
||||
|
||||
interface SendinblueSdk {
|
||||
page: (name?: string, properties?: Record<string, unknown>) => void;
|
||||
identify: (email: string, attributes?: Record<string, unknown>) => void;
|
||||
track: (event: string, properties?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
/** The queue the snippet installs so calls made before load are not lost. */
|
||||
interface SibQueue {
|
||||
equeue: unknown[];
|
||||
client_key?: string;
|
||||
[method: string]: unknown;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
sendinblue?: SendinblueSdk;
|
||||
sib?: SibQueue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gates every call below. False until `start`, and false again after `stop`.
|
||||
*
|
||||
* Kept separate from `window.sendinblue` being present because the two answer
|
||||
* different questions: the SDK stays in the document once injected, but consent
|
||||
* can be withdrawn within the same page. Checking only for the SDK would keep
|
||||
* reporting after sign-out.
|
||||
*/
|
||||
let enabled = false;
|
||||
let loaded = false;
|
||||
|
||||
/**
|
||||
* Installs the method queue and injects the script.
|
||||
*
|
||||
* The queue matters: `sa.js` loads asynchronously and the methods have to exist
|
||||
* before it arrives, or the `page()` for the landing route — the one call that
|
||||
* always happens immediately — is dropped. This is Brevo's own snippet, written
|
||||
* out as typed code rather than pasted as an opaque blob.
|
||||
*/
|
||||
function inject(key: string): void {
|
||||
if (loaded) return;
|
||||
loaded = true;
|
||||
|
||||
const queue: SibQueue = { equeue: [], client_key: key };
|
||||
window.sib = queue;
|
||||
|
||||
const sdk = {} as SendinblueSdk;
|
||||
const methods = ['track', 'identify', 'page'] as const;
|
||||
for (const method of methods) {
|
||||
sdk[method] = (...args: unknown[]) => {
|
||||
const ready = queue[method];
|
||||
if (typeof ready === 'function') {
|
||||
(ready as (...a: unknown[]) => void)(...args);
|
||||
} else {
|
||||
queue.equeue.push({ [method]: args });
|
||||
}
|
||||
};
|
||||
}
|
||||
window.sendinblue = sdk;
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.id = 'sendinblue-js';
|
||||
script.async = true;
|
||||
script.src = `https://sibautomation.com/sa.js?key=${encodeURIComponent(key)}`;
|
||||
// A tracker that cannot load must never take the page down with it. There is
|
||||
// no retry and no error surfaced: losing analytics is not worth telling a
|
||||
// customer about, and a visible failure here would be noise on every visit
|
||||
// from anyone running a blocker.
|
||||
script.onerror = () => { enabled = false; };
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins tracking for a consenting, signed-in customer.
|
||||
*
|
||||
* Safe to call repeatedly — React effects will. The script is injected once;
|
||||
* later calls only re-assert identity, which is what a customer switching
|
||||
* accounts in one session needs.
|
||||
*/
|
||||
export function startBrevoTracking(key: string | null, email: string): void {
|
||||
if (!key) return;
|
||||
inject(key);
|
||||
enabled = true;
|
||||
window.sendinblue?.identify(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops tracking, on sign-out or when consent is withdrawn.
|
||||
*
|
||||
* **This stops calls, it does not unload the script.** Nothing can un-inject a
|
||||
* script tag or take back the cookies it set, so `sa.js` stays in the document
|
||||
* until the next full page load. What this guarantees is that no further page
|
||||
* view or event is reported, and no identity is re-asserted, which is the part
|
||||
* this application actually controls. Said plainly here because "tracking
|
||||
* stops" is easy to read as a stronger promise than any web tracker can make.
|
||||
*/
|
||||
export function stopBrevoTracking(): void {
|
||||
enabled = false;
|
||||
}
|
||||
|
||||
/** A route change. No-op unless tracking is currently permitted. */
|
||||
export function brevoPage(path: string): void {
|
||||
if (!enabled) return;
|
||||
window.sendinblue?.page(path);
|
||||
}
|
||||
|
||||
/** A named event. No-op unless tracking is currently permitted. */
|
||||
export function brevoTrack(event: string, properties?: Record<string, unknown>): void {
|
||||
if (!enabled) return;
|
||||
window.sendinblue?.track(event, properties);
|
||||
}
|
||||
|
||||
/** Exported for tests, which need to observe the gate without a real script. */
|
||||
export function isBrevoTrackingEnabled(): boolean {
|
||||
return enabled;
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
// Reported here rather than at the UI call sites so that no caller can add an
|
||||
// item or complete a checkout without it being counted (#56). Every one of
|
||||
// these calls is a no-op unless a consenting customer is signed in and the
|
||||
// environment has a key, so importing this into the API layer does not make the
|
||||
// cart depend on analytics being configured.
|
||||
import { brevoTrack } from '../brevo';
|
||||
|
||||
export interface CartItem {
|
||||
item_id: number;
|
||||
name: string;
|
||||
@@ -34,7 +41,14 @@ export function fetchCart(): Promise<{ items: CartItem[] }> {
|
||||
}
|
||||
|
||||
export function addToCart(itemId: number): Promise<{ itemId: number; expiresAt: string }> {
|
||||
return fetch(`/api/cart/items/${itemId}`, { method: 'POST' }).then(res => handle(res));
|
||||
// After handle(), which throws on a non-OK response — so a refused add (the
|
||||
// item was already reserved by someone else) is not reported as one.
|
||||
return fetch(`/api/cart/items/${itemId}`, { method: 'POST' })
|
||||
.then(res => handle<{ itemId: number; expiresAt: string }>(res))
|
||||
.then(result => {
|
||||
brevoTrack('added_to_cart', { itemId });
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
export function removeFromCart(itemId: number): Promise<void> {
|
||||
@@ -80,12 +94,19 @@ export function createCartPaypalOrder(shippingAddressId: number): Promise<{ orde
|
||||
}).then(res => handle(res));
|
||||
}
|
||||
|
||||
// The two checkout completions are separate functions rather than one, so both
|
||||
// report the event and both say which they were. Without the processor these
|
||||
// would be indistinguishable in Brevo, and a demo purchase charges nothing —
|
||||
// counting it as a sale would overstate revenue. QA cannot reach here anyway
|
||||
// (no key), but DEMO_MODE is not exclusive to QA, so the distinction is real.
|
||||
export function captureCartPaypalOrder(orderID: string): Promise<void> {
|
||||
return fetch('/api/checkout/cart/paypal/capture', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ orderID })
|
||||
}).then(res => handle(res));
|
||||
})
|
||||
.then(res => handle<void>(res))
|
||||
.then(() => { brevoTrack('checkout_completed', { processor: 'paypal' }); });
|
||||
}
|
||||
|
||||
export function demoCartPurchase(shippingAddressId: number): Promise<void> {
|
||||
@@ -93,5 +114,7 @@ export function demoCartPurchase(shippingAddressId: number): Promise<void> {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ shippingAddressId })
|
||||
}).then(res => handle(res));
|
||||
})
|
||||
.then(res => handle<void>(res))
|
||||
.then(() => { brevoTrack('checkout_completed', { processor: 'demo' }); });
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ import message from 'antd/es/message';
|
||||
import Space from 'antd/es/space';
|
||||
import Divider from 'antd/es/divider';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { updateConsent, exportMyData, deleteMyAccount, resendVerificationEmail } from './customerApi';
|
||||
import { updateConsent, updateAnalyticsConsent, exportMyData, deleteMyAccount, resendVerificationEmail } from './customerApi';
|
||||
import { setFavoriteAlerts } from './favoritesApi';
|
||||
import { useCustomerAuth } from './CustomerAuthContext';
|
||||
import AccountDetails from './AccountDetails';
|
||||
import Passkeys from './Passkeys';
|
||||
import ConnectedAccounts from './ConnectedAccounts';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -62,6 +64,14 @@ export default function Account({ onClose }: Props) {
|
||||
refresh();
|
||||
}
|
||||
|
||||
// Its own handler and its own endpoint. Withdrawing this must not disturb the
|
||||
// email consent, and must be exactly as easy as giving it (#56).
|
||||
async function handleAnalyticsConsentToggle(checked: boolean) {
|
||||
await updateAnalyticsConsent(checked);
|
||||
message.success(checked ? 'Thanks — this helps us send you relevant emails' : 'Turned off — we will stop sharing your activity');
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await logout();
|
||||
@@ -133,7 +143,7 @@ export default function Account({ onClose }: Props) {
|
||||
|
||||
<Divider />
|
||||
<Space align="center">
|
||||
<Switch checked={customer.marketing_consent} onChange={handleConsentToggle} />
|
||||
<Switch aria-label="Receive emails about new items" checked={customer.marketing_consent} onChange={handleConsentToggle} />
|
||||
<Text>Receive emails about new items</Text>
|
||||
</Space>
|
||||
|
||||
@@ -141,11 +151,36 @@ export default function Account({ onClose }: Props) {
|
||||
customer can hold one without the other. */}
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Space align="center">
|
||||
<Switch checked={customer.favorite_alerts} onChange={handleFavoriteAlertsToggle} />
|
||||
<Switch aria-label="Email me when an item I favorited is sold" checked={customer.favorite_alerts} onChange={handleFavoriteAlertsToggle} />
|
||||
<Text>Email me when an item I favorited is sold</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Analytics, and a third independent consent (#56). Named as sharing
|
||||
with Brevo rather than as "analytics", because the customer cannot
|
||||
weigh a decision described in a word that hides who receives the
|
||||
data. The subtext restates that it is optional, since this is the
|
||||
control that has to make withdrawal as easy as consenting. */}
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Space align="center">
|
||||
<Switch aria-label="Share what I browse and buy with Brevo" checked={customer.analytics_consent} onChange={handleAnalyticsConsentToggle} />
|
||||
<Text>Share what I browse and buy with Brevo, to make emails relevant</Text>
|
||||
</Space>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Optional, and independent of the emails above. Turning it off stops any further
|
||||
activity being shared.
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
{/* Renders nothing where WebAuthn is unavailable, so a browser that
|
||||
cannot do this is not offered a button that fails (#40). */}
|
||||
<Passkeys />
|
||||
|
||||
<ConnectedAccounts />
|
||||
|
||||
<Divider />
|
||||
<Space wrap>
|
||||
{/* Order history is a page of its own now. The link stays here because
|
||||
|
||||
@@ -30,6 +30,10 @@ export default function AccountDetails({ customer, onChanged }: Props) {
|
||||
const [passwordForm] = Form.useForm();
|
||||
const [emailForm] = Form.useForm();
|
||||
|
||||
// A customer who signed up with Google has none, which changes the wording,
|
||||
// the button, and whether a current-password field exists at all (#344).
|
||||
const hasPassword = customer.has_password;
|
||||
|
||||
async function saveName(values: { firstName: string; lastName: string }) {
|
||||
setBusy('name');
|
||||
setNameError(null);
|
||||
@@ -59,15 +63,21 @@ export default function AccountDetails({ customer, onChanged }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
async function savePassword(values: { currentPassword: string; newPassword: string }) {
|
||||
async function savePassword(values: { currentPassword?: string; newPassword: string }) {
|
||||
setBusy('password');
|
||||
setPasswordError(null);
|
||||
try {
|
||||
await changeMyPassword(values.currentPassword, values.newPassword);
|
||||
// Nothing to refresh: this session is deliberately the one kept alive.
|
||||
// Clearing the fields matters more, since they hold both passwords.
|
||||
await changeMyPassword(values.currentPassword ?? '', values.newPassword);
|
||||
// Clearing the fields matters more than anything else here, since they
|
||||
// hold both passwords. Setting a first one does refresh, because
|
||||
// has_password has just changed and this panel renders from it.
|
||||
passwordForm.resetFields();
|
||||
message.success('Password changed. Other devices have been signed out.');
|
||||
if (hasPassword) {
|
||||
message.success('Password changed. Other devices have been signed out.');
|
||||
} else {
|
||||
onChanged();
|
||||
message.success('Password set. You can now sign in with it as well as with Google.');
|
||||
}
|
||||
} catch (err) {
|
||||
setPasswordError((err as Error).message);
|
||||
} finally {
|
||||
@@ -151,23 +161,33 @@ export default function AccountDetails({ customer, onChanged }: Props) {
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
label: 'Change your password',
|
||||
// Named for what it is for this customer. Offering to change a
|
||||
// password to somebody who signed up with Google and has never had
|
||||
// one is a dead end (#344).
|
||||
label: hasPassword ? 'Change your password' : 'Set a password',
|
||||
children: (
|
||||
<>
|
||||
<Paragraph type="secondary">
|
||||
Signing in elsewhere will end. You will stay signed in on this device.
|
||||
{hasPassword
|
||||
? 'Signing in elsewhere will end. You will stay signed in on this device.'
|
||||
: 'You signed up without a password. Setting one gives you a second way in, alongside the accounts listed below.'}
|
||||
</Paragraph>
|
||||
{passwordError && (
|
||||
<Alert type="error" showIcon message={passwordError} style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
<Form layout="vertical" form={passwordForm} onFinish={savePassword}>
|
||||
<Form.Item
|
||||
name="currentPassword"
|
||||
label="Current password"
|
||||
rules={[{ required: true, message: 'Your current password is required' }]}
|
||||
>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
{/* Absent, not disabled, for an account that has none. The
|
||||
server branches on the stored hash rather than on anything
|
||||
sent, so there is nothing for this field to carry. */}
|
||||
{hasPassword && (
|
||||
<Form.Item
|
||||
name="currentPassword"
|
||||
label="Current password"
|
||||
rules={[{ required: true, message: 'Your current password is required' }]}
|
||||
>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
name="newPassword"
|
||||
label="New password"
|
||||
@@ -193,7 +213,7 @@ export default function AccountDetails({ customer, onChanged }: Props) {
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="primary" htmlType="submit" loading={busy === 'password'}>
|
||||
Change password
|
||||
{hasPassword ? 'Change password' : 'Set password'}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import Form from 'antd/es/form';
|
||||
import Input from 'antd/es/input';
|
||||
import Button from 'antd/es/button';
|
||||
@@ -6,8 +7,11 @@ import Checkbox from 'antd/es/checkbox';
|
||||
import Tabs from 'antd/es/tabs';
|
||||
import Alert from 'antd/es/alert';
|
||||
import Typography from 'antd/es/typography';
|
||||
import { registerCustomer, loginCustomer } from './customerApi';
|
||||
import Divider from 'antd/es/divider';
|
||||
import { registerCustomer, loginCustomer, signInWithPasskey, passkeysSupported } from './customerApi';
|
||||
import { useCustomerAuth } from './CustomerAuthContext';
|
||||
import GoogleSignInButton from './GoogleSignInButton';
|
||||
import { fetchConfig } from '../api';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -23,6 +27,15 @@ export type AuthMode = 'register' | 'login';
|
||||
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.';
|
||||
|
||||
// Analytics consent (#56). A second sentence and a second checkbox rather than
|
||||
// wording folded into the one above, because GDPR requires consent to be
|
||||
// granular: someone must be able to take the emails and refuse the tracking.
|
||||
// Must stay identical to ANALYTICS_CONSENT_TEXT in backend/src/utils.ts, which
|
||||
// is what gets stored verbatim — same rule, and same failure mode, as the
|
||||
// marketing sentence.
|
||||
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.';
|
||||
|
||||
type Props = Readonly<{
|
||||
mode: AuthMode;
|
||||
onModeChange: (mode: AuthMode) => void;
|
||||
@@ -31,17 +44,76 @@ type Props = Readonly<{
|
||||
// route closes back to the page behind it, while the cart and favorite
|
||||
// prompts resume the action the customer was interrupted doing.
|
||||
onSuccess: () => void;
|
||||
/**
|
||||
* Where a Google sign-in should return the customer (#345).
|
||||
*
|
||||
* Supplied by the caller because only the caller knows: the route modal has a
|
||||
* page behind it, and the cart prompt has the page it interrupted. An OAuth
|
||||
* redirect leaves the application entirely, so this cannot be recovered
|
||||
* afterwards the way onSuccess recovers it for every other path.
|
||||
*/
|
||||
returnTo?: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* What a Google sign-in that ended badly wants the login form to say (#343).
|
||||
*
|
||||
* Read from the query string because the callback is a redirect: it cannot
|
||||
* return a body, and the customer's browser arrives here having been sent by
|
||||
* Google. A parameter is the only channel there is.
|
||||
*
|
||||
* `google-use-password` is the interesting one. It means the customer has an
|
||||
* account and simply cannot reach it this way, which is the single refusal in
|
||||
* this flow they can act on — so it says what to do rather than what failed.
|
||||
*
|
||||
* 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 only about themselves.
|
||||
*/
|
||||
function googleNotice(reason: string | null): string | null {
|
||||
if (reason === 'google-use-password') {
|
||||
return 'You already have an account with this email address. Log in with your password below.';
|
||||
}
|
||||
if (reason === 'google-failed') {
|
||||
return 'That Google sign-in did not work. You can log in with your password instead.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The one implementation of signing in and registering. It was previously
|
||||
// written twice — once as the /login and /register pages, once inside the
|
||||
// prompt shown when a signed-out visitor adds to the cart — which had already
|
||||
// drifted in consent wording and in which links each offered.
|
||||
export default function AuthForm({ mode, onModeChange, onForgotPassword, onSuccess }: Props) {
|
||||
export default function AuthForm({ mode, onModeChange, onForgotPassword, onSuccess, returnTo = '/' }: Props) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const notice = googleNotice(searchParams.get('auth'));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Separate from `loading`, so the password button does not sit disabled and
|
||||
// spinning while the browser's passkey prompt is open. The whole requirement
|
||||
// is that a dismissed prompt leaves a usable password form behind it.
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
const { refresh } = useCustomerAuth();
|
||||
|
||||
// Read once at render rather than per click: a browser either implements
|
||||
// WebAuthn or it does not, and this decides whether the control exists at all
|
||||
// rather than whether pressing it works.
|
||||
const canUsePasskeys = passkeysSupported();
|
||||
|
||||
// Whether this environment has Google credentials at all. Fetched rather than
|
||||
// built in, because one image serves every environment — and false is the
|
||||
// right starting value: a button that appears a moment late is better than
|
||||
// one that appears and then vanishes.
|
||||
const [googleEnabled, setGoogleEnabled] = useState(false);
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
.then((config) => setGoogleEnabled(config.googleSignIn))
|
||||
// Silent, and the button simply never appears. The password form behind
|
||||
// it works regardless, which is the whole reason it is below rather than
|
||||
// above.
|
||||
.catch(() => setGoogleEnabled(false));
|
||||
}, []);
|
||||
|
||||
async function submit(action: () => Promise<unknown>) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -56,8 +128,44 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in with a passkey (#41).
|
||||
*
|
||||
* Not routed through `submit`, because the two differ in the one place that
|
||||
* matters: dismissing the browser's prompt rejects, and that is a
|
||||
* cancellation rather than a failure. Showing an error there would tell a
|
||||
* customer something went wrong when they changed their mind, and would leave
|
||||
* a red alert sitting above a password form that is working perfectly.
|
||||
*
|
||||
* Every other outcome clears back to the password form rather than a dead
|
||||
* end. The server answers every refusal identically — no such credential, a
|
||||
* disabled account, a bad assertion — so this cannot say whether an account
|
||||
* exists, and neither can the copy here.
|
||||
*/
|
||||
async function signInWithAPasskey() {
|
||||
setPasskeyLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await signInWithPasskey();
|
||||
refresh();
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
const name = (err as { name?: string }).name;
|
||||
if (name === 'NotAllowedError' || name === 'AbortError') return;
|
||||
setError('That passkey did not work. You can log in with your password instead.');
|
||||
} finally {
|
||||
setPasskeyLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The notice sits above the tabs and below any live error, because it
|
||||
describes how the customer arrived rather than what they just did. An
|
||||
error from this form supersedes it. */}
|
||||
{!error && notice && (
|
||||
<Alert type="info" showIcon message={notice} style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
{error && <Alert type="error" showIcon message={error} style={{ marginBottom: 16 }} />}
|
||||
<Tabs
|
||||
activeKey={mode}
|
||||
@@ -76,7 +184,7 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce
|
||||
layout="vertical"
|
||||
onFinish={(values) =>
|
||||
submit(() =>
|
||||
registerCustomer(values.email, values.password, values.firstName, values.lastName, !!values.marketingConsent)
|
||||
registerCustomer(values.email, values.password, values.firstName, values.lastName, !!values.marketingConsent, !!values.analyticsConsent)
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -107,6 +215,14 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce
|
||||
<Form.Item name="marketingConsent" valuePropName="checked" initialValue={false}>
|
||||
<Checkbox>{MARKETING_CONSENT_TEXT}</Checkbox>
|
||||
</Form.Item>
|
||||
{/* Its own checkbox, and independent of the one above: someone
|
||||
has to be able to take the emails and refuse the tracking,
|
||||
or the consent is not granular and is not valid. Unchecked
|
||||
by default and never pre-ticked — Quebec's Law 25 requires
|
||||
profiling to be off until the person switches it on. */}
|
||||
<Form.Item name="analyticsConsent" valuePropName="checked" initialValue={false}>
|
||||
<Checkbox>{ANALYTICS_CONSENT_TEXT}</Checkbox>
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
Create account
|
||||
</Button>
|
||||
@@ -117,6 +233,27 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce
|
||||
By creating an account you agree to our{' '}
|
||||
<a href="/privacy" target="_blank" rel="noopener noreferrer">Privacy Policy</a>.
|
||||
</Text>
|
||||
|
||||
{/* On this tab too, and its absence here was a bug (#345).
|
||||
A passkey belongs only on Log In, because you cannot
|
||||
register an account with one — but creating an account is
|
||||
exactly what a new customer reaches for Google to do, so
|
||||
leaving it off the sign-up tab hid the feature from the
|
||||
people it helps most.
|
||||
|
||||
The two consent boxes above are 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, on the step they land on (#342). */}
|
||||
{googleEnabled && (
|
||||
<>
|
||||
<Divider plain style={{ marginBlock: 16 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>or</Text>
|
||||
</Divider>
|
||||
<GoogleSignInButton returnTo={returnTo} intent="sign-up" />
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
)
|
||||
},
|
||||
@@ -140,6 +277,46 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce
|
||||
<Button type="link" style={{ paddingInline: 0, marginTop: 8 }} onClick={onForgotPassword}>
|
||||
Forgot password?
|
||||
</Button>
|
||||
|
||||
{/* Below the password form, not above it. Passwords are how
|
||||
every existing customer signs in, and a passkey is the
|
||||
alternative — putting it first would demote the path that
|
||||
works for everyone. Absent entirely where WebAuthn is not
|
||||
available, rather than shown disabled: a greyed button
|
||||
invites a customer to wonder what they are missing (#41). */}
|
||||
{(canUsePasskeys || googleEnabled) && (
|
||||
<Divider plain style={{ marginBlock: 16 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>or</Text>
|
||||
</Divider>
|
||||
)}
|
||||
{canUsePasskeys && (
|
||||
<>
|
||||
<Button
|
||||
block
|
||||
loading={passkeyLoading}
|
||||
onClick={signInWithAPasskey}
|
||||
>
|
||||
Sign in with a passkey
|
||||
</Button>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
|
||||
{/* Says what it needs rather than naming the standard.
|
||||
"WebAuthn" means nothing to a customer, and the thing
|
||||
they recognise is the gesture their device asks for. */}
|
||||
Use your fingerprint, face or screen lock.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{/* 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. Absent
|
||||
rather than disabled where it is not configured, for the
|
||||
same reason as the one above (#345). */}
|
||||
{googleEnabled && (
|
||||
<div style={{ marginTop: canUsePasskeys ? 16 : 0 }}>
|
||||
<GoogleSignInButton returnTo={returnTo} />
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,12 @@ export default function AuthPromptModal({ open, onClose, onSuccess }: Props) {
|
||||
onClose();
|
||||
navigate('/forgot-password', { state: { background: location } });
|
||||
}}
|
||||
// The page the customer was on when this interrupted them, which is
|
||||
// where a Google round trip should put them back (#345). Unlike
|
||||
// onSuccess it cannot resume the interrupted action — the redirect
|
||||
// leaves the application — so it returns them to the page and they
|
||||
// press the button again.
|
||||
returnTo={`${location.pathname}${location.search}`}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -7,6 +7,15 @@ type Props = Readonly<{
|
||||
// Moving between the auth routes, supplied by the router so the rule about
|
||||
// keeping the whole detour to one history entry lives in one place.
|
||||
onNavigate: (path: string) => void;
|
||||
/**
|
||||
* The page behind this modal, for a Google sign-in to return to (#345).
|
||||
*
|
||||
* Supplied by the router, which is the only thing that knows it: this modal
|
||||
* renders over a backdrop location, and its own path is /login, so reading
|
||||
* the current URL here would send the customer back to the form they just
|
||||
* left.
|
||||
*/
|
||||
returnTo: string;
|
||||
}>;
|
||||
|
||||
const TITLES: Record<AuthMode, string> = {
|
||||
@@ -18,7 +27,7 @@ const TITLES: Record<AuthMode, string> = {
|
||||
// clicks Log in while browsing and changes their mind is not stranded. Both
|
||||
// stay real routes: /reset-password links to /login, and customers may have
|
||||
// bookmarks.
|
||||
export default function AuthRouteModal({ mode, onClose, onNavigate }: Props) {
|
||||
export default function AuthRouteModal({ mode, onClose, onNavigate, returnTo }: Props) {
|
||||
return (
|
||||
<Modal
|
||||
title={TITLES[mode]}
|
||||
@@ -36,6 +45,7 @@ export default function AuthRouteModal({ mode, onClose, onNavigate }: Props) {
|
||||
// in while browsing wants to carry on browsing rather than be moved to
|
||||
// their account page.
|
||||
onSuccess={onClose}
|
||||
returnTo={returnTo}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Typography from 'antd/es/typography';
|
||||
import Tag from 'antd/es/tag';
|
||||
import Spin from 'antd/es/spin';
|
||||
import { fetchIdentities, Identity } from './customerApi';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
const PROVIDER_NAMES: Record<string, string> = { google: 'Google' };
|
||||
|
||||
/**
|
||||
* Which identity providers this account can be signed in with (#343).
|
||||
*
|
||||
* Linking happens automatically when Google vouches for an address that already
|
||||
* has an account here. That is defensible — whoever completed the sign-in
|
||||
* demonstrably controls the mailbox, which is already the root of trust for a
|
||||
* password reset — but it is not obvious, and a customer who signed up with a
|
||||
* password has had two credentials joined without being asked.
|
||||
*
|
||||
* A silent link is indistinguishable from a bug when somebody later wonders 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.
|
||||
*
|
||||
* Read-only for now. Removing the only way into an account is the question #344
|
||||
* settles, and offering an unlink button before that check runs would be the
|
||||
* fastest way to lock somebody out of their own orders.
|
||||
*/
|
||||
export default function ConnectedAccounts() {
|
||||
const [identities, setIdentities] = useState<Identity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchIdentities()
|
||||
.then(setIdentities)
|
||||
// Silent. This is a supplementary panel on a page whose real content is
|
||||
// elsewhere, and a red error over the account settings because one extra
|
||||
// read failed would be worse than the panel simply not appearing.
|
||||
.catch(() => setIdentities([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Nothing at all for the overwhelming majority, who have never used a
|
||||
// provider. An empty state here would appear on every account page to say
|
||||
// that nothing had happened.
|
||||
if (loading) return <Spin />;
|
||||
if (identities.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title level={5}>Connected accounts</Title>
|
||||
<Paragraph type="secondary" style={{ fontSize: 13 }}>
|
||||
You can sign in with these as well as with your password.
|
||||
</Paragraph>
|
||||
{identities.map((identity) => (
|
||||
<div key={identity.provider} style={{ marginBottom: 8 }}>
|
||||
<Tag color="blue">{PROVIDER_NAMES[identity.provider] ?? identity.provider}</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{/* Last used rather than connected, for the reason the passkey list
|
||||
shows it: it is what tells a customer whether something is still
|
||||
theirs, where a connection date says only that it happened. */}
|
||||
{identity.last_used_at
|
||||
? `last used ${new Date(identity.last_used_at).toLocaleDateString()}`
|
||||
: 'never used to sign in'}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Button from 'antd/es/button';
|
||||
|
||||
/**
|
||||
* Google's own mark, inlined as SVG (#345).
|
||||
*
|
||||
* Their identity guidelines specify the four colours and the geometry, and a
|
||||
* hand-drawn approximation of somebody else's trademark is a compliance problem
|
||||
* rather than a style choice. These are the published values.
|
||||
*
|
||||
* Inlined rather than fetched, for the reason every other asset in this app is:
|
||||
* a second origin is a second thing that can be down, blocked, or slow, and
|
||||
* this one sits on the sign-in path.
|
||||
*/
|
||||
function GoogleMark() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true" focusable="false">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.8.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.34A9 9 0 0 0 9 18z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M3.97 10.72a5.4 5.4 0 0 1 0-3.44V4.94H.96a9 9 0 0 0 0 8.12l3.01-2.34z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.59C13.46.9 11.43 0 9 0A9 9 0 0 0 .96 4.94l3.01 2.34C4.68 5.16 6.66 3.58 9 3.58z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = Readonly<{
|
||||
/** Where to send the customer back to. Validated again on the server. */
|
||||
returnTo: string;
|
||||
/**
|
||||
* Which tab this sits on, which changes only the wording.
|
||||
*
|
||||
* One endpoint serves both: it signs in a known identity, links a verified
|
||||
* address, or creates an account. The customer does not know or care which
|
||||
* of those will happen, so the label matches what they came to the tab to
|
||||
* do rather than what the server ends up doing.
|
||||
*
|
||||
* Both spellings are in Google identity guidelines alongside the mark.
|
||||
*/
|
||||
intent?: 'sign-in' | 'sign-up';
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Signing in with Google (#345).
|
||||
*
|
||||
* A navigation rather than a fetch, which is what makes this different from
|
||||
* every other control on the auth form. The flow leaves this application
|
||||
* entirely, so there is no promise to await and no error to catch here — the
|
||||
* server's callback decides what happens and redirects accordingly.
|
||||
*
|
||||
* `returnTo` is sent as a query parameter and **validated on the server**, not
|
||||
* here. It has to be, since anyone can type the URL, and doing it in one place
|
||||
* beats doing it in two languages. See `google/returnTo.ts`.
|
||||
*/
|
||||
export default function GoogleSignInButton({ returnTo, intent = 'sign-in' }: Props) {
|
||||
return (
|
||||
<Button
|
||||
block
|
||||
icon={<GoogleMark />}
|
||||
onClick={() => {
|
||||
// assign rather than the router: this is a full page departure to
|
||||
// another origin, and react-router would try to match it as a route.
|
||||
window.location.assign(`/api/auth/google/start?returnTo=${encodeURIComponent(returnTo)}`);
|
||||
}}
|
||||
>
|
||||
{intent === 'sign-up' ? 'Sign up with Google' : 'Sign in with Google'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Button from 'antd/es/button';
|
||||
import List from 'antd/es/list';
|
||||
import Space from 'antd/es/space';
|
||||
import Typography from 'antd/es/typography';
|
||||
import Popconfirm from 'antd/es/popconfirm';
|
||||
import message from 'antd/es/message';
|
||||
import { Passkey, fetchPasskeys, registerPasskey, revokePasskey } from './customerApi';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/**
|
||||
* The passkeys section of the account page (#40).
|
||||
*
|
||||
* Registering a passkey with no way to see or remove it is worse than not
|
||||
* offering passkeys at all, which is the whole reason this exists.
|
||||
*
|
||||
* Rendered only where WebAuthn is available. A browser without it gets nothing
|
||||
* rather than a button that cannot work — the same rule #41 applies to the login
|
||||
* form, and the reason the check is here rather than inside the click handler.
|
||||
*/
|
||||
const SUPPORTED =
|
||||
typeof window !== 'undefined' && typeof window.PublicKeyCredential === 'function';
|
||||
|
||||
function whenUsed(passkey: Passkey): string {
|
||||
// The thing that actually 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 "last used" answers that where a creation date does not.
|
||||
if (!passkey.last_used_at) return 'never used';
|
||||
return `last used ${new Date(passkey.last_used_at).toLocaleDateString()}`;
|
||||
}
|
||||
|
||||
export default function Passkeys() {
|
||||
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
|
||||
// Seeded from support rather than always true, so the unsupported case needs
|
||||
// no effect to correct it. Setting it synchronously in the effect below would
|
||||
// be a state update during render as far as the hooks rule is concerned, and
|
||||
// suppressing that would be hiding the smell rather than removing it.
|
||||
const [loading, setLoading] = useState(SUPPORTED);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
return fetchPasskeys()
|
||||
.then(setPasskeys)
|
||||
// Distinguished from an empty list on purpose: "you have no passkeys" and
|
||||
// "we could not find out" look identical otherwise, and the first invites
|
||||
// a customer to add one they may already have.
|
||||
.catch(() => message.error('Could not load your passkeys'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// `load` raises the pending flag before fetching. The rule cannot tell that
|
||||
// from a value that was already knowable, and this is the former — the same
|
||||
// exception CustomerAuthContext and DraftQueue take, for the same reason.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (SUPPORTED) void load();
|
||||
}, [load]);
|
||||
|
||||
if (!SUPPORTED) return null;
|
||||
|
||||
async function handleAdd() {
|
||||
setAdding(true);
|
||||
try {
|
||||
setPasskeys(await registerPasskey());
|
||||
message.success('Passkey added');
|
||||
} catch (err) {
|
||||
// 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.
|
||||
const name = (err as { name?: string }).name;
|
||||
if (name !== 'NotAllowedError' && name !== 'AbortError') {
|
||||
message.error(`Couldn't add a passkey — ${(err as Error).message}`);
|
||||
}
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(passkey: Passkey) {
|
||||
try {
|
||||
await revokePasskey(passkey.id);
|
||||
} catch (err) {
|
||||
// The server's message is shown rather than replaced. Refusing to remove
|
||||
// the last way in says what to do about it, and a generic message would
|
||||
// strand the customer on a button that just does not work.
|
||||
message.error((err as Error).message);
|
||||
return;
|
||||
}
|
||||
message.success(`Removed ${passkey.name}`);
|
||||
void load();
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Space align="center" style={{ marginBottom: 8 }}>
|
||||
<Text strong>Passkeys</Text>
|
||||
<Button size="small" loading={adding} onClick={handleAdd}>
|
||||
Add a passkey
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Sign in with your fingerprint, face or screen lock instead of your password.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<List
|
||||
loading={loading}
|
||||
dataSource={passkeys}
|
||||
locale={{ emptyText: 'No passkeys yet' }}
|
||||
renderItem={(passkey) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Popconfirm
|
||||
key="remove"
|
||||
title={`Remove ${passkey.name}?`}
|
||||
description="You will not be able to sign in with this device again."
|
||||
okText="Remove"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => handleRevoke(passkey)}
|
||||
>
|
||||
<Button size="small" danger>
|
||||
Remove
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={passkey.name}
|
||||
description={`Added ${new Date(passkey.created_at).toLocaleDateString()} · ${whenUsed(passkey)}`}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,54 @@ export default function PrivacyPolicy() {
|
||||
link included in every marketing email — no login required.
|
||||
</Paragraph>
|
||||
|
||||
<Title level={4}>Analytics and tracking</Title>
|
||||
<Paragraph>
|
||||
If — and only if — you have separately opted in to it, we share what you browse and buy on
|
||||
this site with <strong>Brevo</strong>, the service that sends our emails, so that what
|
||||
they contain is relevant to you. That covers the pages you visit here, items you add to
|
||||
your cart or favorite, and completed orders, linked to your email address.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<strong>This is a separate choice from receiving the emails themselves.</strong> You can
|
||||
have the emails without it, or turn it off and keep receiving them. It is off unless you
|
||||
switch it on — we never enable it by default, and never as a side effect of subscribing to
|
||||
anything else. Both choices live in your account settings, and turning either off is as
|
||||
easy as turning it on.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
If you have not opted in, none of this happens: no tracking script is loaded, nothing is
|
||||
sent, and no tracking cookie is set. The same is true if you are not signed in — we do not
|
||||
track visitors who do not have an account. Turning it off stops any further activity being
|
||||
shared. Two limits worth being plain about: anything already shared with Brevo before you
|
||||
turned it off remains with them, and a tracking cookie set earlier in your visit stays in
|
||||
your browser until you close the tab or clear it.
|
||||
</Paragraph>
|
||||
|
||||
<Title level={4}>Cookies and browser storage</Title>
|
||||
<Paragraph>
|
||||
We do not show a cookie banner, because until you ask for something that needs one we do
|
||||
not set anything that requires your permission. Here is everything, so you can check that
|
||||
claim rather than take it on trust:
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<strong>A sign-in cookie (<code>rd_session</code>).</strong> Set only when you sign in,
|
||||
and only so the site knows it is still you on the next page. It cannot be read by
|
||||
JavaScript, is not shared with anyone, and is not used to track you. Signing out removes
|
||||
it. This is what the rules call a strictly necessary cookie: without it, signing in would
|
||||
not work at all, so it does not need — and we do not ask for — separate consent.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<strong>Two preferences kept in your browser</strong>, not cookies and never sent to us:
|
||||
whether you chose the light or dark theme, and how many items you like to see per page.
|
||||
They stay on your device and are readable only by this site. Clearing your browser data
|
||||
removes them.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<strong>Brevo's tracking cookie — only if you opted in to sharing your activity.</strong>{' '}
|
||||
If you have not, the script that would set it is never loaded, so the cookie never exists.
|
||||
It is not set for signed-out visitors under any circumstances.
|
||||
</Paragraph>
|
||||
|
||||
<Title level={4}>Your rights</Title>
|
||||
<Paragraph>
|
||||
You may request a copy of your data ("Download my data" in your account page), or delete your
|
||||
|
||||
@@ -22,6 +22,11 @@ export default function ResetPassword({ onClose, onRequestNewLink, onBackToSignI
|
||||
const token = searchParams.get('token');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Set only when the reset actually removed passkeys (#42). A toast would be
|
||||
// the wrong shape for this: it dismisses itself, and a customer who is told
|
||||
// that credentials they do not remember registering have just been deleted
|
||||
// needs to still be looking at that when they decide what to do about it.
|
||||
const [passkeysRemoved, setPasskeysRemoved] = useState(0);
|
||||
const { refresh } = useCustomerAuth();
|
||||
|
||||
// A link without a token can't do anything, so say so rather than showing a
|
||||
@@ -48,11 +53,17 @@ export default function ResetPassword({ onClose, onRequestNewLink, onBackToSignI
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await resetPassword(token as string, values.password);
|
||||
const result = await resetPassword(token as string, values.password);
|
||||
// The server signs the customer in as part of the reset, so pick up the
|
||||
// new session before closing. Closing lands on the storefront: the link
|
||||
// came from an email, so there is no page behind to return to.
|
||||
refresh();
|
||||
// Unless there is something to say. The reset succeeded either way, so
|
||||
// this is a notice to acknowledge rather than a step still to complete.
|
||||
if (result.passkeysRemoved > 0) {
|
||||
setPasskeysRemoved(result.passkeysRemoved);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
@@ -61,6 +72,31 @@ export default function ResetPassword({ onClose, onRequestNewLink, onBackToSignI
|
||||
}
|
||||
}
|
||||
|
||||
// The reset is done and the customer is signed in; this is the notice about
|
||||
// what else it took with it. Shown in place of the form because there is
|
||||
// nothing left to fill in, and closed by the customer rather than by a timer.
|
||||
if (passkeysRemoved > 0) {
|
||||
return (
|
||||
<Modal title="Password changed" open onCancel={onClose} footer={null} destroyOnHidden>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={
|
||||
passkeysRemoved === 1
|
||||
? 'Your saved passkey was removed'
|
||||
: `Your ${passkeysRemoved} saved passkeys were removed`
|
||||
}
|
||||
description="Resetting a password removes them, so nobody who had access to your account keeps a way in. You can set them up again from your account page."
|
||||
/>
|
||||
<Paragraph style={{ marginTop: 16 }}>
|
||||
<Button type="primary" onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
</Paragraph>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Choose a new password"
|
||||
@@ -73,6 +109,13 @@ export default function ResetPassword({ onClose, onRequestNewLink, onBackToSignI
|
||||
<Paragraph type="secondary">
|
||||
Signing in elsewhere will end — you'll stay signed in on this device.
|
||||
</Paragraph>
|
||||
{/* Said unconditionally, and it has to be: this form has no session and
|
||||
is not told whether the account has passkeys, because answering that
|
||||
would make the reset page an oracle for it. The wording works either
|
||||
way — someone with none reads it and has nothing to lose. */}
|
||||
<Paragraph type="secondary">
|
||||
Any passkeys saved on this account will be removed. You can add them again afterwards.
|
||||
</Paragraph>
|
||||
{error && <Alert type="error" showIcon message={error} style={{ marginBottom: 16 }} />}
|
||||
<Form layout="vertical" onFinish={onFinish}>
|
||||
<Form.Item
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react';
|
||||
import Modal from 'antd/es/modal';
|
||||
import Checkbox from 'antd/es/checkbox';
|
||||
import Button from 'antd/es/button';
|
||||
import Space from 'antd/es/space';
|
||||
import Alert from 'antd/es/alert';
|
||||
import Typography from 'antd/es/typography';
|
||||
import { updateConsent, updateAnalyticsConsent } from './customerApi';
|
||||
import { MARKETING_CONSENT_TEXT, ANALYTICS_CONSENT_TEXT } from './AuthForm';
|
||||
|
||||
const { Paragraph, Title } = Typography;
|
||||
|
||||
type Props = Readonly<{ onClose: () => void }>;
|
||||
|
||||
/**
|
||||
* The consent step a customer sees once, right after signing up with Google (#342).
|
||||
*
|
||||
* ## Why this screen has to exist
|
||||
*
|
||||
* Registration asks for two consents and stores their wording verbatim, and
|
||||
* marketing consent must start unticked (#56). Somebody who arrived through
|
||||
* Google has never seen those checkboxes and could not have: the redirect
|
||||
* happened before anyone knew whether they were new.
|
||||
*
|
||||
* Their account is created with both false, which is legally correct — nobody
|
||||
* agreed to anything and nothing is recorded as though they had. But leaving it
|
||||
* there would mean a Google sign-up is never asked at all, and a silent no is
|
||||
* still a decision made on someone else's behalf.
|
||||
*
|
||||
* ## Why the wording is imported rather than written here
|
||||
*
|
||||
* These two constants are the same strings the server stores against the
|
||||
* consent. The record is meant to say what the customer actually saw, so a
|
||||
* second copy of the sentence that drifted by a word would quietly defeat that.
|
||||
* Three wordings were already in circulation once before this was shared.
|
||||
*
|
||||
* ## Why skipping is a real option, not a soft refusal
|
||||
*
|
||||
* Consent has to be as easy to withhold as to give. "Not now" leaves both false
|
||||
* and closes, and nothing is sent. Both can be changed later from the account
|
||||
* page, which is where a customer who changes their mind will look.
|
||||
*/
|
||||
export default function Welcome({ onClose }: Props) {
|
||||
const [marketing, setMarketing] = useState(false);
|
||||
const [analytics, setAnalytics] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Two calls to two endpoints, which is the point rather than an
|
||||
// inefficiency: they are separate consents with separate purposes, and
|
||||
// the server stores the wording for each independently.
|
||||
await updateConsent(marketing);
|
||||
await updateAnalyticsConsent(analytics);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
// The account exists and they are signed in either way, so this is not a
|
||||
// failure to recover from — only a preference that did not save.
|
||||
setError(`Those preferences didn't save — ${(err as Error).message}. You can set them on your account page.`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Welcome to Redefined Designs"
|
||||
open
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
style={{ maxWidth: 'calc(100vw - 32px)' }}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Paragraph type="secondary">
|
||||
Your account is ready and you are signed in. Two optional things, and you can change
|
||||
either of them later on your account page.
|
||||
</Paragraph>
|
||||
|
||||
{error && <Alert type="warning" showIcon message={error} style={{ marginBottom: 16 }} />}
|
||||
|
||||
<Space direction="vertical" size="middle" style={{ display: 'flex' }}>
|
||||
<Checkbox checked={marketing} onChange={(e) => setMarketing(e.target.checked)}>
|
||||
{MARKETING_CONSENT_TEXT}
|
||||
</Checkbox>
|
||||
{/* Its own checkbox and independently refusable. Someone has to be able
|
||||
to take the emails and refuse the tracking, or the consent is not
|
||||
granular and is not valid. Unticked, and never pre-ticked: Quebec's
|
||||
Law 25 requires profiling to be off until the person switches it on. */}
|
||||
<Checkbox checked={analytics} onChange={(e) => setAnalytics(e.target.checked)}>
|
||||
{ANALYTICS_CONSENT_TEXT}
|
||||
</Checkbox>
|
||||
</Space>
|
||||
|
||||
<Space style={{ marginTop: 24 }}>
|
||||
<Button type="primary" loading={saving} onClick={save}>
|
||||
Save preferences
|
||||
</Button>
|
||||
{/* As prominent as it needs to be. Withholding consent has to be as
|
||||
easy as giving it, and a "Not now" hidden in small print is the
|
||||
pattern that makes a consent invalid. */}
|
||||
<Button onClick={onClose} disabled={saving}>
|
||||
Not now
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Title level={5} style={{ marginTop: 24, fontSize: 13, opacity: 0.65 }}>
|
||||
Leaving both unticked is fine — we will not email you or share what you browse.
|
||||
</Title>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,25 @@ export interface Customer {
|
||||
last_name: string | null;
|
||||
email_verified: boolean;
|
||||
marketing_consent: boolean;
|
||||
/**
|
||||
* Whether this customer agreed to the *current* consent wording, which is the
|
||||
* only thing that authorises the Brevo tracker (#56).
|
||||
*
|
||||
* Not a duplicate of marketing_consent: the two disagree for anyone who
|
||||
* consented before that sentence was widened to mention analytics. Computed
|
||||
* on the server from the wording stored against the customer — never derive
|
||||
* it here from marketing_consent, which is the mistake it exists to prevent.
|
||||
*/
|
||||
analytics_consent: boolean;
|
||||
favorite_alerts: boolean;
|
||||
/**
|
||||
* Whether this account has a password at all (#344).
|
||||
*
|
||||
* False for anyone who signed up with Google. The account page reads it to
|
||||
* decide between offering to change a password and offering to set a first
|
||||
* one, which are different things to somebody who has never had one.
|
||||
*/
|
||||
has_password: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -33,12 +51,16 @@ export function registerCustomer(
|
||||
password: string,
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
marketingConsent: boolean
|
||||
marketingConsent: boolean,
|
||||
// Separate argument rather than folded into the one above: they are separate
|
||||
// consents and the caller has to be able to send one true and the other
|
||||
// false. The server treats an absent value as false (#56).
|
||||
analyticsConsent: boolean
|
||||
): Promise<Customer> {
|
||||
return fetch('/api/customers/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, firstName, lastName, marketingConsent })
|
||||
body: JSON.stringify({ email, password, firstName, lastName, marketingConsent, analyticsConsent })
|
||||
}).then(res => handle<Customer>(res));
|
||||
}
|
||||
|
||||
@@ -83,6 +105,21 @@ export function updateConsent(marketingConsent: boolean): Promise<void> {
|
||||
}).then(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Its own endpoint, not a second field on updateConsent (#56).
|
||||
*
|
||||
* Withdrawal has to be as easy as giving consent, and it has to be possible to
|
||||
* withdraw one without touching the other. A combined call would make it easy
|
||||
* to send a stale value for the answer the customer did not change.
|
||||
*/
|
||||
export function updateAnalyticsConsent(analyticsConsent: boolean): Promise<void> {
|
||||
return fetch('/api/customers/me/analytics-consent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ analyticsConsent })
|
||||
}).then(() => undefined);
|
||||
}
|
||||
|
||||
export function fetchMyOrders(): Promise<OrderHistoryItem[]> {
|
||||
return fetch('/api/customers/me/orders').then(res => handle<OrderHistoryItem[]>(res));
|
||||
}
|
||||
@@ -103,12 +140,23 @@ export function requestPasswordReset(email: string): Promise<{ status: string }>
|
||||
}).then(res => handle<{ status: string }>(res));
|
||||
}
|
||||
|
||||
export function resetPassword(token: string, password: string): Promise<Customer> {
|
||||
/**
|
||||
* A completed reset, and how many passkeys it removed (#42).
|
||||
*
|
||||
* The count is part of the answer rather than something to look up afterwards:
|
||||
* the credentials are already gone by the time the form could go and ask, so
|
||||
* the only moment this can be reported is this one.
|
||||
*/
|
||||
export interface PasswordResetResult extends Customer {
|
||||
passkeysRemoved: number;
|
||||
}
|
||||
|
||||
export function resetPassword(token: string, password: string): Promise<PasswordResetResult> {
|
||||
return fetch('/api/customers/reset-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, password })
|
||||
}).then(res => handle<Customer>(res));
|
||||
}).then(res => handle<PasswordResetResult>(res));
|
||||
}
|
||||
|
||||
export function updateMyName(firstName: string, lastName: string): Promise<Customer> {
|
||||
@@ -143,6 +191,111 @@ export function changeMyEmail(currentPassword: string, email: string): Promise<C
|
||||
}).then(res => handle<Customer>(res));
|
||||
}
|
||||
|
||||
/** One identity provider this account can sign in with (#343). */
|
||||
export interface Identity {
|
||||
provider: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
}
|
||||
|
||||
export function fetchIdentities(): Promise<Identity[]> {
|
||||
return fetch('/api/customers/me/identities').then(res => handle<Identity[]>(res));
|
||||
}
|
||||
|
||||
/** A registered passkey, as the account page lists it (#40). */
|
||||
export interface Passkey {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
}
|
||||
|
||||
export function fetchPasskeys(): Promise<Passkey[]> {
|
||||
return fetch('/api/customers/me/passkeys').then(res => handle<Passkey[]>(res));
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a passkey on this device.
|
||||
*
|
||||
* Both halves of the ceremony live here rather than in the component, because
|
||||
* they are one operation: options come from the server, the browser turns them
|
||||
* into an attestation, and the server verifies it. A component holding the
|
||||
* intermediate state could leave a challenge issued and never answered.
|
||||
*
|
||||
* `startRegistration` is what prompts the customer. It throws when they dismiss
|
||||
* that prompt, which is a cancellation rather than a failure — the caller tells
|
||||
* them apart.
|
||||
*/
|
||||
export async function registerPasskey(name?: string): Promise<Passkey[]> {
|
||||
const { startRegistration } = await import('@simplewebauthn/browser');
|
||||
|
||||
const optionsRes = await fetch('/api/customers/me/passkeys/register/begin', { method: 'POST' });
|
||||
const options = await handle<Parameters<typeof startRegistration>[0]['optionsJSON']>(optionsRes);
|
||||
|
||||
const attestation = await startRegistration({ optionsJSON: options });
|
||||
|
||||
const finishRes = await fetch('/api/customers/me/passkeys/register/finish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...attestation, name })
|
||||
});
|
||||
await handle<{ name: string }>(finishRes);
|
||||
|
||||
// The fresh list rather than the one row, so the caller cannot render a list
|
||||
// that disagrees with the server about what was just added.
|
||||
return fetchPasskeys();
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs in with a passkey (#41).
|
||||
*
|
||||
* Usernameless: nothing is sent to `begin`, and the browser offers whichever
|
||||
* accounts it holds. The customer never types an address, which is also why
|
||||
* this cannot leak whether one has an account — there is nothing to ask about.
|
||||
*
|
||||
* Rejects when the customer dismisses the prompt, which callers must treat as a
|
||||
* cancellation rather than a failure.
|
||||
*/
|
||||
export async function signInWithPasskey(): Promise<Customer> {
|
||||
const { startAuthentication } = await import('@simplewebauthn/browser');
|
||||
|
||||
const optionsRes = await fetch('/api/customers/passkeys/login/begin', { method: 'POST' });
|
||||
const options = await handle<Parameters<typeof startAuthentication>[0]['optionsJSON']>(optionsRes);
|
||||
|
||||
const assertion = await startAuthentication({ optionsJSON: options });
|
||||
|
||||
const finishRes = await fetch('/api/customers/passkeys/login/finish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(assertion)
|
||||
});
|
||||
return handle<Customer>(finishRes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this browser can do WebAuthn at all.
|
||||
*
|
||||
* Checked before offering the control rather than inside its handler, so a
|
||||
* browser that cannot do this is never shown a button that fails. Password
|
||||
* login stays the fallback in every case (#41).
|
||||
*/
|
||||
export function passkeysSupported(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.PublicKeyCredential === 'function';
|
||||
}
|
||||
|
||||
export function revokePasskey(id: number): Promise<void> {
|
||||
return fetch(`/api/customers/me/passkeys/${id}`, { method: 'DELETE' }).then(async (res) => {
|
||||
// 204 on success, so handle() would throw on an empty body. The failure
|
||||
// message matters here — refusing to remove the last way in says what to do
|
||||
// about it, and replacing that with something generic would strand the
|
||||
// customer on a button that simply does not work.
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Request failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function resendVerificationEmail(): Promise<void> {
|
||||
// 204 on success, so handle() would throw parsing an empty body. The failure
|
||||
// path must still reject: the server's message distinguishes "already
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Customer } from './customerApi';
|
||||
// See the note in cart/cartApi.ts: reported here rather than at the call sites
|
||||
// so no caller can favorite an item without it being counted (#56).
|
||||
import { brevoTrack } from '../brevo';
|
||||
|
||||
export interface Favorite {
|
||||
item_id: number;
|
||||
@@ -26,6 +29,11 @@ export async function addFavorite(itemId: number): Promise<void> {
|
||||
await fetch(`/api/customers/me/favorites/${itemId}`, { method: 'POST' }),
|
||||
'failed to save favorite'
|
||||
);
|
||||
// After expectOk, which throws on failure, so a favorite that was not saved
|
||||
// is not reported as one. Only favoriting is tracked, not un-favoriting:
|
||||
// #56 asked for the act of favoriting as a signal of interest, and removal is
|
||||
// a different question nobody has asked yet.
|
||||
brevoTrack('favorited', { itemId });
|
||||
}
|
||||
|
||||
export async function removeFavorite(itemId: number): Promise<void> {
|
||||
|
||||
+17
-3
@@ -13,6 +13,7 @@ import ErrorFallback from './components/ErrorFallback';
|
||||
import DevThrow from './components/DevThrow';
|
||||
import Admin from './admin/Admin';
|
||||
import AuthRouteModal from './customer/AuthRouteModal';
|
||||
import Welcome from './customer/Welcome';
|
||||
import Account from './customer/Account';
|
||||
import PrivacyPolicy from './customer/PrivacyPolicy';
|
||||
import Submit from './intake/Submit';
|
||||
@@ -27,6 +28,7 @@ import { FavoritesProvider } from './customer/FavoritesContext';
|
||||
import { ThemeModeProvider, useThemeMode } from './theme/ThemeContext';
|
||||
import './styles.css';
|
||||
import { fetchConfig } from './api';
|
||||
import BrevoTracking from './BrevoTracking';
|
||||
|
||||
// The brand accent is monochrome, so it inverts between themes rather than
|
||||
// switching to a different hue.
|
||||
@@ -44,7 +46,7 @@ const STOREFRONT_BACKDROP: Partial<Location> = { pathname: '/', search: '', hash
|
||||
// than as a page of their own. Each stays a real, linkable URL — bookmarkable,
|
||||
// refreshable, and closed by the browser's Back button — while never being
|
||||
// somewhere with no way out.
|
||||
const MODAL_ROUTES = ['/account', '/login', '/register', '/forgot-password', '/reset-password'];
|
||||
const MODAL_ROUTES = ['/account', '/login', '/register', '/forgot-password', '/reset-password', '/welcome'];
|
||||
|
||||
// Respects the OS-level "reduce motion" accessibility setting by turning off
|
||||
// antd's transitions. Beyond the accessibility win, animated popups are a
|
||||
@@ -146,6 +148,10 @@ function AppRoutes() {
|
||||
// the storefront, so closing always lands somewhere real.
|
||||
const background = state?.background;
|
||||
const backdrop = modalPath ? background ?? { ...location, ...STOREFRONT_BACKDROP } : location;
|
||||
// Where a Google sign-in should land the customer: the page behind the modal,
|
||||
// not the modal's own path. Built here because the backdrop is only known
|
||||
// here, and validated again on the server (#345).
|
||||
const returnTo = `${backdrop.pathname}${backdrop.search ?? ''}`;
|
||||
|
||||
function closeModal() {
|
||||
// Back, when there is somewhere to go back to, so closing the modal and
|
||||
@@ -163,6 +169,10 @@ function AppRoutes() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Renders nothing. Mounted here because this is the innermost place
|
||||
that has both the router and the auth context, which is what it
|
||||
needs to report route changes for a consenting customer (#56). */}
|
||||
<BrevoTracking />
|
||||
{import.meta.env.DEV && <DevThrow scope="page" />}
|
||||
<Routes location={backdrop}>
|
||||
<Route path="/" element={<App />} />
|
||||
@@ -187,14 +197,18 @@ function AppRoutes() {
|
||||
{import.meta.env.DEV && <DevThrow scope="modal" />}
|
||||
{modalPath === '/account' && <Account onClose={closeModal} />}
|
||||
{modalPath === '/login' && (
|
||||
<AuthRouteModal mode="login" onClose={closeModal} onNavigate={goWithinAuth} />
|
||||
<AuthRouteModal mode="login" onClose={closeModal} onNavigate={goWithinAuth} returnTo={returnTo} />
|
||||
)}
|
||||
{modalPath === '/register' && (
|
||||
<AuthRouteModal mode="register" onClose={closeModal} onNavigate={goWithinAuth} />
|
||||
<AuthRouteModal mode="register" onClose={closeModal} onNavigate={goWithinAuth} returnTo={returnTo} />
|
||||
)}
|
||||
{modalPath === '/forgot-password' && (
|
||||
<ForgotPassword onClose={closeModal} onBackToSignIn={() => goWithinAuth('/login')} />
|
||||
)}
|
||||
{/* One-time, right after a Google sign-up (#342). A route rather than
|
||||
a flag so it has an address and uses the same modal machinery as
|
||||
every other auth screen. */}
|
||||
{modalPath === '/welcome' && <Welcome onClose={closeModal} />}
|
||||
{modalPath === '/reset-password' && (
|
||||
<ResetPassword
|
||||
onClose={closeModal}
|
||||
|
||||
@@ -78,7 +78,28 @@ test.describe('My Account opens as a modal', () => {
|
||||
await expect(accountModal.orderHistoryButton).toBeVisible();
|
||||
// Scoped to the modal: the storefront behind it has a theme switch of its
|
||||
// own, so an unscoped switch locator would be ambiguous.
|
||||
await expect(accountModal.themeSwitches).toHaveCount(2);
|
||||
//
|
||||
// Three since #56 added analytics consent: marketing email, favourite
|
||||
// alerts, analytics. The count is a guard against one appearing or
|
||||
// vanishing unnoticed, so it is asserted alongside naming each of them —
|
||||
// a count alone would pass if two were swapped for each other.
|
||||
await expect(accountModal.themeSwitches).toHaveCount(3);
|
||||
await expect(accountModal.marketingConsentSwitch).toBeVisible();
|
||||
await expect(accountModal.favoriteAlertsSwitch).toBeVisible();
|
||||
await expect(accountModal.analyticsConsentSwitch).toBeVisible();
|
||||
});
|
||||
|
||||
// Quebec's Law 25 s.8.1 requires profiling to be off until the person turns
|
||||
// it on. The server defaults the column to false and the integration suite
|
||||
// asserts that; this is the half of the promise the customer can actually
|
||||
// see, and the two have to agree.
|
||||
test('analytics consent is off until the customer turns it on', async ({
|
||||
customer,
|
||||
accountModal
|
||||
}) => {
|
||||
await accountModal.open();
|
||||
|
||||
await expect(accountModal.analyticsConsentSwitch).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('deleting the account does not leave the page behind it looking signed in', async ({
|
||||
|
||||
@@ -144,4 +144,53 @@ test.describe('The review queue', () => {
|
||||
await card.getByRole('button', { name: 'Rotate right' }).click();
|
||||
expect((await rotated).status()).toBe(204);
|
||||
});
|
||||
|
||||
/**
|
||||
* The bug reported from QA in #327: two items were published and did not
|
||||
* appear in Inventory.
|
||||
*
|
||||
* The publish itself was never at fault, which is why the test above passes —
|
||||
* it asserts against `GET /api/admin/items`, and the API was always right.
|
||||
* What was wrong was the Inventory tab: antd keeps a pane mounted once it has
|
||||
* been rendered, so a tab opened at page load and returned to later refetches
|
||||
* nothing and shows the list it built the first time.
|
||||
*
|
||||
* So this asserts through the UI and, crucially, **never reloads the page**.
|
||||
* Opening Inventory before publishing is what arms it: the list is fetched
|
||||
* while the item still has its submission-timestamp name, and the assertion
|
||||
* afterwards looks for the name given at publish — which a stale list cannot
|
||||
* contain. A `page.reload()` anywhere in here would make it pass against the
|
||||
* broken behaviour.
|
||||
*/
|
||||
test('an item published from the queue appears in Inventory without a reload', async ({
|
||||
page,
|
||||
admin
|
||||
}) => {
|
||||
const note = `Tab staleness ${RUN}`;
|
||||
const publishedName = `Vase ${RUN}`;
|
||||
await submitAnItem(page, note);
|
||||
|
||||
// Inventory first, so its pane is mounted and its list fetched before the
|
||||
// publish happens. This is the state a real admin is in: the tab has been
|
||||
// open since they arrived.
|
||||
await admin.open('Inventory');
|
||||
await expect(admin.activeTable).toBeVisible();
|
||||
|
||||
await admin.openTab('Review queue');
|
||||
const card = page.locator('.ant-card').filter({ hasText: note });
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
await card.getByLabel('Name').fill(publishedName);
|
||||
// Setting a price confirms it, so publishing does not stop on the
|
||||
// unconfirmed-price dialog the test above covers.
|
||||
await card.getByLabel('Price').fill('42');
|
||||
await card.getByRole('button', { name: 'Publish', exact: true }).click();
|
||||
await expect(card.getByText('you set this price')).toBeVisible();
|
||||
|
||||
await admin.openTab('Inventory');
|
||||
|
||||
// Sorted by created_at descending and submitted moments ago, so it is on the
|
||||
// first page rather than somewhere in the accumulated dev database.
|
||||
await expect(admin.activePanel.getByText(publishedName)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,15 @@ test.describe('Customer accounts', () => {
|
||||
await expect(authModal.marketingConsent).not.toBeChecked();
|
||||
});
|
||||
|
||||
// A second, separate consent (#56). Unchecked for the same reason as the one
|
||||
// above, and asserted separately because the two must be independently
|
||||
// refusable — a single control covering both is the bundling GDPR treats as
|
||||
// invalid, and Law 25 requires this one to start off.
|
||||
test('analytics consent checkbox is unchecked by default', async ({ authModal }) => {
|
||||
await authModal.gotoRegister();
|
||||
await expect(authModal.analyticsConsent).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('the consent label is the exact wording the server records', async ({ authModal }) => {
|
||||
await authModal.gotoRegister();
|
||||
|
||||
@@ -30,6 +39,16 @@ test.describe('Customer accounts', () => {
|
||||
const consent =
|
||||
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
||||
await expect(authModal.registerDialog).toContainText(consent);
|
||||
|
||||
// The analytics consent is a second, separate sentence and a second
|
||||
// checkbox (#56). Asserted here for the same reason as the one above — it
|
||||
// is stored verbatim, so the rendered label parting from the stored string
|
||||
// defeats the record — and because the two being separate is the thing
|
||||
// that makes the consent granular. Folding them back into one control
|
||||
// would still pass the assertion above and would still be wrong.
|
||||
const analytics =
|
||||
'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.';
|
||||
await expect(authModal.registerDialog).toContainText(analytics);
|
||||
});
|
||||
|
||||
// Drives the form rather than taking the `customer` fixture: this test is
|
||||
@@ -57,6 +76,90 @@ test.describe('Customer accounts', () => {
|
||||
await expect(accountModal.emailText(email)).toBeVisible();
|
||||
});
|
||||
|
||||
// #41's requirement, and the half of it that can be proven without a real
|
||||
// authenticator. Credentials bind to the Relying Party ID, so an actual
|
||||
// passkey sign-in cannot be exercised here — but "a failed passkey prompt
|
||||
// must return the customer to a usable password form, not a dead end" is
|
||||
// about what happens *after* the attempt, and that is entirely testable.
|
||||
test('a failed passkey attempt leaves the password form working', async ({
|
||||
page,
|
||||
customer,
|
||||
accountModal,
|
||||
authModal,
|
||||
header
|
||||
}) => {
|
||||
// The fixture leaves the customer signed in, and this test is about the
|
||||
// signed-out login form.
|
||||
await accountModal.openAndLogOut();
|
||||
await expect(header.logInButton).toBeVisible();
|
||||
|
||||
await authModal.gotoLogIn();
|
||||
|
||||
const passkeyButton = authModal.logInDialog.getByRole('button', {
|
||||
name: 'Sign in with a passkey'
|
||||
});
|
||||
// Chromium implements WebAuthn, so the control is offered here. A browser
|
||||
// without it gets no button at all rather than a disabled one.
|
||||
await expect(passkeyButton).toBeVisible();
|
||||
|
||||
// Failed at the first request, before the browser prompt — so this needs no
|
||||
// authenticator and cannot hang waiting for a gesture nobody will make.
|
||||
await page.route('**/api/customers/passkeys/login/begin', (route) =>
|
||||
route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"nope"}' })
|
||||
);
|
||||
await passkeyButton.click();
|
||||
|
||||
// Says what to do next rather than only that something failed, and says
|
||||
// nothing about whether an account exists.
|
||||
await expect(page.getByText(/log in with your password instead/i)).toBeVisible();
|
||||
|
||||
// The actual requirement: not a dead end. The form behind the error still
|
||||
// signs the customer in.
|
||||
await authModal.logIn(customer.email, customer.password);
|
||||
await header.waitForSignedIn();
|
||||
});
|
||||
|
||||
// #345. Local development has no Google credentials, and neither does QA
|
||||
// until #313 moves it off a hostname whose domain nobody can prove they own.
|
||||
// So the button being ABSENT is the behaviour under test here, and it is the
|
||||
// one that matters: a control that appears and then fails at Google is worse
|
||||
// than one that was never offered.
|
||||
// The sign-up tab, which #345 left it off entirely. A passkey belongs only on
|
||||
// Log In, because you cannot register an account with one — but creating an
|
||||
// account is exactly what a new customer reaches for Google to do, so its
|
||||
// absence there hid the feature from the people it helps most.
|
||||
//
|
||||
// Asserted as absent for the same reason as the login one: local and QA have
|
||||
// no credentials, so absence is the behaviour that actually runs here.
|
||||
test('offers no Google button on the sign-up tab either, when unconfigured', async ({
|
||||
authModal
|
||||
}) => {
|
||||
await authModal.gotoRegister();
|
||||
|
||||
await expect(
|
||||
authModal.registerDialog.getByRole('button', { name: /with Google/i })
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('offers no Google button when the environment is not configured for it', async ({
|
||||
authModal,
|
||||
accountModal,
|
||||
customer,
|
||||
header
|
||||
}) => {
|
||||
await accountModal.openAndLogOut();
|
||||
await expect(header.logInButton).toBeVisible();
|
||||
await authModal.gotoLogIn();
|
||||
|
||||
const google = authModal.logInDialog.getByRole('button', { name: /Sign in with Google/i });
|
||||
await expect(google).toHaveCount(0);
|
||||
|
||||
// And the password form is untouched by its absence, which is the whole
|
||||
// reason the alternatives sit below it rather than above.
|
||||
await authModal.logIn(customer.email, customer.password);
|
||||
await header.waitForSignedIn();
|
||||
});
|
||||
|
||||
test('rejects login with the wrong password', async ({ page, customer, accountModal, authModal, header }) => {
|
||||
await accountModal.openAndLogOut();
|
||||
await expect(header.logInButton).toBeVisible();
|
||||
|
||||
@@ -25,14 +25,18 @@ test.describe('Editing the customer emails', () => {
|
||||
'Favorited item sold',
|
||||
'Favorited item withdrawn',
|
||||
'Cart reminder',
|
||||
'Email address changed'
|
||||
'Email address changed',
|
||||
// Added in #337, and the reason these are matched on the whole
|
||||
// accessible name rather than as substrings: it extends the label above
|
||||
// it, so an unanchored match resolved to both tabs.
|
||||
'Email address changed by the shop'
|
||||
]) {
|
||||
await expect(adminEmails.railTab(new RegExp(label))).toBeVisible();
|
||||
await expect(adminEmails.railTab(label)).toBeVisible();
|
||||
}
|
||||
|
||||
// Only a customised template is marked, so which ones have been changed is
|
||||
// visible without opening each one. An untouched template carries nothing.
|
||||
await expect(adminEmails.railTab(/Password reset/)).toBeVisible();
|
||||
await expect(adminEmails.railTab('Password reset')).toBeVisible();
|
||||
await expect(adminEmails.customisedTab('Password reset')).toHaveCount(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -16,9 +16,15 @@ export class AccountModal {
|
||||
readonly orderHistoryButton: Locator;
|
||||
readonly closeButton: Locator;
|
||||
readonly resendVerificationButton: Locator;
|
||||
/**
|
||||
* Every switch in the modal. Kept for the count assertion that guards against
|
||||
* a control appearing or vanishing unnoticed — the individual switches below
|
||||
* are addressed by name, not by position.
|
||||
*/
|
||||
readonly themeSwitches: Locator;
|
||||
/** The second switch in the modal; the first is the theme. */
|
||||
readonly marketingConsentSwitch: Locator;
|
||||
readonly favoriteAlertsSwitch: Locator;
|
||||
readonly analyticsConsentSwitch: Locator;
|
||||
readonly notVerifiedNotice: Locator;
|
||||
|
||||
readonly firstName: Locator;
|
||||
@@ -46,7 +52,25 @@ export class AccountModal {
|
||||
this.closeButton = this.dialog.getByRole('button', { name: 'Close' });
|
||||
this.resendVerificationButton = this.dialog.getByRole('button', { name: 'Send it again' });
|
||||
this.themeSwitches = this.dialog.getByRole('switch');
|
||||
this.favoriteAlertsSwitch = this.dialog.getByRole('switch').last();
|
||||
// Addressed by accessible name rather than by position. `favoriteAlertsSwitch`
|
||||
// used to be `.last()`, which silently retargeted the moment the analytics
|
||||
// consent switch was added below it (#56): the test that meant to turn
|
||||
// favourite alerts off toggled analytics consent on instead, and failed on
|
||||
// the message rather than on the switch, which said nothing about why.
|
||||
//
|
||||
// antd's Switch renders a bare `role="switch"` with no accessible name — the
|
||||
// adjacent Text is a sibling, not a label — so each one carries an explicit
|
||||
// aria-label in Account.tsx. That is what makes these addressable, and it is
|
||||
// also what a screen reader needed.
|
||||
this.marketingConsentSwitch = this.dialog.getByRole('switch', {
|
||||
name: 'Receive emails about new items'
|
||||
});
|
||||
this.favoriteAlertsSwitch = this.dialog.getByRole('switch', {
|
||||
name: 'Email me when an item I favorited is sold'
|
||||
});
|
||||
this.analyticsConsentSwitch = this.dialog.getByRole('switch', {
|
||||
name: 'Share what I browse and buy with Brevo'
|
||||
});
|
||||
this.notVerifiedNotice = this.dialog.getByText('Email not verified');
|
||||
|
||||
this.firstName = this.dialog.getByLabel('First name', { exact: true });
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
import { FrameLocator, Locator, Page, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* A matcher for one template label, against a tab's *whole* accessible name.
|
||||
*
|
||||
* A tab is named for its template, plus the word "Customised" once it has been
|
||||
* edited — the dot beside it carries that as an aria-label, so the state is not
|
||||
* colour-only.
|
||||
*
|
||||
* Anchored at both ends, which is the entire point of this function. The
|
||||
* locators here used to build an unanchored regex from the label, so a template
|
||||
* whose name merely *began* with another's matched both. Adding "Email address
|
||||
* changed by the shop" alongside "Email address changed" broke a passing test
|
||||
* with a strict-mode violation naming the assertion rather than the new
|
||||
* template — the same shape as the switch locator that silently retargeted in
|
||||
* #317, and the same cost to diagnose.
|
||||
*
|
||||
* The escape matters for the same reason: these labels are copy, and copy
|
||||
* acquires brackets and full stops eventually.
|
||||
*/
|
||||
function nameMatching(label: string, options: { customised?: boolean } = {}): RegExp {
|
||||
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const suffix = options.customised ? '\\s+Customised' : '(?:\\s+Customised)?';
|
||||
return new RegExp(`^${escaped}${suffix}$`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Emails tab: a vertical rail of template types and one editor at a time.
|
||||
*
|
||||
@@ -30,13 +54,30 @@ export class AdminEmails {
|
||||
return this.page.getByRole('button', { name: `Insert {{${name}}}` });
|
||||
}
|
||||
|
||||
/** One template's entry in the rail. */
|
||||
railTab(label: string | RegExp): Locator {
|
||||
return this.page.getByRole('tab', { name: label });
|
||||
/**
|
||||
* One template's entry in the rail, matched on its whole accessible name.
|
||||
*
|
||||
* A tab's accessible name is the template's label, plus the word "Customised"
|
||||
* when it has been edited — the dot beside it carries that as an aria-label so
|
||||
* the state is not colour-only.
|
||||
*
|
||||
* Anchored at both ends, which is the point of this helper rather than a bare
|
||||
* substring match. These locators used to build an unanchored regex from the
|
||||
* label, so a template whose name merely *began* with another's matched both.
|
||||
* Adding "Email address changed by the shop" beside "Email address changed"
|
||||
* broke a passing test with a strict-mode violation, and the failure named the
|
||||
* assertion rather than the new template — the same shape as the switch
|
||||
* locator that silently retargeted in #317.
|
||||
*
|
||||
* The escape matters for the same reason: a label is copy, and copy acquires
|
||||
* brackets and full stops eventually.
|
||||
*/
|
||||
railTab(label: string): Locator {
|
||||
return this.page.getByRole('tab', { name: nameMatching(label) });
|
||||
}
|
||||
|
||||
customisedTab(label: string): Locator {
|
||||
return this.page.getByRole('tab', { name: new RegExp(`${label}.*Customised`) });
|
||||
return this.page.getByRole('tab', { name: nameMatching(label, { customised: true }) });
|
||||
}
|
||||
|
||||
subject(label: string): Locator {
|
||||
@@ -65,7 +106,7 @@ export class AdminEmails {
|
||||
* resolved mid-swap finds the outgoing one.
|
||||
*/
|
||||
async openTemplate(label: string): Promise<void> {
|
||||
await this.railTab(new RegExp(label)).click();
|
||||
await this.railTab(label).click();
|
||||
await expect(this.subject(label)).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,17 @@ export type AdminTab =
|
||||
/**
|
||||
* The admin shell: the tab strip and the panel it swaps.
|
||||
*
|
||||
* Only the active tab's panel is mounted, which is what keeps the locators
|
||||
* Only the active tab's panel is *visible*, which is what keeps the locators
|
||||
* inside each panel unambiguous — the email editors used to be stacked and a
|
||||
* locator for "Save" matched all six.
|
||||
*
|
||||
* Visible, not mounted. antd keeps a pane in the DOM once it has been rendered
|
||||
* and only hides it, so scoping to `.ant-tabs-tabpane-active` is what makes
|
||||
* these locators work — the inactive panels are still there. This comment used
|
||||
* to say "mounted", and that mistake is the shape of #327: a tab that never
|
||||
* unmounts also never re-runs its effects, so Inventory went on showing a list
|
||||
* it had fetched before anything was published into it.
|
||||
*
|
||||
* `activePanel` exists because several specs reached for
|
||||
* `.ant-tabs-tabpane-active .ant-table` and similar to scope themselves to the
|
||||
* visible panel. That knowledge belongs here rather than in five spec files.
|
||||
|
||||
@@ -22,6 +22,7 @@ export class AuthModal {
|
||||
readonly lastName: Locator;
|
||||
readonly password: Locator;
|
||||
readonly marketingConsent: Locator;
|
||||
readonly analyticsConsent: Locator;
|
||||
readonly createAccountButton: Locator;
|
||||
readonly createAccountTab: Locator;
|
||||
readonly logInTab: Locator;
|
||||
@@ -34,7 +35,17 @@ export class AuthModal {
|
||||
this.firstName = page.getByRole('textbox', { name: 'First name' });
|
||||
this.lastName = page.getByRole('textbox', { name: 'Last name' });
|
||||
this.password = page.getByLabel('Password');
|
||||
this.marketingConsent = page.getByRole('checkbox');
|
||||
// Named rather than "the checkbox on the form". There are two consents now
|
||||
// and they are deliberately separate (#56), so an unscoped checkbox locator
|
||||
// is ambiguous in strict mode — which is how this broke. Matched on the
|
||||
// opening words of each sentence so the locator survives the wording being
|
||||
// revised, which it has been once already.
|
||||
this.marketingConsent = page.getByRole('checkbox', {
|
||||
name: /^I want to receive occasional emails/
|
||||
});
|
||||
this.analyticsConsent = page.getByRole('checkbox', {
|
||||
name: /^I agree that what I browse and buy/
|
||||
});
|
||||
this.createAccountButton = page.getByRole('button', { name: 'Create account' });
|
||||
this.createAccountTab = page.getByRole('tab', { name: 'Create Account' });
|
||||
this.logInTab = page.getByRole('tab', { name: 'Log In' });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { readPasswordResetToken } from './support/db';
|
||||
import { giveCustomerAPasskey, readPasswordResetToken } from './support/db';
|
||||
|
||||
const NEW_PASSWORD = 'a-brand-new-password';
|
||||
|
||||
@@ -118,4 +118,47 @@ test.describe('Password reset', () => {
|
||||
await expect(page.getByText('invalid email or password')).toBeVisible();
|
||||
await expect(header.myAccountButton).toHaveCount(0);
|
||||
});
|
||||
|
||||
// #42. A reset removes every passkey on the account, which is the one thing
|
||||
// it does that a customer cannot undo and might have chosen differently
|
||||
// about, so both halves of telling them are asserted here.
|
||||
test('the reset form says passkeys will be removed before the customer commits', async ({
|
||||
passwordReset,
|
||||
page
|
||||
}) => {
|
||||
await passwordReset.gotoReset('any-token-will-do');
|
||||
|
||||
// Said unconditionally, and it has to be: this 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 warning shows on a token
|
||||
// that was never issued, exactly as it would on a real one.
|
||||
await expect(page.getByText(/passkeys saved on this account will be removed/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('a reset that removed a passkey says so, and waits to be acknowledged', async ({
|
||||
page,
|
||||
request,
|
||||
customer,
|
||||
accountModal,
|
||||
header,
|
||||
passwordReset
|
||||
}) => {
|
||||
await giveCustomerAPasskey(customer.email);
|
||||
await accountModal.openAndLogOut();
|
||||
await expect(header.logInButton).toBeVisible();
|
||||
|
||||
await request.post('/api/customers/request-password-reset', { data: { email: customer.email } });
|
||||
await passwordReset.gotoReset(await readPasswordResetToken(customer.email));
|
||||
await passwordReset.setNewPassword(NEW_PASSWORD);
|
||||
|
||||
// A toast would be the wrong shape: it dismisses itself, and a customer
|
||||
// told that a credential they do not remember registering has just been
|
||||
// deleted needs to still be looking at that when they decide what to do.
|
||||
await expect(page.getByText('Your saved passkey was removed')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Done' }).click();
|
||||
|
||||
// The reset still succeeded — this was a notice, not a step that failed.
|
||||
await header.waitForSignedIn();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,3 +67,27 @@ export async function readPasswordResetToken(email: string): Promise<string> {
|
||||
return rows[0].token as string;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a passkey on an account, for the tests that are about what happens to it
|
||||
* (#42).
|
||||
*
|
||||
* Registering one for real needs an authenticator, which Playwright does not
|
||||
* have and which no fixture can supply. The credential is a row, and a password
|
||||
* reset acts on the row rather than on the hardware behind it, so a row is
|
||||
* enough to test the interaction — nothing here ever tries to sign in with it.
|
||||
*
|
||||
* Here rather than in a spec for the same reason as the token read above: the
|
||||
* next spec that wants a shortcut should have to come and ask for one.
|
||||
*/
|
||||
export async function giveCustomerAPasskey(email: string, name = 'Test key'): Promise<void> {
|
||||
await withClient(async (client) => {
|
||||
const { rowCount } = await client.query(
|
||||
`INSERT INTO customer_credentials (customer_id, credential_id, public_key, name)
|
||||
SELECT id, 'e2e-' || id || '-' || floor(random() * 1e9)::text, 'not-a-real-key', $2
|
||||
FROM customers WHERE email = $1`,
|
||||
[email, name]
|
||||
);
|
||||
if (rowCount === 0) throw new Error(`no customer to give a passkey to: ${email}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Deletes completed Actions runs older than a given age.
|
||||
*
|
||||
* Gitea 1.27.3 has retention for a run's logs (`LOG_RETENTION_DAYS`) and its
|
||||
* artifacts, but none for the run record itself — so the Actions list grows
|
||||
* without limit while the entries on it become empty shells once their logs
|
||||
* expire. This removes the shells. See #324.
|
||||
*
|
||||
* Dry run unless APPLY is exactly "true". Deleting a run cannot be undone and
|
||||
* there is no confirmation step once this starts, so the default has to be the
|
||||
* harmless one.
|
||||
*
|
||||
* Environment:
|
||||
* GITEA_HOST origin of the instance, scheme included, e.g.
|
||||
* https://gitea.example.com or http://gitea:3000
|
||||
* GITEA_REPO "owner/name"
|
||||
* GITEA_ACCESS_TOKEN token permitted to delete runs
|
||||
* KEEP_DAYS keep runs newer than this many days (default 7)
|
||||
* APPLY "true" to delete; anything else reports only
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const { URL } = require('url');
|
||||
|
||||
const HOST = process.env.GITEA_HOST;
|
||||
const REPO = process.env.GITEA_REPO;
|
||||
const TOKEN = process.env.GITEA_ACCESS_TOKEN;
|
||||
const KEEP_DAYS = Number(process.env.KEEP_DAYS || 7);
|
||||
const APPLY = process.env.APPLY === 'true';
|
||||
|
||||
for (const [name, value] of Object.entries({ GITEA_HOST: HOST, GITEA_REPO: REPO, GITEA_ACCESS_TOKEN: TOKEN })) {
|
||||
if (!value) {
|
||||
console.error(`${name} is required`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(KEEP_DAYS) || KEEP_DAYS < 0) {
|
||||
console.error(`KEEP_DAYS must be a non-negative number, got ${process.env.KEEP_DAYS}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const origin = new URL(HOST);
|
||||
|
||||
/**
|
||||
* The transport GITEA_HOST actually asks for, rather than the one assumed.
|
||||
*
|
||||
* This was hardcoded to https, which worked when the host was typed by hand and
|
||||
* failed the moment the workflow started taking it from `github.server_url`.
|
||||
* Inside the runner that is the address act_runner reaches Gitea on, which here
|
||||
* is plain HTTP on a container port — and a TLS handshake sent to a plaintext
|
||||
* port does not fail as a connection error. It fails as:
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* So the scheme is honoured rather than guessed, and the default port follows
|
||||
* from it. Anything other than the two is refused up front: this script only
|
||||
* speaks HTTP, and a URL naming some other scheme is a mistake worth reporting
|
||||
* as one instead of failing later inside a request.
|
||||
*/
|
||||
if (origin.protocol !== 'https:' && origin.protocol !== 'http:') {
|
||||
console.error(`GITEA_HOST must be an http or https URL, got ${HOST}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const secure = origin.protocol === 'https:';
|
||||
const transport = secure ? https : http;
|
||||
const PORT = origin.port || (secure ? 443 : 80);
|
||||
|
||||
const BASE = `/api/v1/repos/${REPO}/actions/runs`;
|
||||
|
||||
function call(method, path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = transport.request(
|
||||
{ hostname: origin.hostname, port: PORT, path, method, headers: { Authorization: `token ${TOKEN}` } },
|
||||
(res) => {
|
||||
let body = '';
|
||||
res.on('data', (d) => (body += d));
|
||||
res.on('end', () => resolve({ status: res.statusCode, body }));
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function listAllRuns() {
|
||||
const runs = [];
|
||||
// Bounded rather than `while (true)`: a paging bug against an API that keeps
|
||||
// answering would otherwise loop until the job times out.
|
||||
for (let page = 1; page <= 200; page++) {
|
||||
const res = await call('GET', `${BASE}?page=${page}&limit=50`);
|
||||
if (res.status >= 400) throw new Error(`listing runs failed: ${res.status} ${res.body.slice(0, 200)}`);
|
||||
const batch = JSON.parse(res.body).workflow_runs || [];
|
||||
if (batch.length === 0) break;
|
||||
runs.push(...batch);
|
||||
if (batch.length < 50) break;
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
const EPOCH_GUARD = new Date('2000-01-01').getTime();
|
||||
|
||||
/**
|
||||
* When a run happened, from whichever timestamp it actually has.
|
||||
*
|
||||
* `started_at` is preferred and is usually right, but a run **cancelled before
|
||||
* it ever started** reports an epoch value there while carrying a real
|
||||
* `completed_at`. Reading only `started_at` therefore made every cancelled run
|
||||
* look undateable, and a first pass at this left 19 of them — from three weeks
|
||||
* earlier — sitting in a list that was supposed to hold seven days.
|
||||
*
|
||||
* Returns null when neither timestamp is usable, which is the case that must
|
||||
* stay conservative: an epoch date treated as "1969, therefore old" would
|
||||
* delete precisely the runs that have not happened yet.
|
||||
*/
|
||||
function runTimeMs(run) {
|
||||
for (const stamp of [run.started_at, run.completed_at]) {
|
||||
const ms = stamp ? new Date(stamp).getTime() : NaN;
|
||||
if (Number.isFinite(ms) && ms >= EPOCH_GUARD) return ms;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs old enough to remove, and the reasons the others were left.
|
||||
*
|
||||
* A run that is not completed is never a candidate — which is also what stops
|
||||
* this deleting the very run it is executing in.
|
||||
*/
|
||||
function selectDoomed(runs, cutoffMs) {
|
||||
const doomed = [];
|
||||
const kept = { recent: 0, unfinished: 0, undated: 0 };
|
||||
|
||||
for (const run of runs) {
|
||||
if (run.status !== 'completed') {
|
||||
kept.unfinished++;
|
||||
continue;
|
||||
}
|
||||
const ms = runTimeMs(run);
|
||||
if (ms === null) {
|
||||
kept.undated++;
|
||||
continue;
|
||||
}
|
||||
if (ms >= cutoffMs) {
|
||||
kept.recent++;
|
||||
continue;
|
||||
}
|
||||
doomed.push({ id: run.id, startedAt: run.started_at, when: new Date(ms).toISOString() });
|
||||
}
|
||||
|
||||
doomed.sort((a, b) => a.id - b.id);
|
||||
return { doomed, kept };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// Printed before the first request rather than after it succeeds. A transport
|
||||
// failure here says nothing about where it was pointed, and the last one cost
|
||||
// a round trip to find out that the answer was "somewhere plaintext".
|
||||
console.log(`endpoint : ${origin.protocol}//${origin.hostname}:${PORT}`);
|
||||
|
||||
const runs = await listAllRuns();
|
||||
const cutoffMs = Date.now() - KEEP_DAYS * 86400000;
|
||||
const { doomed, kept } = selectDoomed(runs, cutoffMs);
|
||||
|
||||
console.log(`repository : ${REPO}`);
|
||||
console.log(`total runs : ${runs.length}`);
|
||||
console.log(`keeping : ${kept.recent} newer than ${KEEP_DAYS}d, ${kept.unfinished} unfinished, ${kept.undated} undated`);
|
||||
console.log(`to delete : ${doomed.length}`);
|
||||
if (doomed.length > 0) {
|
||||
console.log(` oldest : run ${doomed[0].id} (${doomed[0].when})`);
|
||||
console.log(` newest : run ${doomed[doomed.length - 1].id} (${doomed[doomed.length - 1].when})`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
if (!APPLY) {
|
||||
console.log('DRY RUN — nothing deleted. Re-run with apply set to "true".');
|
||||
return;
|
||||
}
|
||||
if (doomed.length === 0) {
|
||||
console.log('Nothing to delete.');
|
||||
return;
|
||||
}
|
||||
|
||||
let deleted = 0;
|
||||
const failures = [];
|
||||
for (const run of doomed) {
|
||||
const res = await call('DELETE', `${BASE}/${run.id}`);
|
||||
if (res.status >= 200 && res.status < 300) deleted++;
|
||||
else failures.push(`${run.id}:${res.status}`);
|
||||
|
||||
const done = deleted + failures.length;
|
||||
if (done % 50 === 0) console.log(` ...${done}/${doomed.length}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`deleted : ${deleted}`);
|
||||
console.log(`failed : ${failures.length}`);
|
||||
if (failures.length > 0) {
|
||||
console.log(` ${failures.slice(0, 20).join(' ')}${failures.length > 20 ? ' …' : ''}`);
|
||||
// A partial delete is not a success. Most likely the token cannot delete
|
||||
// runs, and reporting green here would hide that behind a job that
|
||||
// appeared to work.
|
||||
process.exitCode = 1;
|
||||
}
|
||||
})().catch((err) => {
|
||||
console.error(`FAILED: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
+29
-4
@@ -280,11 +280,36 @@ Reserved ranges: netsh interface ipv4 show excludedportrange protocol=tcp
|
||||
function Install-IfMissing {
|
||||
param([string]$Directory)
|
||||
$name = Split-Path -Leaf $Directory
|
||||
if (Test-Path (Join-Path $Directory 'node_modules')) {
|
||||
Write-Note "$name dependencies already installed"
|
||||
return
|
||||
|
||||
# Whether node_modules matches the lockfile, not merely whether it exists.
|
||||
#
|
||||
# This used to ask only `Test-Path node_modules`, which meant a branch that
|
||||
# ADDED a dependency never installed it for anyone who already had the
|
||||
# directory — and almost everyone always does. The build then failed on
|
||||
# "Cannot find module", naming a package that is right there in
|
||||
# package.json, which reads as a broken checkout rather than a missing
|
||||
# install. It cost an afternoon the first time #37 added @simplewebauthn.
|
||||
#
|
||||
# npm writes node_modules/.package-lock.json describing exactly what it put
|
||||
# there, so comparing its timestamp against package-lock.json answers the
|
||||
# real question: is what is installed what is currently asked for. A pull
|
||||
# that changes dependencies makes the lockfile newer, and this notices.
|
||||
$lockfile = Join-Path $Directory 'package-lock.json'
|
||||
$installed = Join-Path $Directory 'node_modules/.package-lock.json'
|
||||
|
||||
if ((Test-Path $installed) -and (Test-Path $lockfile)) {
|
||||
$lockTime = (Get-Item $lockfile).LastWriteTimeUtc
|
||||
$installedTime = (Get-Item $installed).LastWriteTimeUtc
|
||||
if ($installedTime -ge $lockTime) {
|
||||
Write-Note "$name dependencies are up to date"
|
||||
return
|
||||
}
|
||||
Write-Step "Installing $name dependencies (the lockfile has changed)"
|
||||
}
|
||||
Write-Step "Installing $name dependencies"
|
||||
else {
|
||||
Write-Step "Installing $name dependencies"
|
||||
}
|
||||
|
||||
Push-Location $Directory
|
||||
try { Invoke-Checked { npm install } "$name npm install" } finally { Pop-Location }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user