Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,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",
Expand Down
83 changes: 83 additions & 0 deletions scripts/generate-email-normalization.mjs
Original file line number Diff line number Diff line change
@@ -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);
189 changes: 189 additions & 0 deletions server/src/db/migrations/595_normalized_email_invariant.sql

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions server/src/scripts/audit-normalized-emails.ts
Original file line number Diff line number Diff line change
@@ -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<string, typeof result.rows>();
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();
}
139 changes: 139 additions & 0 deletions server/tests/integration/normalized-email-composition.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>(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()');
});
});
Loading
Loading