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;`); };