fix(db): isolate redemption migration from identity schema
This commit is contained in:
@@ -2458,3 +2458,19 @@
|
||||
- 相关记录:BUG-126、BUG-132
|
||||
- 复发自:BUG-126(模式:新增页面未同步能力审计精确路由集合)
|
||||
- 修复版本:待 follow-up commit / gate
|
||||
|
||||
## BUG-144 | db/migrations 副本依赖业务 schema 导致 identity-only fixture 迁移失败
|
||||
|
||||
- 状态:resolved(local candidate,远端 gate 待 follow-up 更新)
|
||||
- 首次发现:2026-08-07
|
||||
- 最近更新:2026-08-07
|
||||
- 影响面:`frontend/db/migrations/20260807020000_redeem_security.sql`(已删除)、`frontend/tests/redeem-orders-contract.test.ts`、`frontend/tests/database-redeem-security.test.ts`;staging quality gate run `1552` frontend 1054 passed / 2 failed,Python quick 291 passed / 1 skipped,publish / deploy 均未发生。
|
||||
- 用户现象:gate run `1552` 数据库真实失败,publish 被跳过,自动 deploy 未发生。
|
||||
- 触发条件:新增依赖业务 schema(`public.redemption_codes`、`public.credit_transactions`)的 redemption 安全迁移时,同时在 `frontend/db/migrations` 与 `frontend/supabase/migrations` 各放一份;`database-local-business` 聚合迁移(两目录全量)通过,而只应用 `frontend/db/migrations` 的 identity-only fixture 中业务 schema 尚不存在。
|
||||
- 根因:`database-self-hosted-identity` 与 `identity-auth-integration` 只应用 `frontend/db/migrations`(identity foundation),新的 db 副本假设 `public.redemption_codes` 已由 supabase compatibility 迁移创建;identity-only 序列因此 `migration failed`,与 BUG-127 同类(新增业务表必须精确选择迁移位置/全迁移),但不是 BUG-127 或 BUG-143 的复发,属本变更独立根因。
|
||||
- 修复:删除 `frontend/db/migrations/20260807020000_redeem_security.sql`,仅保留 `frontend/supabase/migrations/20260807030000_redeem_security.sql`;contract 测试只审该单一 migration,删除 byte-identical 双副本断言,并新增硬防线:断言 `frontend/db/migrations/20260807020000_redeem_security.sql` 不存在(`existsSync === false`);DB security 测试 aggregate runner 只期望 `applied 20260807030000_redeem_security.sql`,不再期待 070200,并断言 stdout 不含 `20260807020000_redeem_security`;SQL 内容未作任何变更。
|
||||
- 验证:本机无 Docker,无法执行 identity-only 与 aggregate DB 实测;运行 `npx tsx --test tests/redeem-orders-contract.test.ts`(7 passed,原 6 项 + 新增 existsSync 防复发守卫 1 项)、`tsc --noEmit`(clean)与 `git diff --check` 通过;目标 identity DB 实测(`database-self-hosted-identity`、`identity-auth-integration`)与 aggregate DB 测试(`database-local-business`、`database-redeem-security`)留待远端 gate 确认,本记录不提前声称远端收口。
|
||||
- 防复发:`frontend/db/migrations` 只放 identity foundation 独立可执行迁移;依赖业务 schema(`public.redemption_codes`、`payment_orders` 等)的迁移只进 `frontend/supabase/migrations`;任何新增/删除迁移必须在本变更中同时跑 identity-only 两测试(`database-self-hosted-identity`、`identity-auth-integration`)与 aggregate DB 测试(`database-local-business` 等)。
|
||||
- 相关记录:BUG-127、BUG-143
|
||||
- 复发自:无(独立根因,非 BUG-127 / BUG-143 复发)
|
||||
- 修复版本:待 follow-up commit / gate
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
begin;
|
||||
|
||||
-- Account-level, cross-instance rate limiting for consecutive failed
|
||||
-- redemption attempts. The audit table records only who failed and when;
|
||||
-- neither the plaintext code nor its hash is ever stored here. It is
|
||||
-- failure-only: a successful redemption deletes the account's rows, so no
|
||||
-- permanent success row is kept.
|
||||
create table if not exists public.redemption_attempts (
|
||||
id bigint generated always as identity primary key,
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists redemption_attempts_user_created_idx
|
||||
on public.redemption_attempts (user_id, created_at desc);
|
||||
|
||||
alter table public.redemption_attempts enable row level security;
|
||||
revoke all on table public.redemption_attempts from anon, authenticated;
|
||||
grant select on table public.redemption_attempts to service_role;
|
||||
|
||||
-- CREATE OR REPLACE cannot change a function's return type, so the previous
|
||||
-- 3-column redeem_code(text) is dropped and rebuilt inside the same
|
||||
-- transaction, then its revoke/grant ACLs are restored below.
|
||||
drop function if exists public.redeem_code(text);
|
||||
|
||||
-- redeem_code keeps the redemption-code row lock and the credit_transactions
|
||||
-- unique constraint. It additionally serializes per-account attempts, counts
|
||||
-- business failures within a rolling 10-minute window, and returns
|
||||
-- rate_limited once an account reaches 5 failures. Every business failure
|
||||
-- writes an audit row; a successful redemption deletes the account's
|
||||
-- attempts. account_not_eligible is intentionally not fabricated here: there
|
||||
-- is no account-eligibility restriction model yet. The audit table's
|
||||
-- identity sequence needs no authenticated USAGE grant because the
|
||||
-- security-definer function runs as its owner.
|
||||
create or replace function public.redeem_code(p_code_hash text)
|
||||
returns table (success boolean, credits integer, awarded_credits integer, error_code text)
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public, pg_temp
|
||||
as $$
|
||||
declare
|
||||
v_user_id uuid := auth.uid();
|
||||
v_email text := auth.jwt() ->> 'email';
|
||||
v_code public.redemption_codes%rowtype;
|
||||
v_balance integer;
|
||||
v_failed integer;
|
||||
begin
|
||||
if v_user_id is null then
|
||||
return query select false, null::integer, null::integer, 'unauthorized'::text;
|
||||
return;
|
||||
end if;
|
||||
|
||||
perform pg_advisory_xact_lock(hashtextextended('redeem:' || v_user_id::text, 0));
|
||||
|
||||
delete from public.redemption_attempts
|
||||
where user_id = v_user_id and created_at < now() - interval '10 minutes';
|
||||
|
||||
select count(*) into v_failed
|
||||
from public.redemption_attempts
|
||||
where user_id = v_user_id;
|
||||
|
||||
if v_failed >= 5 then
|
||||
return query select false, null::integer, null::integer, 'rate_limited'::text;
|
||||
return;
|
||||
end if;
|
||||
|
||||
if p_code_hash is null or p_code_hash !~ '^[0-9a-f]{64}$' then
|
||||
insert into public.redemption_attempts (user_id) values (v_user_id);
|
||||
return query select false, null::integer, null::integer, 'invalid_code'::text;
|
||||
return;
|
||||
end if;
|
||||
|
||||
select rc.* into v_code
|
||||
from public.redemption_codes rc
|
||||
where rc.code_hash = p_code_hash
|
||||
for update;
|
||||
if not found then
|
||||
insert into public.redemption_attempts (user_id) values (v_user_id);
|
||||
return query select false, null::integer, null::integer, 'invalid_code'::text;
|
||||
return;
|
||||
end if;
|
||||
if v_code.redeemed_by is not null then
|
||||
insert into public.redemption_attempts (user_id) values (v_user_id);
|
||||
return query select false, null::integer, null::integer, 'already_redeemed'::text;
|
||||
return;
|
||||
end if;
|
||||
if v_code.revoked_at is not null then
|
||||
insert into public.redemption_attempts (user_id) values (v_user_id);
|
||||
return query select false, null::integer, null::integer, 'revoked_code'::text;
|
||||
return;
|
||||
end if;
|
||||
if v_code.expires_at is not null and v_code.expires_at <= now() then
|
||||
insert into public.redemption_attempts (user_id) values (v_user_id);
|
||||
return query select false, null::integer, null::integer, 'expired_code'::text;
|
||||
return;
|
||||
end if;
|
||||
|
||||
select p.credits into v_balance
|
||||
from public.profiles p
|
||||
where p.id = v_user_id
|
||||
for update;
|
||||
if not found then
|
||||
return query select false, null::integer, null::integer, 'profile_missing'::text;
|
||||
return;
|
||||
end if;
|
||||
|
||||
update public.redemption_codes rc
|
||||
set redeemed_by = v_user_id, redeemed_email = v_email, redeemed_at = now()
|
||||
where rc.id = v_code.id;
|
||||
|
||||
update public.profiles p
|
||||
set credits = p.credits + v_code.credits, updated_at = now()
|
||||
where p.id = v_user_id
|
||||
returning p.credits into v_balance;
|
||||
|
||||
insert into public.credit_transactions (
|
||||
user_id, transaction_type, amount, balance_after, request_id, redemption_code_id
|
||||
) values (
|
||||
v_user_id, 'redeem', v_code.credits, v_balance, v_code.id::text, v_code.id
|
||||
);
|
||||
|
||||
delete from public.redemption_attempts where user_id = v_user_id;
|
||||
|
||||
return query select true, v_balance, v_code.credits, null::text;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.redeem_code(text) from public, anon;
|
||||
grant execute on function public.redeem_code(text) to authenticated;
|
||||
|
||||
commit;
|
||||
@@ -33,8 +33,11 @@ test("redeem security: case-sensitive hashing, rate limiting, idempotency and or
|
||||
},
|
||||
});
|
||||
assert.equal(migration.status, 0, migration.stderr);
|
||||
assert.match(migration.stdout, /applied 20260807020000_redeem_security\.sql/);
|
||||
// Only the supabase compatibility migration exists; the db/migrations
|
||||
// copy was removed because identity-only fixtures apply that directory
|
||||
// without the business schema (see BUG-144).
|
||||
assert.match(migration.stdout, /applied 20260807030000_redeem_security\.sql/);
|
||||
assert.doesNotMatch(migration.stdout, /20260807020000_redeem_security/);
|
||||
|
||||
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `
|
||||
insert into identity.users (name, email, email_verified, email_verified_at)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import {
|
||||
formatPaymentOrders,
|
||||
@@ -18,11 +18,10 @@ const ordersRoute = readFileSync(
|
||||
new URL("../src/app/api/payment/orders/route.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const dbMigration = readFileSync(
|
||||
new URL("../db/migrations/20260807020000_redeem_security.sql", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const supabaseMigration = readFileSync(
|
||||
// Only the supabase compatibility migration exists: business-schema
|
||||
// migrations must not be duplicated into db/migrations (identity-only
|
||||
// fixtures apply that directory without the business schema; see BUG-144).
|
||||
const redeemSecurityMigration = readFileSync(
|
||||
new URL("../supabase/migrations/20260807030000_redeem_security.sql", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
@@ -170,7 +169,7 @@ test("payment orders summary is desensitized to the allowlist", () => {
|
||||
});
|
||||
|
||||
test("redeem security migration adds a hash-free failure-only audit table with least privilege", () => {
|
||||
const tableBlock = dbMigration.match(
|
||||
const tableBlock = redeemSecurityMigration.match(
|
||||
/create table if not exists public\.redemption_attempts[\s\S]*?\);/,
|
||||
);
|
||||
assert.ok(tableBlock, "redemption_attempts table must be created");
|
||||
@@ -180,26 +179,33 @@ test("redeem security migration adds a hash-free failure-only audit table with l
|
||||
// Failure-only: no success column, no plaintext code and no code hash.
|
||||
assert.doesNotMatch(tableBlock[0], /success|code|hash|mask/i);
|
||||
|
||||
assert.match(dbMigration, /drop function if exists public\.redeem_code\(text\);/);
|
||||
assert.match(dbMigration, /alter table public\.redemption_attempts enable row level security/);
|
||||
assert.match(dbMigration, /revoke all on table public\.redemption_attempts from anon, authenticated/);
|
||||
assert.match(dbMigration, /grant select on table public\.redemption_attempts to service_role/);
|
||||
assert.match(dbMigration, /pg_advisory_xact_lock/);
|
||||
assert.match(dbMigration, /interval '10 minutes'/);
|
||||
assert.match(dbMigration, /v_failed >= 5/);
|
||||
assert.match(redeemSecurityMigration, /drop function if exists public\.redeem_code\(text\);/);
|
||||
assert.match(redeemSecurityMigration, /alter table public\.redemption_attempts enable row level security/);
|
||||
assert.match(redeemSecurityMigration, /revoke all on table public\.redemption_attempts from anon, authenticated/);
|
||||
assert.match(redeemSecurityMigration, /grant select on table public\.redemption_attempts to service_role/);
|
||||
assert.match(redeemSecurityMigration, /pg_advisory_xact_lock/);
|
||||
assert.match(redeemSecurityMigration, /interval '10 minutes'/);
|
||||
assert.match(redeemSecurityMigration, /v_failed >= 5/);
|
||||
// Business failures insert a single-column audit row; success deletes all
|
||||
// of the account's attempts instead of inserting a permanent success row.
|
||||
assert.match(dbMigration, /insert into public\.redemption_attempts \(user_id\) values \(v_user_id\)/);
|
||||
assert.match(dbMigration, /delete from public\.redemption_attempts where user_id = v_user_id;/);
|
||||
assert.match(redeemSecurityMigration, /insert into public\.redemption_attempts \(user_id\) values \(v_user_id\)/);
|
||||
assert.match(redeemSecurityMigration, /delete from public\.redemption_attempts where user_id = v_user_id;/);
|
||||
// The redemption-code row lock is kept and the credit_transactions
|
||||
// unique constraint is preserved untouched (nothing is dropped).
|
||||
assert.match(dbMigration, /from public\.redemption_codes rc\s+where rc\.code_hash = p_code_hash\s+for update/);
|
||||
assert.doesNotMatch(dbMigration, /alter table public\.credit_transactions/);
|
||||
assert.match(dbMigration, /grant execute on function public\.redeem_code\(text\) to authenticated/);
|
||||
assert.match(dbMigration, /revoke all on function public\.redeem_code\(text\) from public, anon/);
|
||||
assert.match(redeemSecurityMigration, /from public\.redemption_codes rc\s+where rc\.code_hash = p_code_hash\s+for update/);
|
||||
assert.doesNotMatch(redeemSecurityMigration, /alter table public\.credit_transactions/);
|
||||
assert.match(redeemSecurityMigration, /grant execute on function public\.redeem_code\(text\) to authenticated/);
|
||||
assert.match(redeemSecurityMigration, /revoke all on function public\.redeem_code\(text\) from public, anon/);
|
||||
});
|
||||
|
||||
test("supabase compatibility migration is byte-identical to the primary migration", () => {
|
||||
assert.equal(supabaseMigration, dbMigration);
|
||||
assert.match(supabaseMigration, /create table if not exists public\.redemption_attempts/);
|
||||
test("db/migrations must not carry the business-schema redemption copy (BUG-144 guard)", () => {
|
||||
assert.equal(
|
||||
existsSync(
|
||||
new URL(
|
||||
"../db/migrations/20260807020000_redeem_security.sql",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user