From 8ed4786fc49a80c6f6880ebfdfa9443c7e6066a6 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 14 Sep 2026 10:03:01 +0000 Subject: [PATCH 1/2] fix(auth): enforce normalized credential email ownership --- package.json | 2 +- scripts/generate-email-normalization.mjs | 83 +++++ .../595_normalized_email_invariant.sql | 189 ++++++++++++ server/src/scripts/audit-normalized-emails.ts | 40 +++ .../normalized-email-composition.test.ts | 139 +++++++++ .../normalized-email-invariant.test.ts | 284 ++++++++++++++++++ specs/normalized-email-invariant.md | 208 +++++++++++++ 7 files changed, 944 insertions(+), 1 deletion(-) create mode 100644 scripts/generate-email-normalization.mjs create mode 100644 server/src/db/migrations/595_normalized_email_invariant.sql create mode 100644 server/src/scripts/audit-normalized-emails.ts create mode 100644 server/tests/integration/normalized-email-composition.test.ts create mode 100644 server/tests/integration/normalized-email-invariant.test.ts create mode 100644 specs/normalized-email-invariant.md diff --git a/package.json b/package.json index 493293acb5c..a3ce642b12e 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "test:get-creative-features-contract": "node --test --test-force-exit --test-timeout=30000 tests/get-creative-features-idempotency.test.cjs", "test:composed": "node tests/composed-schema-validation.test.cjs && npm run test:get-creative-features-contract", "test:rejection-arm-mutex": "node tests/rejection-arm-mutual-exclusion.test.cjs", - "test:migrations": "node tests/migration-validation.test.cjs", + "test:migrations": "node tests/migration-validation.test.cjs && node scripts/generate-email-normalization.mjs --check", "test:governance-jws-vectors": "node scripts/generate-governance-jws-vectors.mjs --check && node --test --test-force-exit --test-timeout=30000 tests/governance-authorization-jws-vectors.test.cjs", "test:hmac-vectors": "node --test --test-force-exit --test-timeout=30000 tests/webhook-hmac-vectors.test.cjs", "test:hmac-signer-conformance": "node --test --test-force-exit --test-timeout=30000 tests/webhook-hmac-signer-conformance.test.cjs", diff --git a/scripts/generate-email-normalization.mjs b/scripts/generate-email-normalization.mjs new file mode 100644 index 00000000000..343163bb692 --- /dev/null +++ b/scripts/generate-email-normalization.mjs @@ -0,0 +1,83 @@ +// Pin the Unicode tables used by production Node's trim().toLowerCase(). +// Regeneration is a schema change: existing normalized indexes need revalidation. +import fs from 'node:fs'; + +if (process.versions.unicode !== '17.0') throw new Error('Use Node with Unicode 17.0; changing Unicode requires a new migration'); +const file = new URL('../server/src/db/migrations/595_normalized_email_invariant.sql', import.meta.url); +const quote = value => `'${value.replaceAll("'", "''")}'`; +let upper = '', lower = '', whitespace = ''; +const cased = [], ignorable = []; +for (let n = 1; n <= 0x10ffff; n++) { + if (n >= 0xd800 && n <= 0xdfff) continue; + const ch = String.fromCodePoint(n), mapped = ch.toLowerCase(); + if (ch.trim() === '') whitespace += ch; + if (/\p{Cased}/u.test(ch)) cased.push(n); + if (/\p{Case_Ignorable}/u.test(ch)) ignorable.push(n); + if (mapped !== ch && n !== 0x130) { + if ([...mapped].length !== 1) throw new Error(`Unexpected full lowercase mapping: ${n}`); + upper += ch; lower += mapped; + } +} +// Numeric ranges avoid locale-sensitive PostgreSQL regular-expression ranges. +function ranges(points) { + const result = []; + for (const n of points) { + const last = result.at(-1); + if (last && last[1] === n) last[1] = n + 1; + else result.push([n, n + 1]); + } + return `'{${result.map(([a,b]) => `[${a},${b})`).join(',')}}'::pg_catalog.int4multirange`; +} +const generated = `-- BEGIN GENERATED NORMALIZATION (Unicode 17.0; scripts/generate-email-normalization.mjs) +CREATE OR REPLACE FUNCTION public.normalized_credential_email(value TEXT) +RETURNS TEXT LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE +SET search_path = pg_catalog +AS $normalize$ +DECLARE + original TEXT := pg_catalog.btrim(value, ${[...whitespace].map(ch => `pg_catalog.chr(${ch.codePointAt(0)})`).join(' || ')}); + result TEXT := ''; + ch TEXT; + mapped TEXT; + point INTEGER; + position INTEGER; + following INTEGER; + previous_cased BOOLEAN := FALSE; + next_cased BOOLEAN; + cased CONSTANT pg_catalog.int4multirange := ${ranges(cased)}; + ignorable CONSTANT pg_catalog.int4multirange := ${ranges(ignorable)}; +BEGIN + -- Locale-independent ASCII fast path (the usual production address). + IF pg_catalog.octet_length(original) = pg_catalog.char_length(original) THEN + RETURN pg_catalog.translate(original, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); + END IF; + FOR position IN 1..pg_catalog.char_length(original) LOOP + ch := pg_catalog.substr(original, position, 1); + point := pg_catalog.ascii(ch); + IF point = 931 AND previous_cased THEN + next_cased := FALSE; + FOR following IN position + 1..pg_catalog.char_length(original) LOOP + point := pg_catalog.ascii(pg_catalog.substr(original, following, 1)); + IF ignorable @> point THEN CONTINUE; END IF; + next_cased := cased @> point; + EXIT; + END LOOP; + mapped := CASE WHEN next_cased THEN 'σ' ELSE 'ς' END; + ELSIF point = 304 THEN + mapped := U&'i\\0307'; + ELSE + mapped := pg_catalog.translate(ch, ${quote(upper)}, ${quote(lower)}); + END IF; + result := result || mapped; + point := pg_catalog.ascii(ch); + IF NOT (ignorable @> point) THEN previous_cased := cased @> point; END IF; + END LOOP; + RETURN result; +END; +$normalize$; +-- END GENERATED NORMALIZATION`; +const source = fs.readFileSync(file, 'utf8'); +const output = source.replace(/-- BEGIN GENERATED NORMALIZATION[\s\S]*?-- END GENERATED NORMALIZATION/, generated); +if (process.argv.includes('--check')) { + if (output !== source) throw new Error('Migration 595 normalization differs from production Node Unicode tables'); + console.log('Email normalization Unicode 17.0 generator check passed'); +} else fs.writeFileSync(file, output); diff --git a/server/src/db/migrations/595_normalized_email_invariant.sql b/server/src/db/migrations/595_normalized_email_invariant.sql new file mode 100644 index 00000000000..27d4581de76 --- /dev/null +++ b/server/src/db/migrations/595_normalized_email_invariant.sql @@ -0,0 +1,189 @@ +-- Self-contained on main's schema. Operational allocation: 591 -> 592 -> 593 -> +-- unpublished 594 -> 595. Do not renumber without coordinator direction. +-- Exact workos_user_id is the owner; canonical siblings are different owners. +-- One user's credential email and ONE of that same credential's aliases may +-- overlap, as required by the 592 primary-email swap's UPDATE-before-DELETE. +-- Alias verification status confers no authentication authority. +-- +-- Installation is transactional under the repository runner. Ambiguous legacy +-- data aborts installation, retaining every original row. Inventory with: +-- npx tsx server/src/scripts/audit-normalized-emails.ts +-- No honest data rollback exists: removing enforcement reopens the race. See +-- specs/normalized-email-invariant.md for custody, restore and rollback policy. + +DO $$ BEGIN + IF current_setting('server_encoding') <> 'UTF8' THEN + RAISE EXCEPTION 'Normalized email invariant requires UTF8 storage'; + END IF; +END $$; + +-- All cooperating DML uses a separate two-int advisory namespace, disjoint +-- from migration 592's one-bigint credential locks. Never wait while holding +-- a tuple/credential lock, including in arbitrary multi-statement callers. +DO $$ BEGIN + IF NOT pg_catalog.pg_try_advisory_xact_lock(6827, 595) THEN + RAISE EXCEPTION 'Normalized email invariant is busy' USING ERRCODE = '55P03'; + END IF; +END $$; +-- Installation also fails promptly instead of reversing an existing writer's +-- table/advisory order. Drain writers and retry the entire migration on 55P03. +LOCK TABLE public.users, public.user_email_aliases IN ACCESS EXCLUSIVE MODE NOWAIT; + +-- BEGIN GENERATED NORMALIZATION (Unicode 17.0; scripts/generate-email-normalization.mjs) +CREATE OR REPLACE FUNCTION public.normalized_credential_email(value TEXT) +RETURNS TEXT LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE +SET search_path = pg_catalog +AS $normalize$ +DECLARE + original TEXT := pg_catalog.btrim(value, pg_catalog.chr(9) || pg_catalog.chr(10) || pg_catalog.chr(11) || pg_catalog.chr(12) || pg_catalog.chr(13) || pg_catalog.chr(32) || pg_catalog.chr(160) || pg_catalog.chr(5760) || pg_catalog.chr(8192) || pg_catalog.chr(8193) || pg_catalog.chr(8194) || pg_catalog.chr(8195) || pg_catalog.chr(8196) || pg_catalog.chr(8197) || pg_catalog.chr(8198) || pg_catalog.chr(8199) || pg_catalog.chr(8200) || pg_catalog.chr(8201) || pg_catalog.chr(8202) || pg_catalog.chr(8232) || pg_catalog.chr(8233) || pg_catalog.chr(8239) || pg_catalog.chr(8287) || pg_catalog.chr(12288) || pg_catalog.chr(65279)); + result TEXT := ''; + ch TEXT; + mapped TEXT; + point INTEGER; + position INTEGER; + following INTEGER; + previous_cased BOOLEAN := FALSE; + next_cased BOOLEAN; + cased CONSTANT pg_catalog.int4multirange := '{[65,91),[97,123),[170,171),[181,182),[186,187),[192,215),[216,247),[248,443),[444,448),[452,660),[662,697),[704,706),[736,741),[837,838),[880,884),[886,888),[890,894),[895,896),[902,903),[904,907),[908,909),[910,930),[931,1014),[1015,1154),[1162,1328),[1329,1367),[1376,1417),[4256,4294),[4295,4296),[4301,4302),[4304,4347),[4348,4352),[5024,5110),[5112,5118),[7296,7307),[7312,7355),[7357,7360),[7424,7616),[7680,7958),[7960,7966),[7968,8006),[8008,8014),[8016,8024),[8025,8026),[8027,8028),[8029,8030),[8031,8062),[8064,8117),[8118,8125),[8126,8127),[8130,8133),[8134,8141),[8144,8148),[8150,8156),[8160,8173),[8178,8181),[8182,8189),[8305,8306),[8319,8320),[8336,8349),[8450,8451),[8455,8456),[8458,8468),[8469,8470),[8473,8478),[8484,8485),[8486,8487),[8488,8489),[8490,8494),[8495,8501),[8505,8506),[8508,8512),[8517,8522),[8526,8527),[8544,8576),[8579,8581),[9398,9450),[11264,11493),[11499,11503),[11506,11508),[11520,11558),[11559,11560),[11565,11566),[42560,42606),[42624,42654),[42786,42888),[42891,42895),[42896,42973),[42993,42999),[43000,43003),[43824,43867),[43868,43882),[43888,43968),[64256,64263),[64275,64280),[65313,65339),[65345,65371),[66560,66640),[66736,66772),[66776,66812),[66928,66939),[66940,66955),[66956,66963),[66964,66966),[66967,66978),[66979,66994),[66995,67002),[67003,67005),[67456,67457),[67459,67462),[67463,67505),[67506,67515),[68736,68787),[68800,68851),[68944,68966),[68976,68998),[71840,71904),[93760,93824),[93856,93881),[93883,93908),[119808,119893),[119894,119965),[119966,119968),[119970,119971),[119973,119975),[119977,119981),[119982,119994),[119995,119996),[119997,120004),[120005,120070),[120071,120075),[120077,120085),[120086,120093),[120094,120122),[120123,120127),[120128,120133),[120134,120135),[120138,120145),[120146,120486),[120488,120513),[120514,120539),[120540,120571),[120572,120597),[120598,120629),[120630,120655),[120656,120687),[120688,120713),[120714,120745),[120746,120771),[120772,120780),[122624,122634),[122635,122655),[122661,122667),[122928,122990),[125184,125252),[127280,127306),[127312,127338),[127344,127370)}'::pg_catalog.int4multirange; + ignorable CONSTANT pg_catalog.int4multirange := '{[39,40),[46,47),[58,59),[94,95),[96,97),[168,169),[173,174),[175,176),[180,181),[183,185),[688,880),[884,886),[890,891),[900,902),[903,904),[1155,1162),[1369,1370),[1375,1376),[1425,1470),[1471,1472),[1473,1475),[1476,1478),[1479,1480),[1524,1525),[1536,1542),[1552,1563),[1564,1565),[1600,1601),[1611,1632),[1648,1649),[1750,1758),[1759,1769),[1770,1774),[1807,1808),[1809,1810),[1840,1867),[1958,1969),[2027,2038),[2042,2043),[2045,2046),[2070,2094),[2137,2140),[2184,2185),[2192,2194),[2199,2208),[2249,2307),[2362,2363),[2364,2365),[2369,2377),[2381,2382),[2385,2392),[2402,2404),[2417,2418),[2433,2434),[2492,2493),[2497,2501),[2509,2510),[2530,2532),[2558,2559),[2561,2563),[2620,2621),[2625,2627),[2631,2633),[2635,2638),[2641,2642),[2672,2674),[2677,2678),[2689,2691),[2748,2749),[2753,2758),[2759,2761),[2765,2766),[2786,2788),[2810,2816),[2817,2818),[2876,2877),[2879,2880),[2881,2885),[2893,2894),[2901,2903),[2914,2916),[2946,2947),[3008,3009),[3021,3022),[3072,3073),[3076,3077),[3132,3133),[3134,3137),[3142,3145),[3146,3150),[3157,3159),[3170,3172),[3201,3202),[3260,3261),[3263,3264),[3270,3271),[3276,3278),[3298,3300),[3328,3330),[3387,3389),[3393,3397),[3405,3406),[3426,3428),[3457,3458),[3530,3531),[3538,3541),[3542,3543),[3633,3634),[3636,3643),[3654,3663),[3761,3762),[3764,3773),[3782,3783),[3784,3791),[3864,3866),[3893,3894),[3895,3896),[3897,3898),[3953,3967),[3968,3973),[3974,3976),[3981,3992),[3993,4029),[4038,4039),[4141,4145),[4146,4152),[4153,4155),[4157,4159),[4184,4186),[4190,4193),[4209,4213),[4226,4227),[4229,4231),[4237,4238),[4253,4254),[4348,4349),[4957,4960),[5906,5909),[5938,5940),[5970,5972),[6002,6004),[6068,6070),[6071,6078),[6086,6087),[6089,6100),[6103,6104),[6109,6110),[6155,6160),[6211,6212),[6277,6279),[6313,6314),[6432,6435),[6439,6441),[6450,6451),[6457,6460),[6679,6681),[6683,6684),[6742,6743),[6744,6751),[6752,6753),[6754,6755),[6757,6765),[6771,6781),[6783,6784),[6823,6824),[6832,6878),[6880,6892),[6912,6916),[6964,6965),[6966,6971),[6972,6973),[6978,6979),[7019,7028),[7040,7042),[7074,7078),[7080,7082),[7083,7086),[7142,7143),[7144,7146),[7149,7150),[7151,7154),[7212,7220),[7222,7224),[7288,7294),[7376,7379),[7380,7393),[7394,7401),[7405,7406),[7412,7413),[7416,7418),[7468,7531),[7544,7545),[7579,7680),[8125,8126),[8127,8130),[8141,8144),[8157,8160),[8173,8176),[8189,8191),[8203,8208),[8216,8218),[8228,8229),[8231,8232),[8234,8239),[8288,8293),[8294,8304),[8305,8306),[8319,8320),[8336,8349),[8400,8433),[11388,11390),[11503,11506),[11631,11632),[11647,11648),[11744,11776),[11823,11824),[12293,12294),[12330,12334),[12337,12342),[12347,12348),[12441,12447),[12540,12543),[40981,40982),[42232,42238),[42508,42509),[42607,42611),[42612,42622),[42623,42624),[42652,42656),[42736,42738),[42752,42786),[42864,42865),[42888,42891),[42993,42997),[43000,43002),[43010,43011),[43014,43015),[43019,43020),[43045,43047),[43052,43053),[43204,43206),[43232,43250),[43263,43264),[43302,43310),[43335,43346),[43392,43395),[43443,43444),[43446,43450),[43452,43454),[43471,43472),[43493,43495),[43561,43567),[43569,43571),[43573,43575),[43587,43588),[43596,43597),[43632,43633),[43644,43645),[43696,43697),[43698,43701),[43703,43705),[43710,43712),[43713,43714),[43741,43742),[43756,43758),[43763,43765),[43766,43767),[43867,43872),[43881,43884),[44005,44006),[44008,44009),[44013,44014),[64286,64287),[64434,64451),[65024,65040),[65043,65044),[65056,65072),[65106,65107),[65109,65110),[65279,65280),[65287,65288),[65294,65295),[65306,65307),[65342,65343),[65344,65345),[65392,65393),[65438,65440),[65507,65508),[65529,65532),[66045,66046),[66272,66273),[66422,66427),[67456,67462),[67463,67505),[67506,67515),[68097,68100),[68101,68103),[68108,68112),[68152,68155),[68159,68160),[68325,68327),[68900,68904),[68942,68943),[68969,68974),[68975,68976),[69291,69293),[69317,69318),[69370,69376),[69446,69457),[69506,69510),[69633,69634),[69688,69703),[69744,69745),[69747,69749),[69759,69762),[69811,69815),[69817,69819),[69821,69822),[69826,69827),[69837,69838),[69888,69891),[69927,69932),[69933,69941),[70003,70004),[70016,70018),[70070,70079),[70089,70093),[70095,70096),[70191,70194),[70196,70197),[70198,70200),[70206,70207),[70209,70210),[70367,70368),[70371,70379),[70400,70402),[70459,70461),[70464,70465),[70502,70509),[70512,70517),[70587,70593),[70606,70607),[70608,70609),[70610,70611),[70625,70627),[70712,70720),[70722,70725),[70726,70727),[70750,70751),[70835,70841),[70842,70843),[70847,70849),[70850,70852),[71090,71094),[71100,71102),[71103,71105),[71132,71134),[71219,71227),[71229,71230),[71231,71233),[71339,71340),[71341,71342),[71344,71350),[71351,71352),[71453,71454),[71455,71456),[71458,71462),[71463,71468),[71727,71736),[71737,71739),[71995,71997),[71998,71999),[72003,72004),[72148,72152),[72154,72156),[72160,72161),[72193,72203),[72243,72249),[72251,72255),[72263,72264),[72273,72279),[72281,72284),[72330,72343),[72344,72346),[72544,72545),[72546,72549),[72550,72551),[72752,72759),[72760,72766),[72767,72768),[72850,72872),[72874,72881),[72882,72884),[72885,72887),[73009,73015),[73018,73019),[73020,73022),[73023,73030),[73031,73032),[73104,73106),[73109,73110),[73111,73112),[73177,73178),[73459,73461),[73472,73474),[73526,73531),[73536,73537),[73538,73539),[73562,73563),[78896,78913),[78919,78934),[90398,90410),[90413,90416),[92912,92917),[92976,92983),[92992,92996),[93504,93507),[93547,93549),[94031,94032),[94095,94112),[94176,94178),[94179,94181),[94194,94196),[110576,110580),[110581,110588),[110589,110591),[113821,113823),[113824,113828),[118528,118574),[118576,118599),[119143,119146),[119155,119171),[119173,119180),[119210,119214),[119362,119365),[121344,121399),[121403,121453),[121461,121462),[121476,121477),[121499,121504),[121505,121520),[122880,122887),[122888,122905),[122907,122914),[122915,122917),[122918,122923),[122928,122990),[123023,123024),[123184,123198),[123566,123567),[123628,123632),[124139,124144),[124398,124400),[124643,124644),[124646,124647),[124654,124656),[124661,124662),[124671,124672),[125136,125143),[125252,125260),[127995,128000),[917505,917506),[917536,917632),[917760,918000)}'::pg_catalog.int4multirange; +BEGIN + -- Locale-independent ASCII fast path (the usual production address). + IF pg_catalog.octet_length(original) = pg_catalog.char_length(original) THEN + RETURN pg_catalog.translate(original, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); + END IF; + FOR position IN 1..pg_catalog.char_length(original) LOOP + ch := pg_catalog.substr(original, position, 1); + point := pg_catalog.ascii(ch); + IF point = 931 AND previous_cased THEN + next_cased := FALSE; + FOR following IN position + 1..pg_catalog.char_length(original) LOOP + point := pg_catalog.ascii(pg_catalog.substr(original, following, 1)); + IF ignorable @> point THEN CONTINUE; END IF; + next_cased := cased @> point; + EXIT; + END LOOP; + mapped := CASE WHEN next_cased THEN 'σ' ELSE 'ς' END; + ELSIF point = 304 THEN + mapped := U&'i\0307'; + ELSE + mapped := pg_catalog.translate(ch, 'ABCDEFGHIJKLMNOPQRSTUVWXYZÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŸŹŻŽƁƂƄƆƇƉƊƋƎƏƐƑƓƔƖƗƘƜƝƟƠƢƤƦƧƩƬƮƯƱƲƳƵƷƸƼDŽDžLJLjNJNjǍǏǑǓǕǗǙǛǞǠǢǤǦǨǪǬǮDZDzǴǶǷǸǺǼǾȀȂȄȆȈȊȌȎȐȒȔȖȘȚȜȞȠȢȤȦȨȪȬȮȰȲȺȻȽȾɁɃɄɅɆɈɊɌɎͰͲͶͿΆΈΉΊΌΎΏΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩΪΫϏϘϚϜϞϠϢϤϦϨϪϬϮϴϷϹϺϽϾϿЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯѠѢѤѦѨѪѬѮѰѲѴѶѸѺѼѾҀҊҌҎҐҒҔҖҘҚҜҞҠҢҤҦҨҪҬҮҰҲҴҶҸҺҼҾӀӁӃӅӇӉӋӍӐӒӔӖӘӚӜӞӠӢӤӦӨӪӬӮӰӲӴӶӸӺӼӾԀԂԄԆԈԊԌԎԐԒԔԖԘԚԜԞԠԢԤԦԨԪԬԮԱԲԳԴԵԶԷԸԹԺԻԼԽԾԿՀՁՂՃՄՅՆՇՈՉՊՋՌՍՎՏՐՑՒՓՔՕՖႠႡႢႣႤႥႦႧႨႩႪႫႬႭႮႯႰႱႲႳႴႵႶႷႸႹႺႻႼႽႾႿჀჁჂჃჄჅჇჍᎠᎡᎢᎣᎤᎥᎦᎧᎨᎩᎪᎫᎬᎭᎮᎯᎰᎱᎲᎳᎴᎵᎶᎷᎸᎹᎺᎻᎼᎽᎾᎿᏀᏁᏂᏃᏄᏅᏆᏇᏈᏉᏊᏋᏌᏍᏎᏏᏐᏑᏒᏓᏔᏕᏖᏗᏘᏙᏚᏛᏜᏝᏞᏟᏠᏡᏢᏣᏤᏥᏦᏧᏨᏩᏪᏫᏬᏭᏮᏯᏰᏱᏲᏳᏴᏵᲉᲐᲑᲒᲓᲔᲕᲖᲗᲘᲙᲚᲛᲜᲝᲞᲟᲠᲡᲢᲣᲤᲥᲦᲧᲨᲩᲪᲫᲬᲭᲮᲯᲰᲱᲲᲳᲴᲵᲶᲷᲸᲹᲺᲽᲾᲿḀḂḄḆḈḊḌḎḐḒḔḖḘḚḜḞḠḢḤḦḨḪḬḮḰḲḴḶḸḺḼḾṀṂṄṆṈṊṌṎṐṒṔṖṘṚṜṞṠṢṤṦṨṪṬṮṰṲṴṶṸṺṼṾẀẂẄẆẈẊẌẎẐẒẔẞẠẢẤẦẨẪẬẮẰẲẴẶẸẺẼẾỀỂỄỆỈỊỌỎỐỒỔỖỘỚỜỞỠỢỤỦỨỪỬỮỰỲỴỶỸỺỼỾἈἉἊἋἌἍἎἏἘἙἚἛἜἝἨἩἪἫἬἭἮἯἸἹἺἻἼἽἾἿὈὉὊὋὌὍὙὛὝὟὨὩὪὫὬὭὮὯᾈᾉᾊᾋᾌᾍᾎᾏᾘᾙᾚᾛᾜᾝᾞᾟᾨᾩᾪᾫᾬᾭᾮᾯᾸᾹᾺΆᾼῈΈῊΉῌῘῙῚΊῨῩῪΎῬῸΌῺΏῼΩKÅℲⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫⅬⅭⅮⅯↃⒶⒷⒸⒹⒺⒻⒼⒽⒾⒿⓀⓁⓂⓃⓄⓅⓆⓇⓈⓉⓊⓋⓌⓍⓎⓏⰀⰁⰂⰃⰄⰅⰆⰇⰈⰉⰊⰋⰌⰍⰎⰏⰐⰑⰒⰓⰔⰕⰖⰗⰘⰙⰚⰛⰜⰝⰞⰟⰠⰡⰢⰣⰤⰥⰦⰧⰨⰩⰪⰫⰬⰭⰮⰯⱠⱢⱣⱤⱧⱩⱫⱭⱮⱯⱰⱲⱵⱾⱿⲀⲂⲄⲆⲈⲊⲌⲎⲐⲒⲔⲖⲘⲚⲜⲞⲠⲢⲤⲦⲨⲪⲬⲮⲰⲲⲴⲶⲸⲺⲼⲾⳀⳂⳄⳆⳈⳊⳌⳎⳐⳒⳔⳖⳘⳚⳜⳞⳠⳢⳫⳭⳲꙀꙂꙄꙆꙈꙊꙌꙎꙐꙒꙔꙖꙘꙚꙜꙞꙠꙢꙤꙦꙨꙪꙬꚀꚂꚄꚆꚈꚊꚌꚎꚐꚒꚔꚖꚘꚚꜢꜤꜦꜨꜪꜬꜮꜲꜴꜶꜸꜺꜼꜾꝀꝂꝄꝆꝈꝊꝌꝎꝐꝒꝔꝖꝘꝚꝜꝞꝠꝢꝤꝦꝨꝪꝬꝮꝹꝻꝽꝾꞀꞂꞄꞆꞋꞍꞐꞒꞖꞘꞚꞜꞞꞠꞢꞤꞦꞨꞪꞫꞬꞭꞮꞰꞱꞲꞳꞴꞶꞸꞺꞼꞾꟀꟂꟄꟅꟆꟇꟉꟋꟌ꟎Ꟑ꟒꟔ꟖꟘꟚꟜꟵABCDEFGHIJKLMNOPQRSTUVWXYZ𐐀𐐁𐐂𐐃𐐄𐐅𐐆𐐇𐐈𐐉𐐊𐐋𐐌𐐍𐐎𐐏𐐐𐐑𐐒𐐓𐐔𐐕𐐖𐐗𐐘𐐙𐐚𐐛𐐜𐐝𐐞𐐟𐐠𐐡𐐢𐐣𐐤𐐥𐐦𐐧𐒰𐒱𐒲𐒳𐒴𐒵𐒶𐒷𐒸𐒹𐒺𐒻𐒼𐒽𐒾𐒿𐓀𐓁𐓂𐓃𐓄𐓅𐓆𐓇𐓈𐓉𐓊𐓋𐓌𐓍𐓎𐓏𐓐𐓑𐓒𐓓𐕰𐕱𐕲𐕳𐕴𐕵𐕶𐕷𐕸𐕹𐕺𐕼𐕽𐕾𐕿𐖀𐖁𐖂𐖃𐖄𐖅𐖆𐖇𐖈𐖉𐖊𐖌𐖍𐖎𐖏𐖐𐖑𐖒𐖔𐖕𐲀𐲁𐲂𐲃𐲄𐲅𐲆𐲇𐲈𐲉𐲊𐲋𐲌𐲍𐲎𐲏𐲐𐲑𐲒𐲓𐲔𐲕𐲖𐲗𐲘𐲙𐲚𐲛𐲜𐲝𐲞𐲟𐲠𐲡𐲢𐲣𐲤𐲥𐲦𐲧𐲨𐲩𐲪𐲫𐲬𐲭𐲮𐲯𐲰𐲱𐲲𐵐𐵑𐵒𐵓𐵔𐵕𐵖𐵗𐵘𐵙𐵚𐵛𐵜𐵝𐵞𐵟𐵠𐵡𐵢𐵣𐵤𐵥𑢠𑢡𑢢𑢣𑢤𑢥𑢦𑢧𑢨𑢩𑢪𑢫𑢬𑢭𑢮𑢯𑢰𑢱𑢲𑢳𑢴𑢵𑢶𑢷𑢸𑢹𑢺𑢻𑢼𑢽𑢾𑢿𖹀𖹁𖹂𖹃𖹄𖹅𖹆𖹇𖹈𖹉𖹊𖹋𖹌𖹍𖹎𖹏𖹐𖹑𖹒𖹓𖹔𖹕𖹖𖹗𖹘𖹙𖹚𖹛𖹜𖹝𖹞𖹟𖺠𖺡𖺢𖺣𖺤𖺥𖺦𖺧𖺨𖺩𖺪𖺫𖺬𖺭𖺮𖺯𖺰𖺱𖺲𖺳𖺴𖺵𖺶𖺷𖺸𞤀𞤁𞤂𞤃𞤄𞤅𞤆𞤇𞤈𞤉𞤊𞤋𞤌𞤍𞤎𞤏𞤐𞤑𞤒𞤓𞤔𞤕𞤖𞤗𞤘𞤙𞤚𞤛𞤜𞤝𞤞𞤟𞤠𞤡', 'abcdefghijklmnopqrstuvwxyzàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþāăąćĉċčďđēĕėęěĝğġģĥħĩīĭįijĵķĺļľŀłńņňŋōŏőœŕŗřśŝşšţťŧũūŭůűųŵŷÿźżžɓƃƅɔƈɖɗƌǝəɛƒɠɣɩɨƙɯɲɵơƣƥʀƨʃƭʈưʊʋƴƶʒƹƽdždžljljnjnjǎǐǒǔǖǘǚǜǟǡǣǥǧǩǫǭǯdzdzǵƕƿǹǻǽǿȁȃȅȇȉȋȍȏȑȓȕȗșțȝȟƞȣȥȧȩȫȭȯȱȳⱥȼƚⱦɂƀʉʌɇɉɋɍɏͱͳͷϳάέήίόύώαβγδεζηθικλμνξοπρστυφχψωϊϋϗϙϛϝϟϡϣϥϧϩϫϭϯθϸϲϻͻͼͽѐёђѓєѕіїјљњћќѝўџабвгдежзийклмнопрстуфхцчшщъыьэюяѡѣѥѧѩѫѭѯѱѳѵѷѹѻѽѿҁҋҍҏґғҕҗҙқҝҟҡңҥҧҩҫҭүұҳҵҷҹһҽҿӏӂӄӆӈӊӌӎӑӓӕӗәӛӝӟӡӣӥӧөӫӭӯӱӳӵӷӹӻӽӿԁԃԅԇԉԋԍԏԑԓԕԗԙԛԝԟԡԣԥԧԩԫԭԯաբգդեզէըթժիլխծկհձղճմյնշոչպջռսվտրցւփքօֆⴀⴁⴂⴃⴄⴅⴆⴇⴈⴉⴊⴋⴌⴍⴎⴏⴐⴑⴒⴓⴔⴕⴖⴗⴘⴙⴚⴛⴜⴝⴞⴟⴠⴡⴢⴣⴤⴥⴧⴭꭰꭱꭲꭳꭴꭵꭶꭷꭸꭹꭺꭻꭼꭽꭾꭿꮀꮁꮂꮃꮄꮅꮆꮇꮈꮉꮊꮋꮌꮍꮎꮏꮐꮑꮒꮓꮔꮕꮖꮗꮘꮙꮚꮛꮜꮝꮞꮟꮠꮡꮢꮣꮤꮥꮦꮧꮨꮩꮪꮫꮬꮭꮮꮯꮰꮱꮲꮳꮴꮵꮶꮷꮸꮹꮺꮻꮼꮽꮾꮿᏸᏹᏺᏻᏼᏽᲊაბგდევზთიკლმნოპჟრსტუფქღყშჩცძწჭხჯჰჱჲჳჴჵჶჷჸჹჺჽჾჿḁḃḅḇḉḋḍḏḑḓḕḗḙḛḝḟḡḣḥḧḩḫḭḯḱḳḵḷḹḻḽḿṁṃṅṇṉṋṍṏṑṓṕṗṙṛṝṟṡṣṥṧṩṫṭṯṱṳṵṷṹṻṽṿẁẃẅẇẉẋẍẏẑẓẕßạảấầẩẫậắằẳẵặẹẻẽếềểễệỉịọỏốồổỗộớờởỡợụủứừửữựỳỵỷỹỻỽỿἀἁἂἃἄἅἆἇἐἑἒἓἔἕἠἡἢἣἤἥἦἧἰἱἲἳἴἵἶἷὀὁὂὃὄὅὑὓὕὗὠὡὢὣὤὥὦὧᾀᾁᾂᾃᾄᾅᾆᾇᾐᾑᾒᾓᾔᾕᾖᾗᾠᾡᾢᾣᾤᾥᾦᾧᾰᾱὰάᾳὲέὴήῃῐῑὶίῠῡὺύῥὸόὼώῳωkåⅎⅰⅱⅲⅳⅴⅵⅶⅷⅸⅹⅺⅻⅼⅽⅾⅿↄⓐⓑⓒⓓⓔⓕⓖⓗⓘⓙⓚⓛⓜⓝⓞⓟⓠⓡⓢⓣⓤⓥⓦⓧⓨⓩⰰⰱⰲⰳⰴⰵⰶⰷⰸⰹⰺⰻⰼⰽⰾⰿⱀⱁⱂⱃⱄⱅⱆⱇⱈⱉⱊⱋⱌⱍⱎⱏⱐⱑⱒⱓⱔⱕⱖⱗⱘⱙⱚⱛⱜⱝⱞⱟⱡɫᵽɽⱨⱪⱬɑɱɐɒⱳⱶȿɀⲁⲃⲅⲇⲉⲋⲍⲏⲑⲓⲕⲗⲙⲛⲝⲟⲡⲣⲥⲧⲩⲫⲭⲯⲱⲳⲵⲷⲹⲻⲽⲿⳁⳃⳅⳇⳉⳋⳍⳏⳑⳓⳕⳗⳙⳛⳝⳟⳡⳣⳬⳮⳳꙁꙃꙅꙇꙉꙋꙍꙏꙑꙓꙕꙗꙙꙛꙝꙟꙡꙣꙥꙧꙩꙫꙭꚁꚃꚅꚇꚉꚋꚍꚏꚑꚓꚕꚗꚙꚛꜣꜥꜧꜩꜫꜭꜯꜳꜵꜷꜹꜻꜽꜿꝁꝃꝅꝇꝉꝋꝍꝏꝑꝓꝕꝗꝙꝛꝝꝟꝡꝣꝥꝧꝩꝫꝭꝯꝺꝼᵹꝿꞁꞃꞅꞇꞌɥꞑꞓꞗꞙꞛꞝꞟꞡꞣꞥꞧꞩɦɜɡɬɪʞʇʝꭓꞵꞷꞹꞻꞽꞿꟁꟃꞔʂᶎꟈꟊɤꟍ꟏ꟑꟓꟕꟗꟙꟛƛꟶabcdefghijklmnopqrstuvwxyz𐐨𐐩𐐪𐐫𐐬𐐭𐐮𐐯𐐰𐐱𐐲𐐳𐐴𐐵𐐶𐐷𐐸𐐹𐐺𐐻𐐼𐐽𐐾𐐿𐑀𐑁𐑂𐑃𐑄𐑅𐑆𐑇𐑈𐑉𐑊𐑋𐑌𐑍𐑎𐑏𐓘𐓙𐓚𐓛𐓜𐓝𐓞𐓟𐓠𐓡𐓢𐓣𐓤𐓥𐓦𐓧𐓨𐓩𐓪𐓫𐓬𐓭𐓮𐓯𐓰𐓱𐓲𐓳𐓴𐓵𐓶𐓷𐓸𐓹𐓺𐓻𐖗𐖘𐖙𐖚𐖛𐖜𐖝𐖞𐖟𐖠𐖡𐖣𐖤𐖥𐖦𐖧𐖨𐖩𐖪𐖫𐖬𐖭𐖮𐖯𐖰𐖱𐖳𐖴𐖵𐖶𐖷𐖸𐖹𐖻𐖼𐳀𐳁𐳂𐳃𐳄𐳅𐳆𐳇𐳈𐳉𐳊𐳋𐳌𐳍𐳎𐳏𐳐𐳑𐳒𐳓𐳔𐳕𐳖𐳗𐳘𐳙𐳚𐳛𐳜𐳝𐳞𐳟𐳠𐳡𐳢𐳣𐳤𐳥𐳦𐳧𐳨𐳩𐳪𐳫𐳬𐳭𐳮𐳯𐳰𐳱𐳲𐵰𐵱𐵲𐵳𐵴𐵵𐵶𐵷𐵸𐵹𐵺𐵻𐵼𐵽𐵾𐵿𐶀𐶁𐶂𐶃𐶄𐶅𑣀𑣁𑣂𑣃𑣄𑣅𑣆𑣇𑣈𑣉𑣊𑣋𑣌𑣍𑣎𑣏𑣐𑣑𑣒𑣓𑣔𑣕𑣖𑣗𑣘𑣙𑣚𑣛𑣜𑣝𑣞𑣟𖹠𖹡𖹢𖹣𖹤𖹥𖹦𖹧𖹨𖹩𖹪𖹫𖹬𖹭𖹮𖹯𖹰𖹱𖹲𖹳𖹴𖹵𖹶𖹷𖹸𖹹𖹺𖹻𖹼𖹽𖹾𖹿𖺻𖺼𖺽𖺾𖺿𖻀𖻁𖻂𖻃𖻄𖻅𖻆𖻇𖻈𖻉𖻊𖻋𖻌𖻍𖻎𖻏𖻐𖻑𖻒𖻓𞤢𞤣𞤤𞤥𞤦𞤧𞤨𞤩𞤪𞤫𞤬𞤭𞤮𞤯𞤰𞤱𞤲𞤳𞤴𞤵𞤶𞤷𞤸𞤹𞤺𞤻𞤼𞤽𞤾𞤿𞥀𞥁𞥂𞥃'); + END IF; + result := result || mapped; + point := pg_catalog.ascii(ch); + IF NOT (ignorable @> point) THEN previous_cased := cased @> point; END IF; + END LOOP; + RETURN result; +END; +$normalize$; +-- END GENERATED NORMALIZATION + +CREATE TABLE IF NOT EXISTS public.normalized_email_serialization ( + singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), + version BIGINT NOT NULL DEFAULT 0 CHECK (version >= 0) +); +INSERT INTO public.normalized_email_serialization(singleton) +VALUES (TRUE) ON CONFLICT DO NOTHING; +REVOKE ALL ON public.normalized_email_serialization FROM PUBLIC; + +CREATE OR REPLACE FUNCTION public.check_normalized_email_invariant() +RETURNS VOID LANGUAGE plpgsql VOLATILE SECURITY DEFINER +SET search_path = pg_catalog +SET row_security = off +AS $$ +DECLARE + conflict_count BIGINT; + evidence JSONB; +BEGIN + WITH representations AS ( + SELECT public.normalized_credential_email(email) COLLATE "C" AS normalized_email, + workos_user_id COLLATE "C" AS owner, 'users' AS source, + workos_user_id::TEXT AS row_id, email::TEXT AS original_email + FROM public.users + UNION ALL + SELECT public.normalized_credential_email(email) COLLATE "C", + workos_user_id COLLATE "C", 'user_email_aliases', id::TEXT, email::TEXT + FROM public.user_email_aliases + ), conflicts AS ( + SELECT normalized_email, + jsonb_agg(jsonb_build_object('source', source, 'id', row_id, + 'owner', owner, 'email', original_email) ORDER BY source, row_id) AS rows + FROM representations GROUP BY normalized_email + HAVING count(DISTINCT owner) > 1 + OR count(*) FILTER (WHERE source = 'user_email_aliases') > 1 + ) + SELECT (SELECT count(*) FROM conflicts), + (SELECT jsonb_agg(to_jsonb(sample)) FROM + (SELECT * FROM conflicts ORDER BY normalized_email LIMIT 20) sample) + INTO conflict_count, evidence; + IF conflict_count > 0 THEN + RAISE EXCEPTION 'Normalized email invariant conflict (% keys)', conflict_count + USING ERRCODE = '23505', CONSTRAINT = 'normalized_email_owner', + DETAIL = evidence::TEXT, + HINT = 'Retain all rows. Run audit-normalized-emails.ts with read-only credentials; resolve provenance with support before retrying. No automatic winner is safe.'; + END IF; +END; +$$; +REVOKE ALL ON FUNCTION public.check_normalized_email_invariant() FROM PUBLIC; + +-- Fail installation before introducing enforcement over ambiguous history. +SELECT public.check_normalized_email_invariant(); + +CREATE OR REPLACE FUNCTION public.lock_normalized_email_writer() +RETURNS TRIGGER LANGUAGE plpgsql VOLATILE SECURITY DEFINER +SET search_path = pg_catalog +SET row_security = off +AS $$ +DECLARE + affected BIGINT; + generation BIGINT; +BEGIN + IF NOT pg_catalog.pg_try_advisory_xact_lock(6827, 595) THEN + RAISE EXCEPTION 'Normalized email invariant is busy' USING ERRCODE = '55P03'; + END IF; + -- A lock alone cannot refresh a REPEATABLE READ snapshot. Every writer + -- writes this row: a stale snapshot fails with 40001 before checking data. + UPDATE public.normalized_email_serialization SET version = version + 1 + WHERE singleton RETURNING version INTO generation; + GET DIAGNOSTICS affected = ROW_COUNT; + IF affected <> 1 OR generation IS NULL THEN + RAISE EXCEPTION 'Normalized email serialization evidence missing' USING ERRCODE = '23514'; + END IF; + RETURN NULL; +END; +$$; +REVOKE ALL ON FUNCTION public.lock_normalized_email_writer() FROM PUBLIC; + +CREATE OR REPLACE FUNCTION public.verify_normalized_email_writer() +RETURNS TRIGGER LANGUAGE plpgsql VOLATILE SECURITY DEFINER +SET search_path = pg_catalog +SET row_security = off +AS $$ +BEGIN + PERFORM public.check_normalized_email_invariant(); + RETURN NULL; +END; +$$; +REVOKE ALL ON FUNCTION public.verify_normalized_email_writer() FROM PUBLIC; + +-- All UPDATEs participate: another row trigger can change email even when +-- the original SET list does not mention it. No recursion-depth bypass. +DROP TRIGGER IF EXISTS lock_normalized_email_writer ON public.users; +CREATE TRIGGER lock_normalized_email_writer + BEFORE INSERT OR UPDATE OR DELETE OR TRUNCATE ON public.users + FOR EACH STATEMENT EXECUTE FUNCTION public.lock_normalized_email_writer(); +DROP TRIGGER IF EXISTS verify_normalized_email_writer ON public.users; +CREATE TRIGGER verify_normalized_email_writer + AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON public.users + FOR EACH STATEMENT EXECUTE FUNCTION public.verify_normalized_email_writer(); +DROP TRIGGER IF EXISTS lock_normalized_email_writer ON public.user_email_aliases; +CREATE TRIGGER lock_normalized_email_writer + BEFORE INSERT OR UPDATE OR DELETE OR TRUNCATE ON public.user_email_aliases + FOR EACH STATEMENT EXECUTE FUNCTION public.lock_normalized_email_writer(); +DROP TRIGGER IF EXISTS verify_normalized_email_writer ON public.user_email_aliases; +CREATE TRIGGER verify_normalized_email_writer + AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON public.user_email_aliases + FOR EACH STATEMENT EXECUTE FUNCTION public.verify_normalized_email_writer(); +ALTER TABLE public.users ENABLE ALWAYS TRIGGER lock_normalized_email_writer; +ALTER TABLE public.users ENABLE ALWAYS TRIGGER verify_normalized_email_writer; +ALTER TABLE public.user_email_aliases ENABLE ALWAYS TRIGGER lock_normalized_email_writer; +ALTER TABLE public.user_email_aliases ENABLE ALWAYS TRIGGER verify_normalized_email_writer; diff --git a/server/src/scripts/audit-normalized-emails.ts b/server/src/scripts/audit-normalized-emails.ts new file mode 100644 index 00000000000..f197ec81fab --- /dev/null +++ b/server/src/scripts/audit-normalized-emails.ts @@ -0,0 +1,40 @@ +/** Read-only legacy inventory for migration 595. Never resolve ownership by email. */ +import { Client } from 'pg'; + +if (process.versions.unicode !== '17.0') throw new Error('Inventory requires Unicode 17.0 to match migration 595'); +if (!process.env.DATABASE_URL) throw new Error('Set DATABASE_URL explicitly to a read-only database connection'); +const client = new Client({ connectionString: process.env.DATABASE_URL }); +try { + await client.connect(); + await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'); + // Refuse an incomplete RLS-filtered inventory instead of reporting it clean. + await client.query('SET LOCAL row_security = off'); + const result = await client.query<{ + source: string; id: string; owner: string; email: string; identity_id: string | null; + }>(` + SELECT 'users' AS source, u.workos_user_id::text AS id, + u.workos_user_id::text AS owner, u.email::text, b.identity_id + FROM public.users u LEFT JOIN public.identity_workos_users b USING (workos_user_id) + UNION ALL + SELECT 'user_email_aliases', a.id::text, a.workos_user_id::text, a.email::text, b.identity_id + FROM public.user_email_aliases a LEFT JOIN public.identity_workos_users b USING (workos_user_id) + `); + await client.query('COMMIT'); + const grouped = new Map(); + for (const row of result.rows) { + const key = row.email.trim().toLowerCase(); + const rows = grouped.get(key) ?? []; + rows.push(row); grouped.set(key, rows); + } + const overlaps = [...grouped].filter(([, rows]) => rows.length > 1).map(([email, rows]) => ({ + normalized_email: email, + blocks_installation: new Set(rows.map(row => row.owner)).size > 1 + || rows.filter(row => row.source === 'user_email_aliases').length > 1, + rows, + })); + console.log(JSON.stringify({ unicode_version: process.versions.unicode, row_count: result.rowCount, + conflict_count: overlaps.filter(overlap => overlap.blocks_installation).length, overlaps }, null, 2)); + if (overlaps.some(overlap => overlap.blocks_installation)) process.exitCode = 1; +} finally { + await client.end(); +} diff --git a/server/tests/integration/normalized-email-composition.test.ts b/server/tests/integration/normalized-email-composition.test.ts new file mode 100644 index 00000000000..166763f383f --- /dev/null +++ b/server/tests/integration/normalized-email-composition.test.ts @@ -0,0 +1,139 @@ +// Run in a disposable checkout of published #7463 with exact 8847fc route and +// migration 595 overlaid. See specs/normalized-email-invariant.md. These source +// hashes prevent a simplified verifier from masquerading as composition proof. +import { createHash, randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import express from 'express'; +import request from 'supertest'; +import { Client, type Pool } from 'pg'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { initializeDatabase, closeDatabase } from '../../src/db/client.js'; +import { runMigrations } from '../../src/db/migrate.js'; + +const { provider } = vi.hoisted(() => ({ provider: { getUser: vi.fn(), updateUser: vi.fn(), createUser: vi.fn(), deleteUser: vi.fn() } })); +vi.mock('../../src/auth/workos-client.js', () => ({ getWorkos: () => ({ userManagement: provider }), getEmailMutationWorkos: () => ({ userManagement: provider }) })); +vi.mock('../../src/notifications/email.js', () => ({ sendEmailLinkVerification: vi.fn() })); +vi.mock('../../src/db/user-merge-db.js', () => ({ mergeUsers: vi.fn() })); +vi.mock('../../src/middleware/auth.js', () => ({ requireAuth: (req: { user?: unknown }, _res: unknown, next: () => void) => { + req.user = { id: 'user_normalized_comp_a', authWorkosUserId: 'user_normalized_comp_a', + authorizationSnapshot: { authenticatedUserId: 'user_normalized_comp_a' } }; + next(); +}, invalidateSessionsForUsers: vi.fn() })); +vi.mock('express-rate-limit', () => ({ default: () => (_req: unknown, _res: unknown, next: () => void) => next() })); +import { createAccountLinkingRouter, handleEmailLinkVerification } from '../../src/routes/account-linking.js'; + +const A = 'user_normalized_comp_a', B = 'user_normalized_comp_b', token = 'normalized-comp-token'; +const email = 'normalized-comp-target@test.example'; +function barrier() { + let resolve!: () => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} +function blob(source: Buffer) { return createHash('sha1').update(`blob ${source.length}\0`).update(source).digest('hex'); } + +describe.runIf(process.env.ADCP_EMAIL_COMPOSITION === 'true')('exact 592 + 8847fc + 595 composition', () => { + let pool: Pool; + const app = express(); + app.use(express.json()); + app.use('/linked-emails', createAccountLinkingRouter()); + handleEmailLinkVerification(app); + const submit = () => request(app).post('/verify-email-link').type('form').send({ token }); + beforeAll(async () => { + expect(blob(await fs.readFile(new URL('../../src/routes/account-linking.ts', import.meta.url)))).toBe('ab283d29b754dd083d31c645a49ef56fbb94e2da'); + expect(blob(await fs.readFile(new URL('../../src/db/migrations/592_email_mutations.sql', import.meta.url)))).toBe('e726bbaaa214105df6a922773f7c208bb4919966'); + expect(blob(await fs.readFile(new URL('./alias-verification-atomicity.test.ts', import.meta.url)))).toBe('0cef113161f9bed39a9c52e1f67d0f555771098b'); + const url = new URL(process.env.DATABASE_URL!); + if (url.hostname !== '127.0.0.1' || !url.pathname.includes('test')) throw new Error('Use a disposable local test database'); + pool = initializeDatabase({ connectionString: url.toString() }); + await runMigrations(); + }, 60000); + beforeEach(async () => { + vi.restoreAllMocks(); vi.clearAllMocks(); + for (const mock of Object.values(provider)) mock.mockReset(); + await pool.query('DELETE FROM email_mutations WHERE workos_user_id=ANY($1)', [[A, B]]); + await pool.query('DELETE FROM users WHERE workos_user_id=ANY($1)', [[A, B]]); + await pool.query('INSERT INTO users(workos_user_id,email) VALUES($1,$2),($3,$4)', [A, 'normalized-comp-a@test.example', B, 'normalized-comp-b@test.example']); + await pool.query("INSERT INTO email_link_tokens(token,primary_workos_user_id,target_email,expires_at) VALUES($1,$2,$3,NOW()+INTERVAL '1 day')", [token, A, email]); + }); + afterAll(async () => { + vi.restoreAllMocks(); + if (pool) { + await pool.query('DELETE FROM email_mutations WHERE workos_user_id=ANY($1)', [[A, B]]); + await pool.query('DELETE FROM users WHERE workos_user_id=ANY($1)', [[A, B]]); + } + await closeDatabase(); + }); + async function check(failed: boolean) { + expect((await pool.query('SELECT status FROM email_link_tokens WHERE token=$1', [token])).rows[0].status).toBe(failed ? 'pending' : 'verified'); + await pool.query('SELECT public.check_normalized_email_invariant()'); + for (const mock of Object.values(provider)) expect(mock).not.toHaveBeenCalled(); + } + it.each(['INSERT', 'UPDATE'])('rejects verification when a competing users %s commits after its users predicate', async action => { + if (action === 'INSERT') await pool.query('DELETE FROM users WHERE workos_user_id=$1', [B]); + const checked = barrier(), release = barrier(); + const query = Client.prototype.query; + vi.spyOn(Client.prototype, 'query').mockImplementation(function (this: Client, ...args: any[]): any { + const result = (query as any).apply(this, args); + if (typeof args[0] === 'string' && args[0].includes('SELECT workos_user_id FROM users WHERE LOWER(email)')) { + return result.then(async (value: unknown) => { checked.resolve(); await release.promise; return value; }); + } + return result; + }); + const verifying = submit().then(result => result); + try { + await checked.promise; + const written = action === 'INSERT' + ? await pool.query('INSERT INTO users(workos_user_id,email) VALUES($1,$2)', [B, email]) + : await pool.query('UPDATE users SET email=$2 WHERE workos_user_id=$1', [B, email]); + expect(written.rowCount).toBe(1); + } finally { release.resolve(); } + expect((await verifying).text).toContain('Verification Failed'); + await check(true); + }); + it('allows verified success only when the competing credential writer fails', async () => { + const checked = barrier(), release = barrier(); + const query = Client.prototype.query; + vi.spyOn(Client.prototype, 'query').mockImplementation(function (this: Client, ...args: any[]): any { + const result = (query as any).apply(this, args); + if (typeof args[0] === 'string' && args[0].includes('INSERT INTO user_email_aliases')) { + return result.then(async (value: unknown) => { checked.resolve(); await release.promise; return value; }); + } + return result; + }); + const verifying = submit().then(result => result); + try { + await checked.promise; + await expect(pool.query('UPDATE users SET email=$2 WHERE workos_user_id=$1', [B, email])).rejects.toMatchObject({ code: '55P03' }); + } finally { release.resolve(); } + expect((await verifying).text).toContain('Email Linked'); + await expect(pool.query('UPDATE users SET email=$2 WHERE workos_user_id=$1', [B, email])).rejects.toMatchObject({ code: '23505' }); + await check(false); + }); + it('makes no provider call after mounted primary-email local preflight denial', async () => { + await request(app).put('/linked-emails/primary').send({ email, operation_id: randomUUID() }).expect(404); + for (const mock of Object.values(provider)) expect(mock).not.toHaveBeenCalled(); + expect((await pool.query('SELECT 1 FROM email_mutations WHERE workos_user_id=$1', [A])).rowCount).toBe(0); + }); + it('retains durable reconciliation when the common lock denies local apply and compensation', async () => { + await pool.query('INSERT INTO user_email_aliases(workos_user_id,email) VALUES($1,$2)', [A, email]); + const original = { id: A, email: 'normalized-comp-a@test.example', emailVerified: false }; + provider.getUser.mockResolvedValue(original); + provider.updateUser.mockImplementation(async ({ email: next, emailVerified }) => ({ id: A, email: next, emailVerified })); + const blocker = await pool.connect(); + const operationId = randomUUID(); + try { + await blocker.query('BEGIN'); + await blocker.query('INSERT INTO user_email_aliases(workos_user_id,email) VALUES($1,$2)', [B, 'normalized-comp-lock@test.example']); + const response = await request(app).put('/linked-emails/primary').send({ email, operation_id: operationId }).expect(409); + expect(response.body).toMatchObject({ reconciliation_required: true, operation_id: operationId }); + expect((await pool.query('SELECT state FROM email_mutations WHERE id=$1', [operationId])).rows[0].state).toBe('reconciliation_required'); + expect((await pool.query('SELECT email FROM users WHERE workos_user_id=$1', [A])).rows[0].email).toBe(original.email); + expect(provider.updateUser).toHaveBeenCalledTimes(2); + expect(provider.updateUser).toHaveBeenLastCalledWith({ userId: A, email: original.email, emailVerified: false }); + } finally { await blocker.query('ROLLBACK'); blocker.release(); } + vi.clearAllMocks(); + await request(app).put('/linked-emails/primary').send({ email, operation_id: operationId }).expect(409); + for (const mock of Object.values(provider)) expect(mock).not.toHaveBeenCalled(); + await pool.query('SELECT public.check_normalized_email_invariant()'); + }); +}); diff --git a/server/tests/integration/normalized-email-invariant.test.ts b/server/tests/integration/normalized-email-invariant.test.ts new file mode 100644 index 00000000000..67060d6d68f --- /dev/null +++ b/server/tests/integration/normalized-email-invariant.test.ts @@ -0,0 +1,284 @@ +import fs from 'node:fs/promises'; +import { Pool, type PoolClient } from 'pg'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { initializeDatabase, closeDatabase } from '../../src/db/client.js'; +import { runMigrations } from '../../src/db/migrate.js'; + +const migration = await fs.readFile(new URL('../../src/db/migrations/595_normalized_email_invariant.sql', import.meta.url), 'utf8'); +const connectionString = process.env.DATABASE_URL || 'postgresql://adcp:localdev@127.0.0.1:5432/adcp_email_invariant_test'; +const A = 'user_normalized_email_a', B = 'user_normalized_email_b'; +const target = 'normalized-target@test.example'; + +describe('database-wide normalized credential email invariant', () => { + let pool: Pool; + beforeAll(async () => { + // This suite only supports a deliberately selected disposable test DB. + const url = new URL(connectionString); + if (!['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) || !url.pathname.includes('test')) { + throw new Error('Use a local disposable database whose name contains test'); + } + pool = initializeDatabase({ connectionString }); + await runMigrations(); + }, 60000); + beforeEach(async () => { + await pool.query('DELETE FROM users WHERE workos_user_id = ANY($1)', [[A, B]]); + await pool.query('INSERT INTO users(workos_user_id,email) VALUES ($1,$2),($3,$4)', [A, 'normalized-a@test.example', B, 'normalized-b@test.example']); + }); + afterAll(async () => { + await pool.query('DELETE FROM users WHERE workos_user_id = ANY($1)', [[A, B]]); + await closeDatabase(); + }); + + async function transaction(work: (client: PoolClient) => Promise) { + const client = await pool.connect(); + try { await client.query('BEGIN'); await work(client); await client.query('COMMIT'); } + catch (error) { await client.query('ROLLBACK'); throw error; } + finally { client.release(); } + } + async function assertInvariant() { + await pool.query('SELECT public.check_normalized_email_invariant()'); + } + type Write = 'user insert' | 'user update' | 'alias insert' | 'alias update'; + async function prepare(kind: Write, owner: string) { + if (kind === 'user insert') await pool.query('DELETE FROM users WHERE workos_user_id=$1', [owner]); + if (kind === 'alias update') await pool.query('INSERT INTO user_email_aliases(workos_user_id,email) VALUES ($1,$2)', [owner, `${owner}@test.example`]); + } + async function write(client: Pick, kind: Write, owner: string, email = target) { + if (kind === 'user insert') return client.query('INSERT INTO users(workos_user_id,email) VALUES ($1,$2)', [owner, email]); + if (kind === 'user update') return client.query('UPDATE users SET email=$2 WHERE workos_user_id=$1', [owner, email]); + if (kind === 'alias insert') return client.query('INSERT INTO user_email_aliases(workos_user_id,email) VALUES ($1,$2)', [owner, email]); + return client.query('UPDATE user_email_aliases SET email=$2 WHERE workos_user_id=$1', [owner, email]); + } + const kinds: Write[] = ['user insert', 'user update', 'alias insert', 'alias update']; + for (const first of kinds) for (const second of kinds) { + it(`${first} versus ${second}: deterministic concurrent rejection and bounded retry`, async () => { + await prepare(first, A); await prepare(second, B); + const one = await pool.connect(), two = await pool.connect(); + try { + await one.query('BEGIN'); await two.query('BEGIN'); + await write(one, first, A); // Barrier: first writer has completed its check and holds its lock. + await expect(write(two, second, B, ` \t${target.toUpperCase()}\n`)).rejects.toMatchObject({ code: '55P03' }); + await two.query('ROLLBACK'); await one.query('COMMIT'); + // Retry only the whole rolled-back transaction, once, after owner commits. + await expect(write(two, second, B, target)).rejects.toMatchObject({ code: '23505' }); + await assertInvariant(); + } finally { await one.query('ROLLBACK'); await two.query('ROLLBACK'); one.release(); two.release(); } + }); + } + + it('permits precisely a credential/own-alias overlap needed by primary email swaps', async () => { + await write(pool, 'alias insert', A); + await transaction(async client => { + await write(client, 'user update', A); + expect((await client.query('DELETE FROM user_email_aliases WHERE workos_user_id=$1', [A])).rowCount).toBe(1); + await write(client, 'alias insert', A, 'normalized-a@test.example'); + }); + await assertInvariant(); + }); + it('treats canonical siblings as different owners without changing bindings', async () => { + await pool.query('UPDATE identity_workos_users SET is_primary=FALSE, identity_id=(SELECT identity_id FROM identity_workos_users WHERE workos_user_id=$1) WHERE workos_user_id=$2', [A, B]); + await write(pool, 'alias insert', A); + await expect(write(pool, 'user update', B)).rejects.toMatchObject({ code: '23505' }); + }); + it('rejects duplicate normalized aliases even for the same owner', async () => { + await write(pool, 'alias insert', A); + await expect(write(pool, 'alias insert', A, ` ${target} `)).rejects.toMatchObject({ code: '23505' }); + }); + + it.each(['READ COMMITTED', 'REPEATABLE READ', 'SERIALIZABLE'])('rejects stale check snapshots at %s', async isolation => { + const client = await pool.connect(); + try { + await client.query(`BEGIN ISOLATION LEVEL ${isolation}`); + await client.query('SELECT count(*) FROM users'); + await write(pool, 'alias insert', A); + await expect(write(client, 'user update', B)).rejects.toMatchObject({ code: isolation === 'READ COMMITTED' ? '23505' : '40001' }); + await client.query('ROLLBACK'); await assertInvariant(); + } finally { await client.query('ROLLBACK'); client.release(); } + }); + it.each(['COMMIT', 'ROLLBACK'])('does not release a deleted alias until transaction %s', async outcome => { + await write(pool, 'alias insert', A); + const client = await pool.connect(); + try { + await client.query('BEGIN'); await client.query('DELETE FROM user_email_aliases WHERE workos_user_id=$1', [A]); + await expect(write(pool, 'user update', B)).rejects.toMatchObject({ code: '55P03' }); + await client.query(outcome); + if (outcome === 'COMMIT') await write(pool, 'user update', B); + else await expect(write(pool, 'user update', B)).rejects.toMatchObject({ code: '23505' }); + await assertInvariant(); + } finally { await client.query('ROLLBACK'); client.release(); } + }); + it.each(['COMMIT', 'ROLLBACK'])('holds user email and cascaded aliases through deletion %s', async outcome => { + await write(pool, 'alias insert', A); + const client = await pool.connect(); + try { + await client.query('BEGIN'); await client.query('DELETE FROM users WHERE workos_user_id=$1', [A]); + await expect(write(pool, 'user update', B)).rejects.toMatchObject({ code: '55P03' }); + await client.query(outcome); + if (outcome === 'COMMIT') { + await write(pool, 'user update', B); + await write(pool, 'alias insert', B, 'normalized-a@test.example'); + } else await expect(write(pool, 'user update', B)).rejects.toMatchObject({ code: '23505' }); + await assertInvariant(); + } finally { await client.query('ROLLBACK'); client.release(); } + }); + it('rolls back acquisition and permits reuse after a failed transaction', async () => { + await expect(transaction(async client => { await write(client, 'alias insert', A); throw new Error('abort'); })).rejects.toThrow('abort'); + await write(pool, 'user update', B); await assertInvariant(); + }); + it('rejects the opposite-direction transaction before it can wait on the first writer', async () => { + const one = await pool.connect(), two = await pool.connect(); + try { + await one.query('BEGIN'); await two.query('BEGIN'); + await one.query('SELECT 1 FROM users WHERE workos_user_id=$1 FOR UPDATE', [A]); + await write(two, 'alias insert', B); + await expect(write(one, 'alias insert', A, 'normalized-opposite@test.example')).rejects.toMatchObject({ code: '55P03' }); + await one.query('ROLLBACK'); + await write(two, 'user update', A, 'normalized-opposite@test.example'); + await two.query('COMMIT'); await assertInvariant(); + } finally { await one.query('ROLLBACK'); await two.query('ROLLBACK'); one.release(); two.release(); } + }); + it('handles multi-row cross-table CTE writes with one owner check', async () => { + await expect(pool.query(`WITH inserted AS (INSERT INTO user_email_aliases(workos_user_id,email) VALUES ($1,$3)) + UPDATE users SET email=$3 WHERE workos_user_id=$2`, [A, B, target])).rejects.toMatchObject({ code: '23505' }); + await assertInvariant(); + }); + it('enforces MERGE and alias owner UPDATE automatically', async () => { + await write(pool, 'alias insert', A); + await expect(pool.query(`MERGE INTO users u USING (VALUES ($1::text,$2::text)) v(id,email) + ON u.workos_user_id=v.id WHEN MATCHED THEN UPDATE SET email=v.email`, [B, target])).rejects.toMatchObject({ code: '23505' }); + await write(pool, 'user update', A); + await expect(pool.query('UPDATE user_email_aliases SET workos_user_id=$2 WHERE workos_user_id=$1', [A, B])).rejects.toMatchObject({ code: '23505' }); + }); + it('enforces COPY without a route or application lock', async () => { + await write(pool, 'alias insert', A); + await pool.query('DELETE FROM users WHERE workos_user_id=$1', [B]); + // Static test-only local printf supplies COPY input inside disposable PG. + await expect(pool.query(`COPY users(workos_user_id,email) FROM PROGRAM + 'printf "user_normalized_email_b\\tnormalized-target@test.example\\n"'`)).rejects.toMatchObject({ code: '23505' }); + await assertInvariant(); + }); + it('holds TRUNCATE reuse until rollback and keeps savepoint lock ownership', async () => { + await write(pool, 'alias insert', A); + const client = await pool.connect(); + try { + await client.query('BEGIN'); await client.query('SAVEPOINT before_truncate'); + await client.query('TRUNCATE user_email_aliases'); + await expect(write(pool, 'user update', B)).rejects.toMatchObject({ code: '55P03' }); + await client.query('ROLLBACK TO SAVEPOINT before_truncate'); + await client.query('ROLLBACK'); + await expect(write(pool, 'user update', B)).rejects.toMatchObject({ code: '23505' }); + } finally { await client.query('ROLLBACK'); client.release(); } + }); + it('retains enforcement when low-privilege callers cannot read aliases or the epoch', async () => { + await write(pool, 'alias insert', A); + await expect(transaction(async client => { + await client.query(`CREATE ROLE normalized_email_test_writer NOLOGIN; + GRANT USAGE ON SCHEMA public TO normalized_email_test_writer; + GRANT SELECT, UPDATE ON users TO normalized_email_test_writer; + SET LOCAL ROLE normalized_email_test_writer`); + await write(client, 'user update', B); + })).rejects.toMatchObject({ code: '23505' }); + }); + it('enforces email mutations made by another trigger on an unrelated UPDATE', async () => { + await write(pool, 'alias insert', A); + await expect(transaction(async client => { + await client.query(`CREATE FUNCTION test_normalized_email_rewrite() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN NEW.email := '${target}'; RETURN NEW; END $$; + CREATE TRIGGER test_normalized_email_rewrite BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION test_normalized_email_rewrite()`); + await client.query('UPDATE users SET first_name=first_name WHERE workos_user_id=$1', [B]); + })).rejects.toMatchObject({ code: '23505' }); + }); + it('enforces replica writes and cannot be deferred with SET CONSTRAINTS', async () => { + await write(pool, 'alias insert', A); + await expect(transaction(async client => { + await client.query("SET LOCAL session_replication_role='replica'; SET CONSTRAINTS ALL DEFERRED"); + await write(client, 'user update', B); + })).rejects.toMatchObject({ code: '23505' }); + }); + it.each(['RAISE EXCEPTION \'storage failure\'', 'RETURN NULL'])('fails closed on serialization storage trigger: %s', async behavior => { + await expect(transaction(async client => { + await client.query(`CREATE FUNCTION test_normalized_email_storage() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN ${behavior}; END $$; + CREATE TRIGGER test_normalized_email_storage BEFORE UPDATE ON normalized_email_serialization FOR EACH ROW EXECUTE FUNCTION test_normalized_email_storage()`); + await write(client, 'alias insert', A); + })).rejects.toThrow(); + expect((await pool.query('SELECT 1 FROM user_email_aliases WHERE workos_user_id=$1', [A])).rowCount).toBe(0); + }); + it('fails closed when the serialization singleton is missing', async () => { + await expect(transaction(async client => { + await client.query('DELETE FROM normalized_email_serialization'); + await write(client, 'user update', B); + })).rejects.toMatchObject({ code: '23514' }); + }); + it('uses qualified relations despite a hostile caller search_path', async () => { + await write(pool, 'alias insert', A); + await expect(transaction(async client => { + await client.query('CREATE TEMP TABLE user_email_aliases(email TEXT); SET LOCAL search_path=pg_temp,public'); + await write(client, 'user update', B); + })).rejects.toMatchObject({ code: '23505' }); + }); + + it('matches production Node normalization for every mapped scalar, trim character and contextual sigma', async () => { + expect(process.versions.unicode).toBe('17.0'); + const examples = ['ΟΣ', 'Οσ', 'İ', 'AΣ', 'AΣA', 'ʰΣ', 'AʰΣ', 'AΣʰA', '\u0345Σ', 'A\u0345Σ', 'AΣ\u0345A']; + for (let n = 1; n <= 0x10ffff; n++) { + if (n >= 0xd800 && n <= 0xdfff) continue; + const ch = String.fromCodePoint(n); + if (ch !== ch.toLowerCase() || ch.trim() === '') examples.push(ch, `${ch}AΣ${ch}`, `${ch}TEST@EXAMPLE.COM${ch}`); + } + for (let start = 0; start < examples.length; start += 300) { + const batch = examples.slice(start, start + 300); + const result = await pool.query('SELECT public.normalized_credential_email(value) AS email FROM unnest($1::text[]) value', [batch]); + expect(result.rows.map(row => row.email)).toEqual(batch.map(value => value.trim().toLowerCase())); + } + }); + it('reruns idempotently without changing existing unambiguous rows', async () => { + await write(pool, 'alias insert', A); + const before = (await pool.query('SELECT * FROM users WHERE workos_user_id=ANY($1) ORDER BY workos_user_id', [[A, B]])).rows; + await transaction(async client => { await client.query(migration); }); + expect((await pool.query('SELECT * FROM users WHERE workos_user_id=ANY($1) ORDER BY workos_user_id', [[A, B]])).rows).toEqual(before); + }); + const productionSources = [ + '../../src/http.ts', '../../src/routes/workos-webhooks.ts', '../../src/routes/admin/users.ts', + '../../src/mcp/oauth-provider.ts', '../../src/dev-setup.ts', '../../scripts/setup-sandbox.ts', + ]; + for (const path of productionSources) { + it(`covers actual INSERT SQL at the ${path} production database seam`, async () => { + const source = await fs.readFile(new URL(path, import.meta.url), 'utf8'); + const statements = [...source.matchAll(/`(INSERT INTO users\s*\([\s\S]*?)`/g)].map(match => match[1]); + expect(statements.length).toBeGreaterThan(0); + await write(pool, 'alias insert', A); + for (const sql of statements) { + const columns = sql.match(/INSERT INTO users\s*\(([^)]+)\)/)![1].split(',').map(value => value.trim()); + const values = sql.match(/VALUES\s*\(([^;]*?)\)\s*(?:ON CONFLICT|$)/)![1].split(',').map(value => value.trim()); + const params: unknown[] = []; + values.forEach((value, index) => { + const placeholder = value.match(/^\$(\d+)$/); + if (!placeholder) return; + const column = columns[index]; + params[Number(placeholder[1]) - 1] = column === 'workos_user_id' ? B : column === 'email' ? target + : column === 'email_verified' ? false : column.endsWith('_at') ? new Date().toISOString() + : column === 'primary_organization_id' ? null : 'Test'; + }); + await pool.query('DELETE FROM users WHERE workos_user_id=$1', [B]); + await expect(pool.query(sql, params)).rejects.toMatchObject({ code: '23505' }); + if (sql.includes('DO UPDATE')) { + await pool.query('INSERT INTO users(workos_user_id,email) VALUES ($1,$2)', [B, 'normalized-b@test.example']); + await expect(pool.query(sql, params)).rejects.toMatchObject({ code: '23505' }); + } + } + await assertInvariant(); + }); + } + it('surfaces ambiguous legacy conflicts without deleting or reassigning any row', async () => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query('ALTER TABLE users DISABLE TRIGGER verify_normalized_email_writer'); + await write(client, 'user update', A); await write(client, 'user update', B, ` ${target.toUpperCase()} `); + const before = (await client.query('SELECT workos_user_id,email FROM users WHERE workos_user_id=ANY($1) ORDER BY workos_user_id', [[A, B]])).rows; + await client.query('SAVEPOINT install'); + await expect(client.query(migration)).rejects.toMatchObject({ code: '23505', constraint: 'normalized_email_owner', detail: expect.stringContaining(A) }); + await client.query('ROLLBACK TO SAVEPOINT install'); + expect((await client.query('SELECT workos_user_id,email FROM users WHERE workos_user_id=ANY($1) ORDER BY workos_user_id', [[A, B]])).rows).toEqual(before); + } finally { await client.query('ROLLBACK'); client.release(); } + }); +}); diff --git a/specs/normalized-email-invariant.md b/specs/normalized-email-invariant.md new file mode 100644 index 00000000000..6112f4a291f --- /dev/null +++ b/specs/normalized-email-invariant.md @@ -0,0 +1,208 @@ +# Normalized credential email invariant (migration 595) + +This is one #6827 integrity/recovery slice. It is not #6827 closure. Historical +exact reversal, identity/authority provenance, callback containment, and provider +reconciliation remain separate work. An alias is not an authentication source; +the #7458 containment must remain in the deployment stack. + +## Ownership and legacy policy + +The owner is the exact `workos_user_id`, never an identity inferred from email. +For each JavaScript-normalized email, at most one credential may occur across +`users.email` and `user_email_aliases.email`. Canonical siblings are different +owners. One credential row may overlap with one alias belonging to that exact +credential: migration 592 updates the credential before deleting its own alias +during a primary-email swap. Two aliases with the same normalized email are +rejected even if their owners match. Verification state does not relax ownership. + +Migration 595 does not normalize stored values, select a winner, move bindings, +delete aliases, or change membership/credential provenance. Preflight runs under +both table locks and reports conflicting raw rows, credential IDs and normalized +keys. A conflict aborts the migration transaction; all original rows remain. +The exception includes the total and the first 20 conflicting keys. Obtain the +complete inventory using explicitly supplied read-only credentials: + +```bash +DATABASE_URL=... npx tsx server/src/scripts/audit-normalized-emails.ts +``` + +The script requires Unicode 17.0 and refuses an RLS-filtered result. It uses a +read-only repeatable-read transaction and reports every overlap, +including permitted exact-credential overlaps. Binding IDs are evidence only. +Exit status 1 means conflicts exist. Protect the output as account data. No live +inventory was performed for this candidate. Case/whitespace variants, prior +Google/admin duplication, rebinding and uncertain provider operations can all +block installation. Support must establish provenance and authorize individual +repairs; this migration cannot determine which historical row is correct. + +## Database protocol + +The two tables each have an `ENABLE ALWAYS` BEFORE STATEMENT lock trigger and +AFTER STATEMENT check trigger on **all** INSERT, UPDATE, DELETE and TRUNCATE. +This covers COPY, UPSERT, MERGE, cascades, direct SQL, dynamic SQL, future callers, +and another trigger changing email during an unrelated-column UPDATE. There is +no trigger-depth exemption. Neither check is deferred by SET CONSTRAINTS. + +1. Acquire `pg_try_advisory_xact_lock(6827, 595)`. This two-int namespace is + disjoint from 592's one-bigint hashed credential locks. Failure raises 55P03. +2. Physically increment the singleton `normalized_email_serialization.version`; + require exactly one returned row. Missing/suppressed storage fails closed. + Under REPEATABLE READ/SERIALIZABLE, a writer with an older snapshot fails + with 40001 when another invariant transaction has changed the singleton. +3. Run the requested statement, including its row triggers and FK effects. +4. Check the persisted representations with a VOLATILE function and bytewise + `COLLATE "C"` grouping. Conflicts raise 23505 / `normalized_email_owner`. +5. Retain the lock through commit/rollback, including deferred trigger execution. + +The transaction lock prevents another participating writer between check and +commit. VOLATILE SPI queries see new committed snapshots at READ COMMITTED; +the singleton write closes the stronger-isolation stale-snapshot gap. Opposite +email order, table order, and multi-statement order add no advisory wait edge: +contenders abort immediately. A caller may retry the **entire rolled-back local +transaction**, with a fresh snapshot and a bounded attempt count. There is no +automatic trigger retry and no provider retry authorization. Never retry an +uncertain external operation without its durable reconciliation protocol. + +This is not a claim that arbitrary PostgreSQL code is deadlock-free. Explicit +row/table locks and TRUNCATE's relation locks may deadlock before these triggers +run. PostgreSQL aborts a participant without breaking the invariant. Installation +also uses NOWAIT table locks; drain writers and retry installation on 55P03. + +The global gate and full-table validation deliberately trade throughput for a +small, auditable protocol. Profile/name updates and zero-row statements also +participate. Measure contention and scan cost at realistic volume before rollout; +55P03 is a fail-closed availability response, not successful synchronization. +An unrelated advisory user deliberately choosing the same two-int key can cause +contention but cannot admit a conflicting email. + +## Exact normalization + +`normalized_credential_email(text)` implements Node's `trim().toLowerCase()` for +PostgreSQL UTF-8 text, pinned to Unicode 17.0 (the production Node 24 toolchain). +It uses generated locale-independent lowercase mappings, ECMAScript whitespace, +the U+0130 expansion, and context-sensitive final sigma on the original input. +Case_Ignorable takes precedence over Cased for overlapping Unicode properties. +No PostgreSQL locale-specific LOWER, Unicode casefold, NFC, IDNA, or Google +mailbox heuristic determines ownership. NUL/lone surrogates are outside the +PostgreSQL text domain. Stored email strings remain unchanged. + +```bash +node scripts/generate-email-normalization.mjs --check +``` + +The generator and parity integration test reject Unicode-version drift. A Node +upgrade changing these semantics requires a new migration and conflict inventory; +do not rewrite a deployed immutable normalization function or released migration. +The pre-existing `LOWER(email)` alias index is retained: it may reject additional +Unicode combinations that JavaScript distinguishes. That conservative historical +restriction is not a bypass of the new invariant. + +## Function custody and operational order + +All data-reading/mutating enforcement functions are SECURITY DEFINER with fixed +`search_path=pg_catalog`, schema-qualified application relations, and +`row_security=off`. PUBLIC cannot call the internal check/trigger functions or +write the serialization table. Trigger execution still protects DML roles that +cannot read aliases or the singleton. The owner must have full visibility; +`row_security=off` fails if RLS would otherwise hide rows. Do not grant runtime +DDL ownership, trigger-disabling capability, or function replacement rights. +Superusers and object owners can intentionally bypass enforcement; no trigger +scheme protects against a database administrator replacing it. + +ALWAYS triggers enforce the email invariant in replica mode. They do not make +592's ordinary triggers or foreign keys replica-safe. Logical replication and +restore require a reviewed ordering/validation procedure. Restore into an +isolated database, retain all raw evidence, run the inventory and install or +revalidate enforcement before admitting application traffic. Restoring a dump +that creates triggers after COPY does not prove the copied data is conflict-free. +Never disable triggers on an active application database to import conflicts. + +Migration 595 is self-contained against main's current schema and is rerunnable +under the repository runner. It does not redefine any 592 function or alter its +journal/uncertain-operation semantics. Operational stack position is strictly +591 -> 592 -> 593 -> unpublished 594 -> 595, after coordinator allocation audit. +The alias atomicity candidate and #7458/#7459/#7464 application containment must +be composed/reviewed in their intended order. No dependency migration is copied +into this production branch. + +There is no honest automated down migration: dropping the four triggers and +their functions/table reopens the race and discards serialization evidence. +An authorized rollback requires drained writes and an explicit decision to +remove the integrity guarantee. Application rollback alone must retain 595. + +## Production writer inventory + +Line references are to exact parent `8d50c0e3c303391c8bb35c5042248eb847b06bb9`. +All 21 statements below are covered by the table triggers after installation. +Historical boot SQL runs before 595 and is covered by installation preflight. + +| File | Lines | SQL writer | +|---|---|---| +| server/src/http.ts | 7758, 7807 | Login UPSERT and Google credential import INSERT | +| server/src/http.ts | 7828, 7880 | Legacy Google alias INSERT / cleanup DELETE | +| server/src/routes/account-linking.ts | 298, 303, 308, 480 | Primary email UPDATE; alias DELETE/INSERT; verification alias INSERT | +| server/src/routes/workos-webhooks.ts | 448, 506 | WorkOS user event UPSERT / DELETE with FK cascades | +| server/src/routes/workos-webhooks.ts | 1430, 1552 | Global/per-org backfill UPSERT / provider-404 cleanup DELETE | +| server/src/routes/admin/users.ts | 939, 1094 | Fresh credential INSERT / existing provider credential import INSERT | +| server/src/mcp/oauth-provider.ts | 271 | MCP OAuth credential UPSERT | +| server/src/db/user-merge-db.ts | 714, 722 | Alias duplicate DELETE / owner UPDATE | +| server/src/dev-setup.ts | 246 | Dev credential INSERT | +| server/scripts/setup-sandbox.ts | 226, 283 | Sandbox credential UPSERT / cleanup DELETE | +| server/src/db/migrations/079_users_table.sql | 130 | Historical credential backfill UPSERT | + +The dynamic `users SET` builder in `addie/mcp/member-tools.ts:3024` allows only +headline, bio, city, linkedin_url, twitter_url, expertise and interests; it is +nevertheless protected. `user-merge-db.ts` has fixed-list dynamic UPDATE/DELETE +helpers; their current call sites do not target the two email tables. Migration +080 engagement and 210 slug procedures update users without email changes and +still participate. Migration 460 creates explicit identity bindings after user +insertion. Binding-only writes cannot change this exact-credential invariant. +No production COPY/MERGE/TRUNCATE writer was found; DB tests cover their seams. + +Dependency writer movements are also covered: #7459/#7464 centralize credential +UPSERT/DELETE in `identity-db.ts`; #7464 adds an INSERT in +`services/admin-credential-bind.ts`; 592 adds UPDATE users and alias DELETE/UPSERT +in `services/email-mutation.ts`. #7458 removes legacy automatic Google alias +authority behavior. The new migration adds no authority inference. + +## Application and composition limits + +Database-seam tests execute the actual production INSERT/UPSERT strings from +login/Google, WorkOS event/backfill, admin create/import, MCP and sandbox code. +Those tests prove storage enforcement. They do not claim provider ordering: +base login/MCP still catch local persistence errors and continue authentication, +and base/admin #7464 preflight only checks users with LOWER(email). An alias +conflict can therefore be denied after provider creation; durable compensation +and reconciliation remain required. New route-level preflight/containment is a +separate stack concern. No non-test database or real provider is used here. + +Migration 595 alone cannot prove an alias verification INSERT/UPDATE happened +when an unrelated trigger returns NULL. Exact candidate 8847fc performs the +required rowCount/readback checks; its production verification flow has no +required audit INSERT. Its existing test-only audit/storage faults are included +in composition validation. Main's legacy verifier remains outside this fix. + +`normalized-email-composition.test.ts` intentionally runs only with +`ADCP_EMAIL_COMPOSITION=true` in an isolated checkout containing published #7463 +plus the exact candidate route. It checks these git blob hashes before execution: + +- migration 592: `e726bbaaa214105df6a922773f7c208bb4919966` +- candidate route: `ab283d29b754dd083d31c645a49ef56fbb94e2da` +- recovered existing candidate tests: `0cef113161f9bed39a9c52e1f67d0f555771098b` + +The composition pauses the actual candidate after its users predicate, commits +an independent competing credential INSERT/UPDATE, then resumes: verification +fails, token remains pending and no alias commits. In the reverse ordering, +the credential writer fails and only verification succeeds. A READ COMMITTED +predicate is no longer the invariant's serialization boundary. + +Mounted primary-email composition also proves that local preflight denial makes +no provider calls, and that common-lock denial during local apply/compensation +retains 592's durable reconciliation state. Retrying that unresolved operation +does not repeat provider calls. + +Three old dependency tests deliberately create rows that 595 now prohibits: +two physically ambiguous historical-alias fixtures and a same-email fingerprint +fixture on another credential. Their pre-595 behavior and post-595 setup denial +must be classified separately; they are not grounds to disable production +enforcement. Legacy-conflict retention is tested at migration preflight. From 0443295a6cbb11f6a52cb8c14cd8b92485639fbd Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 10 Oct 2026 00:34:02 +0000 Subject: [PATCH 2/2] test(auth): qualify credential capacity under normalized-email fencing --- .../workos-user-deletion-race.test.ts | 79 +++++++++++++++---- 1 file changed, 62 insertions(+), 17 deletions(-) diff --git a/server/tests/integration/workos-user-deletion-race.test.ts b/server/tests/integration/workos-user-deletion-race.test.ts index 75f644186e6..eb82dfd23c7 100644 --- a/server/tests/integration/workos-user-deletion-race.test.ts +++ b/server/tests/integration/workos-user-deletion-race.test.ts @@ -271,35 +271,80 @@ describe('WorkOS webhook vs sync-users deletion', () => { let activeCallbacks = 0; let maxActiveCallbacks = 0; - const results = await within(Promise.all( - Array.from({ length: 10 }, async (_, index) => withCredentialCreationEventMutation( - `${PREFIX}pool_capacity_${index}`, + // Email writers intentionally fail fast on migration 595's global lock. + // Seed serially so this test measures credential pool admission rather + // than asking competing email writers to bypass that invariant. + const userIds = Array.from({ length: 10 }, (_, index) => `${PREFIX}pool_capacity_${index}`); + for (const [index, userId] of userIds.entries()) { + await pool.query( + `INSERT INTO users (workos_user_id, email) VALUES ($1, $2)`, + [userId, `pool-${index}@race.test`], + ); + } + + const pending = Promise.allSettled( + userIds.map((userId) => withActiveCredentialEventMutation( + userId, async (client) => { - await upsertWorkosUserInCredentialEvent(client, { - id: `${PREFIX}pool_capacity_${index}`, - email: `pool-${index}@race.test`, - firstName: 'Pool', - lastName: 'Capacity', - emailVerified: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, 'provider_authoritative'); activeCallbacks++; maxActiveCallbacks = Math.max(maxActiveCallbacks, activeCallbacks); try { - await client.query(`SELECT pg_sleep(0.025)`); + await client.query(`SELECT pg_sleep(0.1)`); } finally { activeCallbacks--; } }, )), - ), 3_000); - - expect(results).toHaveLength(10); - expect(results.every((result) => result.applied)).toBe(true); + ); + try { + const results = await within(pending, 3_000); + expect(results).toHaveLength(10); + for (const result of results) { + if (result.status === 'rejected') throw result.reason; + expect(result.value.applied).toBe(true); + } + } finally { + // Even a timeout must drain callbacks before the next test's cleanup. + await pending; + } expect(maxActiveCallbacks).toBeGreaterThan(1); // One connection stays reserved so confirmed deletion can always enter. expect(maxActiveCallbacks).toBeLessThanOrEqual(7); + expect((await pool.query( + `SELECT workos_user_id FROM authorization_epochs WHERE workos_user_id = ANY($1)`, + [userIds], + )).rowCount).toBe(10); + }); + + it('rolls back without replaying a callback denied by the normalized-email writer lock', async () => { + const userId = `${PREFIX}email_writer_busy`; + let callbackCalls = 0; + const blocker = await pool.connect(); + try { + await blocker.query('BEGIN'); + expect((await blocker.query( + `SELECT pg_try_advisory_xact_lock(6827, 595) AS acquired`, + )).rows[0].acquired).toBe(true); + + await expect(withCredentialCreationEventMutation(userId, async (client) => { + callbackCalls++; + await upsertWorkosUserInCredentialEvent(client, { + id: userId, + email: 'email-writer-busy@race.test', + firstName: 'Busy', + lastName: 'Writer', + emailVerified: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, 'provider_authoritative'); + })).rejects.toMatchObject({ code: '55P03' }); + expect(callbackCalls).toBe(1); + expect((await pool.query(`SELECT 1 FROM users WHERE workos_user_id = $1`, [userId])).rows).toEqual([]); + expect((await pool.query(`SELECT 1 FROM authorization_epochs WHERE workos_user_id = $1`, [userId])).rows).toEqual([]); + } finally { + await blocker.query('ROLLBACK'); + blocker.release(); + } }); it('never replays a callback that has started after a retryable database error', async () => {