-
Notifications
You must be signed in to change notification settings - Fork 0
Reconcile PR #33 + #34 audit remediation (fixes broken checkout) + post-review hardening #46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
09343a2
fix(schema): reconcile pr33/pr34 audit migrations into one clean migr…
claude c7ec106
fix(payments): wire up the Source-based webhook flow that main never …
claude 35dcd5e
fix(integrity): replace check-then-act races with DB-enforced idempot…
claude 9eb7b6b
fix(security): port forward pr33-only fixes with no pr34 overlap
claude 3c4bf0b
test: reconcile and extend coverage for the audit remediation
claude ad71c2d
fix(payments,security): harden reconciliation — webhook idempotency, …
claude 0a19044
review: address CodeRabbit findings on #46
claude 0a4cd7e
fix(ci,seed): unblock E2E/Lighthouse seed + canonical-email collision…
claude 6fb46b6
fix(script): mask emails by default in collision report (CWE-532)
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
107 changes: 107 additions & 0 deletions
107
prisma/migrations/20260718000000_audit_integrity_fixes/migration.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| -- Audit remediation (2026-07-17): integrity + security fixes on main. | ||
| -- Covers: XP ledger (C10), guest-claim token (C5/C6), unique refund id (C8), | ||
| -- one-active-refund-per-payment (C7), one-active-certificate-per-course (H7), | ||
| -- and canonical (lowercase) email backfill (H6). | ||
| -- | ||
| -- This is a single hand-assembled migration reconciling PR #33's | ||
| -- 20260717000001_claim_token and PR #34's 20260717000000_audit_integrity_fixes | ||
| -- migrations, which both independently added `User.claimTokenHash` / | ||
| -- `User.claimTokenExpiresAt` and would collide (duplicate-column error) if | ||
| -- stacked directly on top of one another. | ||
|
|
||
| -- ---------------------------------------------------------------------------- | ||
| -- C5/C6: guest-checkout account-claim token columns on User. | ||
| -- ---------------------------------------------------------------------------- | ||
| ALTER TABLE "User" ADD COLUMN "claimTokenHash" TEXT; | ||
| ALTER TABLE "User" ADD COLUMN "claimTokenExpiresAt" TIMESTAMP(3); | ||
|
|
||
| CREATE INDEX "User_claimTokenHash_idx" ON "User"("claimTokenHash"); | ||
|
|
||
| -- ---------------------------------------------------------------------------- | ||
| -- C10: XP ledger. Unique (userId, eventKey) makes every award exactly-once. | ||
| -- ---------------------------------------------------------------------------- | ||
| CREATE TABLE "XpLedger" ( | ||
| "id" TEXT NOT NULL, | ||
| "userId" TEXT NOT NULL, | ||
| "eventKey" TEXT NOT NULL, | ||
| "amount" INTEGER NOT NULL, | ||
| "reason" TEXT NOT NULL, | ||
| "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
|
|
||
| CONSTRAINT "XpLedger_pkey" PRIMARY KEY ("id") | ||
| ); | ||
|
|
||
| CREATE UNIQUE INDEX "XpLedger_userId_eventKey_key" ON "XpLedger" ("userId", "eventKey"); | ||
| CREATE INDEX "XpLedger_userId_idx" ON "XpLedger" ("userId"); | ||
| CREATE INDEX "XpLedger_createdAt_idx" ON "XpLedger" ("createdAt"); | ||
|
|
||
| ALTER TABLE "XpLedger" ADD CONSTRAINT "XpLedger_userId_fkey" | ||
| FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE; | ||
|
|
||
| -- ---------------------------------------------------------------------------- | ||
| -- C8: a PayMongo refund id may be recorded at most once. | ||
| -- ---------------------------------------------------------------------------- | ||
| CREATE UNIQUE INDEX "RefundRequest_paymongoRefundId_key" | ||
| ON "RefundRequest" ("paymongoRefundId"); | ||
|
|
||
| -- ---------------------------------------------------------------------------- | ||
| -- C7: at most one active (PENDING or APPROVED) refund request per payment. | ||
| -- Partial unique index — cannot be expressed in schema.prisma, so it is | ||
| -- managed by raw SQL here. Concurrent duplicate requests now fail with P2002. | ||
| -- ---------------------------------------------------------------------------- | ||
| CREATE UNIQUE INDEX "RefundRequest_active_per_payment_key" | ||
| ON "RefundRequest" ("paymentId") | ||
| WHERE "deletedAt" IS NULL AND "status" IN ('PENDING', 'APPROVED'); | ||
|
|
||
| -- ---------------------------------------------------------------------------- | ||
| -- H7: at most one active certificate per (user, course). | ||
| -- ---------------------------------------------------------------------------- | ||
| CREATE UNIQUE INDEX "Certificate_active_per_user_course_key" | ||
| ON "Certificate" ("userId", "courseId") | ||
| WHERE "deletedAt" IS NULL AND "status" = 'ACTIVE'; | ||
|
|
||
| -- ---------------------------------------------------------------------------- | ||
| -- H6: backfill existing emails to canonical (trimmed, lowercase) form. | ||
| -- Rows whose canonical form would collide with another account are left | ||
| -- untouched, and need manual reconciliation before a case-insensitive | ||
| -- uniqueness constraint (citext / normalized column) can be enforced. | ||
| -- | ||
| -- The collision guard compares canonical-to-CANONICAL, not canonical-to-exact. | ||
| -- Two rows that are both non-canonical but share a canonical form (e.g. | ||
| -- 'Foo@Bar.com' and 'FOO@bar.com') would otherwise BOTH satisfy an exact-match | ||
| -- guard and both UPDATE to the same value in one statement (WHERE is evaluated | ||
| -- against the MVCC snapshot), violating the case-sensitive unique email index | ||
| -- and failing the migration. Canonical-to-canonical skips every member of a | ||
| -- colliding group instead. | ||
| -- ---------------------------------------------------------------------------- | ||
| UPDATE "User" u | ||
| SET email = lower(btrim(u.email)) | ||
| WHERE u.email <> lower(btrim(u.email)) | ||
| AND NOT EXISTS ( | ||
| SELECT 1 FROM "User" o | ||
| WHERE o.id <> u.id | ||
| AND lower(btrim(o.email)) = lower(btrim(u.email)) | ||
| ); | ||
|
|
||
| -- Surface the rows we deliberately skipped (their canonical form collides with | ||
| -- another account) so they don't vanish silently: canonicalized lookups | ||
| -- (findOrCreateUserByEmail, signUpAction) query by lowercase and can't find a | ||
| -- still-mixed-case row. These need manual reconciliation. Emitted as a WARNING | ||
| -- so it shows in migration/deploy logs; run scripts/report-email-collisions.ts | ||
| -- afterwards to list them again on demand. | ||
| DO $$ | ||
| DECLARE | ||
| skipped_count INTEGER; | ||
| BEGIN | ||
| SELECT count(*) INTO skipped_count | ||
| FROM "User" u | ||
| WHERE u.email <> lower(btrim(u.email)) | ||
| AND EXISTS ( | ||
| SELECT 1 FROM "User" o | ||
| WHERE o.id <> u.id | ||
| AND lower(btrim(o.email)) = lower(btrim(u.email)) | ||
| ); | ||
| IF skipped_count > 0 THEN | ||
| RAISE WARNING 'H6 email canonicalization: % account(s) left un-normalized due to a case-insensitive collision. Run scripts/report-email-collisions.ts and reconcile manually.', skipped_count; | ||
| END IF; | ||
| END $$; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| #!/usr/bin/env tsx | ||
| /** | ||
| * Report User rows whose email is not yet canonical (trimmed + lowercase) | ||
| * because canonicalizing it would collide with another account's email. | ||
| * | ||
| * The H6 backfill migration (20260718000000_audit_integrity_fixes) skips these | ||
| * rows on purpose - normalizing them would violate the case-sensitive unique | ||
| * email constraint. Canonicalized lookups (findOrCreateUserByEmail, | ||
| * signUpAction) query by lowercase, so a still-mixed-case row is effectively | ||
| * unreachable until an operator reconciles the duplicate accounts by hand. | ||
| * | ||
| * This script lists them so the reconciliation can actually happen. | ||
| * | ||
| * Usage: | ||
| * DATABASE_URL="postgres://..." tsx scripts/report-email-collisions.ts | ||
| */ | ||
| import { PrismaClient } from '@prisma/client'; | ||
| import { PrismaPg } from '@prisma/adapter-pg'; | ||
|
|
||
| if (!process.env.DATABASE_URL) { | ||
| throw new Error('DATABASE_URL environment variable is not set.'); | ||
| } | ||
| const prisma = new PrismaClient({ adapter: new PrismaPg(process.env.DATABASE_URL) }); | ||
|
|
||
| // Raw email addresses are PII; a plain run may land in CI/cron/aggregated logs | ||
| // (CWE-532). Mask by default and require an explicit opt-in to print full | ||
| // addresses for the actual reconciliation. The opaque user id is always shown | ||
| // (it's the key an operator acts on) and is not itself PII. | ||
| const SHOW_RAW_EMAILS = process.env.SHOW_EMAILS === '1' || process.env.SHOW_EMAILS === 'true'; | ||
|
|
||
| /** Mask the local part, keep the domain: 'Maria@Example.com' -> 'M***@example.com'. */ | ||
| function maskEmail(email: string): string { | ||
| const at = email.lastIndexOf('@'); | ||
| if (at <= 0) return '***'; | ||
| const first = email[0] ?? ''; | ||
| return `${first}***${email.slice(at)}`; | ||
| } | ||
|
|
||
| async function main() { | ||
| // Group by canonical (lowercased) email and report every member of any group | ||
| // with more than one row. This catches collisions where BOTH rows are | ||
| // non-canonical (e.g. 'Foo@Bar.com' + 'FOO@bar.com') and share a canonical | ||
| // form, which an exact-value EXISTS lookup would miss - exactly the rows the | ||
| // H6 migration leaves un-normalized. | ||
| const rows = await prisma.$queryRaw< | ||
| Array<{ id: string; email: string; canonical: string }> | ||
| >` | ||
| SELECT u.id, u.email, lower(btrim(u.email)) AS canonical | ||
| FROM "User" u | ||
| WHERE lower(btrim(u.email)) IN ( | ||
| SELECT lower(btrim(email)) | ||
| FROM "User" | ||
| GROUP BY lower(btrim(email)) | ||
| HAVING COUNT(*) > 1 | ||
| ) | ||
| ORDER BY canonical, u.email | ||
| `; | ||
|
|
||
| if (rows.length === 0) { | ||
| console.log('No email-canonicalization collisions - every canonical email is unique.'); | ||
| return; | ||
| } | ||
|
|
||
| console.log(`Found ${rows.length} account(s) in ${new Set(rows.map((r) => r.canonical)).size} colliding group(s), needing manual reconciliation:\n`); | ||
| for (const r of rows) { | ||
| const email = SHOW_RAW_EMAILS ? r.email : maskEmail(r.email); | ||
| const canonical = SHOW_RAW_EMAILS ? r.canonical : maskEmail(r.canonical); | ||
| console.log(` ${r.id} ${email} -> canonical "${canonical}"`); | ||
| } | ||
| if (!SHOW_RAW_EMAILS) { | ||
| console.log('\n(Emails masked. Re-run with SHOW_EMAILS=1 to print full addresses for reconciliation.)'); | ||
| } | ||
| console.log( | ||
| '\nResolve each group by merging or renaming the duplicate accounts, then re-run the H6 UPDATE.', | ||
| ); | ||
| } | ||
|
|
||
| main() | ||
| .catch((err) => { | ||
| console.error(err); | ||
| process.exit(1); | ||
| }) | ||
| .finally(() => prisma.$disconnect()); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.