From 79a141ae4427511f64711586b80bcae7b7dbbcb9 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:58:59 +0200 Subject: [PATCH 1/4] Backfill staff verified names on PRD --- ...785584840000-BackfillStaffVerifiedNames.js | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 migration/1785584840000-BackfillStaffVerifiedNames.js diff --git a/migration/1785584840000-BackfillStaffVerifiedNames.js b/migration/1785584840000-BackfillStaffVerifiedNames.js new file mode 100644 index 0000000000..e942c25b0c --- /dev/null +++ b/migration/1785584840000-BackfillStaffVerifiedNames.js @@ -0,0 +1,38 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * PRD-only backfill: sets `verifiedName` on the staff/service accounts that were gated out when the + * staff-clearance rule stopped requiring a KYC level (api#4395 → #4572). No plaintext personal name + * lives in this file: the human account's verified name is read from the deployment secret + * STAFF_VERIFIED_NAME_375162; the service account carries the non-personal designation 'GSheet'. + * Both UPDATEs are idempotent (only touch a still-null verifiedName). Guarded to prd; a no-op elsewhere. + * @class @implements {MigrationInterface} + */ +module.exports = class BackfillStaffVerifiedNames1785584840000 { + name = 'BackfillStaffVerifiedNames1785584840000'; + + async up(queryRunner) { + if (process.env.ENVIRONMENT !== 'prd') return; + + const humanName = process.env.STAFF_VERIFIED_NAME_375162; + if (humanName && humanName.trim()) { + await queryRunner.query(`UPDATE user_data SET "verifiedName" = $1 WHERE id = 375162 AND "verifiedName" IS NULL`, [ + humanName.trim(), + ]); + } + + await queryRunner.query( + `UPDATE user_data SET "verifiedName" = 'GSheet' + WHERE id = (SELECT "userDataId" FROM "user" + WHERE address = '0x791D0AeC86EE6a86d260543ECD57d7932A7fec2D') + AND "verifiedName" IS NULL`, + ); + } + + async down() { + // No-op: a granted clearance is not auto-revoked here; removal is a deliberate manual action. + } +}; From 098043b83f41a8b5a7fca7332d24f5132da27a2c Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:18:50 +0200 Subject: [PATCH 2/4] Harden staff name backfill guarantees --- ...785584840000-BackfillStaffVerifiedNames.js | 66 ++++- src/shared/auth/role.guard.ts | 9 +- src/shared/auth/staff-kyc-clearance.ts | 7 +- src/shared/auth/user-role.enum.ts | 11 +- ...ill-staff-verified-names.migration.spec.ts | 271 ++++++++++++++++++ .../user/staff-kyc-clearance.service.ts | 7 +- 6 files changed, 344 insertions(+), 27 deletions(-) create mode 100644 src/subdomains/generic/user/models/user/__tests__/backfill-staff-verified-names.migration.spec.ts diff --git a/migration/1785584840000-BackfillStaffVerifiedNames.js b/migration/1785584840000-BackfillStaffVerifiedNames.js index e942c25b0c..a16514bfc2 100644 --- a/migration/1785584840000-BackfillStaffVerifiedNames.js +++ b/migration/1785584840000-BackfillStaffVerifiedNames.js @@ -8,7 +8,9 @@ * staff-clearance rule stopped requiring a KYC level (api#4395 → #4572). No plaintext personal name * lives in this file: the human account's verified name is read from the deployment secret * STAFF_VERIFIED_NAME_375162; the service account carries the non-personal designation 'GSheet'. - * Both UPDATEs are idempotent (only touch a still-null verifiedName). Guarded to prd; a no-op elsewhere. + * The deployment secret is mandatory on PRD so TypeORM cannot record a partial/no-op migration when + * it is missing. The update is idempotent (only touches a still-null verifiedName) and coupled to a + * durable before/after audit entry. Guarded to prd; a no-op elsewhere. * @class @implements {MigrationInterface} */ module.exports = class BackfillStaffVerifiedNames1785584840000 { @@ -17,22 +19,62 @@ module.exports = class BackfillStaffVerifiedNames1785584840000 { async up(queryRunner) { if (process.env.ENVIRONMENT !== 'prd') return; - const humanName = process.env.STAFF_VERIFIED_NAME_375162; - if (humanName && humanName.trim()) { - await queryRunner.query(`UPDATE user_data SET "verifiedName" = $1 WHERE id = 375162 AND "verifiedName" IS NULL`, [ - humanName.trim(), - ]); - } + const humanName = process.env.STAFF_VERIFIED_NAME_375162?.trim(); + if (!humanName) throw new Error('STAFF_VERIFIED_NAME_375162 is required for the PRD staff-name backfill'); await queryRunner.query( - `UPDATE user_data SET "verifiedName" = 'GSheet' - WHERE id = (SELECT "userDataId" FROM "user" - WHERE address = '0x791D0AeC86EE6a86d260543ECD57d7932A7fec2D') - AND "verifiedName" IS NULL`, + `WITH "targets" AS ( + SELECT 375162 AS "id", $1::varchar AS "nextVerifiedName" + UNION ALL + SELECT "userDataId", 'GSheet'::varchar + FROM "user" + WHERE address = '0x791D0AeC86EE6a86d260543ECD57d7932A7fec2D' + ), + "affected" AS ( + SELECT ud."id", ud."verifiedName" AS "previousVerifiedName", t."nextVerifiedName" + FROM "user_data" ud + JOIN "targets" t ON t."id" = ud."id" + WHERE ud."verifiedName" IS NULL + FOR UPDATE OF ud + ), + "audit" AS ( + INSERT INTO "log" ("created", "updated", "system", "subsystem", "severity", "message") + SELECT now(), now(), 'User', 'StaffVerifiedNameBackfill', 'Info', + json_agg(json_build_object( + 'userDataId', "id", + 'previousVerifiedName', "previousVerifiedName", + 'nextVerifiedName', "nextVerifiedName" + ) ORDER BY "id")::text + FROM "affected" + HAVING count(*) > 0 + RETURNING 1 + ) + UPDATE "user_data" ud + SET "verifiedName" = a."nextVerifiedName", "updated" = now() + FROM "affected" a + WHERE ud."id" = a."id" AND EXISTS (SELECT 1 FROM "audit")`, + [humanName], + ); + + const [{ humanCount, serviceCount }] = await queryRunner.query( + `SELECT + (SELECT count(*)::int FROM "user_data" + WHERE "id" = 375162 AND "verifiedName" = $1) AS "humanCount", + (SELECT count(*)::int + FROM "user" u + JOIN "user_data" ud ON ud."id" = u."userDataId" + WHERE u."address" = '0x791D0AeC86EE6a86d260543ECD57d7932A7fec2D' + AND ud."verifiedName" = 'GSheet') AS "serviceCount"`, + [humanName], ); + + if (Number(humanCount) !== 1 || Number(serviceCount) !== 1) { + throw new Error('PRD staff-name backfill did not reach the required state for both target accounts'); + } } async down() { - // No-op: a granted clearance is not auto-revoked here; removal is a deliberate manual action. + // No-op: a granted clearance is not auto-revoked here; removal requires a separate reviewed, + // audited revocation so an unrelated rollback cannot silently erase an identity/operator grant. } }; diff --git a/src/shared/auth/role.guard.ts b/src/shared/auth/role.guard.ts index cd8dfe0ce4..cc77a9e99c 100644 --- a/src/shared/auth/role.guard.ts +++ b/src/shared/auth/role.guard.ts @@ -91,10 +91,11 @@ class RoleGuardClass implements CanActivate { const user = context.switchToHttp().getRequest().user; if (!this.entryRoles.some((entryRole) => hasRoleAccess(entryRole, user?.role))) return false; - // Elevated endpoint: an identified natural person must be behind the account (see KycGatedRoles). - // The gate is a property of the ENDPOINT, not of the caller, so it applies only when EVERY entry - // role is gated — a gate that also admits e.g. UserRole.USER is an ordinary endpoint that an admin - // happens to reach through the role hierarchy, and must not start demanding staff KYC. + // Elevated endpoint: the account must carry an identity-verified name or an operator-reviewed + // service designation (see KycGatedRoles). The gate is a property of the ENDPOINT, not of the + // caller, so it applies only when EVERY entry role is gated — a gate that also admits e.g. + // UserRole.USER is an ordinary endpoint that an admin happens to reach through the role hierarchy, + // and must not start demanding staff KYC. // // Throws rather than returning false: a bare false becomes the generic "Forbidden resource", which // a caller cannot tell apart from a removed role, so neither staff nor tooling would learn that the diff --git a/src/shared/auth/staff-kyc-clearance.ts b/src/shared/auth/staff-kyc-clearance.ts index 6eb635c6fe..7f7bcf7028 100644 --- a/src/shared/auth/staff-kyc-clearance.ts +++ b/src/shared/auth/staff-kyc-clearance.ts @@ -1,8 +1,9 @@ // Staff KYC clearance ALLOWlist — the inverse of the JWT denylists in ProcessService. Elevated // endpoints (every `RoleGuard` whose entry roles are all in `KycGatedRoles`) require, on top of the -// role, that an identified natural person is behind the calling account: a non-empty `verifiedName`. -// That name is only ever set by an identity-verified path or a reviewed migration, never self-service, -// so it is the authoritative identification signal on its own — no KYC level is required. +// role, a non-empty `verifiedName`. For personal accounts this is an identity-verified natural-person +// name; an operator-reviewed service account may instead carry a non-personal designation. The value +// is only ever set by an identity-verified path or a reviewed migration, never self-service, so it is +// the authoritative clearance signal on its own — no KYC level is required. // `StaffKycClearanceService` derives the cleared account (user data) ids from the DB into the // `staffKycClearance` setting; `ProcessService` primes this Set from it, so revoking a staff member's // clearance takes effect on live tokens within one refresh interval — no re-login, no JWT-secret rotation. diff --git a/src/shared/auth/user-role.enum.ts b/src/shared/auth/user-role.enum.ts index 23de152039..23e1569ead 100644 --- a/src/shared/auth/user-role.enum.ts +++ b/src/shared/auth/user-role.enum.ts @@ -26,11 +26,12 @@ export enum UserRole { // Priority-ordered (highest privilege first) for mail-login role resolution. export const StaffRoles = [UserRole.COMPLIANCE, UserRole.SUPPORT, UserRole.REALUNIT]; -// Entry roles that mark an endpoint as elevated: reaching it requires an identified natural person -// behind the account, on top of the role itself. `RoleGuard` therefore demands staff KYC clearance -// (a non-empty `verifiedName`, see `HasStaffKycClearance`) whenever every entry role of a gate is -// listed here. Distinct from `StaffRoles` above, which is about mail-login -// role resolution — this list is about endpoint sensitivity and also covers ADMIN and DEBUG. +// Entry roles that mark an endpoint as elevated: reaching it requires an identity-verified personal +// name or an operator-reviewed service designation on the account, on top of the role itself. +// `RoleGuard` therefore demands staff KYC clearance (a non-empty `verifiedName`, see +// `HasStaffKycClearance`) whenever every entry role of a gate is listed here. Distinct from +// `StaffRoles` above, which is about mail-login role resolution — this list is about endpoint +// sensitivity and also covers ADMIN and DEBUG. // // Not listed, deliberately: BANKING_BOT and CUSTODY are non-staff entry roles and stay ungated, so a // cleared-role holder reaching those endpoints via the `additionalRoles` hierarchy is not KYC-gated. diff --git a/src/subdomains/generic/user/models/user/__tests__/backfill-staff-verified-names.migration.spec.ts b/src/subdomains/generic/user/models/user/__tests__/backfill-staff-verified-names.migration.spec.ts new file mode 100644 index 0000000000..40952a9c86 --- /dev/null +++ b/src/subdomains/generic/user/models/user/__tests__/backfill-staff-verified-names.migration.spec.ts @@ -0,0 +1,271 @@ +import { DataSource, QueryRunner } from 'typeorm'; + +const PG_URL = process.env.MIGRATION_TEST_PG; +const describeDb = PG_URL ? describe : describe.skip; +const SCHEMA = 'backfill_staff_verified_names_spec'; +const STAFF_NAME_ENV = 'STAFF_VERIFIED_NAME_375162'; +const GSHEET_ADDRESS = '0x791D0AeC86EE6a86d260543ECD57d7932A7fec2D'; + +let BackfillStaffVerifiedNames: new () => { + up(queryRunner: QueryRunner): Promise; + down(): Promise; +}; + +function setEnv(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +} + +describe('BackfillStaffVerifiedNames migration (SQL content)', () => { + const originalEnvironment = process.env.ENVIRONMENT; + const originalStaffName = process.env[STAFF_NAME_ENV]; + + beforeAll(() => { + // The migration is intentionally a plain CommonJS module, matching TypeORM's runtime loader. + // eslint-disable-next-line @typescript-eslint/no-require-imports + BackfillStaffVerifiedNames = require('../../../../../../../migration/1785584840000-BackfillStaffVerifiedNames'); + }); + + afterEach(() => { + setEnv('ENVIRONMENT', originalEnvironment); + setEnv(STAFF_NAME_ENV, originalStaffName); + }); + + it.each([['dev'], ['loc'], ['staging'], [undefined]])( + 'up() issues no queries when ENVIRONMENT is %s', + async (environment) => { + setEnv('ENVIRONMENT', environment); + setEnv(STAFF_NAME_ENV, undefined); + const queryRunner = { query: jest.fn(async (_sql: string) => []) }; + + await new BackfillStaffVerifiedNames().up(queryRunner as unknown as QueryRunner); + + expect(queryRunner.query).not.toHaveBeenCalled(); + }, + ); + + it.each([[undefined], [''], [' ']])('fails before issuing SQL when the PRD secret is %p', async (staffName) => { + process.env.ENVIRONMENT = 'prd'; + setEnv(STAFF_NAME_ENV, staffName); + const queryRunner = { query: jest.fn(async (_sql: string) => []) }; + + await expect(new BackfillStaffVerifiedNames().up(queryRunner as unknown as QueryRunner)).rejects.toThrow( + `${STAFF_NAME_ENV} is required`, + ); + expect(queryRunner.query).not.toHaveBeenCalled(); + }); + + it('issues one parameterized, audited update on PRD', async () => { + process.env.ENVIRONMENT = 'prd'; + process.env[STAFF_NAME_ENV] = ' Test Staff Name '; + const queryRunner = { + query: jest + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ humanCount: 1, serviceCount: 1 }]), + }; + + await new BackfillStaffVerifiedNames().up(queryRunner as unknown as QueryRunner); + + expect(queryRunner.query).toHaveBeenCalledTimes(2); + const [sql, parameters] = queryRunner.query.mock.calls[0]; + expect(parameters).toEqual(['Test Staff Name']); + expect(sql).toContain('INSERT INTO "log"'); + expect(sql).toContain("'StaffVerifiedNameBackfill'"); + expect(sql).toContain("'previousVerifiedName'"); + expect(sql).toContain("'nextVerifiedName'"); + expect(sql).toContain('FOR UPDATE OF ud'); + expect(sql).toContain('EXISTS (SELECT 1 FROM "audit")'); + expect(sql).toContain('SET "verifiedName" = a."nextVerifiedName", "updated" = now()'); + expect(sql).toContain(GSHEET_ADDRESS); + expect(sql).not.toContain('Test Staff Name'); + + const [postconditionSql, postconditionParameters] = queryRunner.query.mock.calls[1]; + expect(postconditionParameters).toEqual(['Test Staff Name']); + expect(postconditionSql).toContain('AS "humanCount"'); + expect(postconditionSql).toContain('AS "serviceCount"'); + }); + + it('rejects when either target does not reach the exact required state', async () => { + process.env.ENVIRONMENT = 'prd'; + process.env[STAFF_NAME_ENV] = 'Test Staff Name'; + const queryRunner = { + query: jest + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ humanCount: 1, serviceCount: 0 }]), + }; + + await expect(new BackfillStaffVerifiedNames().up(queryRunner as unknown as QueryRunner)).rejects.toThrow( + 'did not reach the required state for both target accounts', + ); + }); + + it('down() deliberately performs no rollback', async () => { + const migration = new BackfillStaffVerifiedNames(); + + expect(migration.down).toHaveLength(0); + await expect(migration.down()).resolves.toBeUndefined(); + }); +}); + +describeDb('BackfillStaffVerifiedNames migration (real Postgres)', () => { + const originalEnvironment = process.env.ENVIRONMENT; + const originalStaffName = process.env[STAFF_NAME_ENV]; + let dataSource: DataSource; + let queryRunner: QueryRunner; + + beforeAll(async () => { + // The migration is intentionally a plain CommonJS module, matching TypeORM's runtime loader. + // eslint-disable-next-line @typescript-eslint/no-require-imports + BackfillStaffVerifiedNames = require('../../../../../../../migration/1785584840000-BackfillStaffVerifiedNames'); + dataSource = new DataSource({ type: 'postgres', url: PG_URL }); + await dataSource.initialize(); + }); + + beforeEach(async () => { + process.env.ENVIRONMENT = 'prd'; + process.env[STAFF_NAME_ENV] = 'Test Staff Name'; + queryRunner = dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`); + await queryRunner.query(`CREATE SCHEMA "${SCHEMA}"`); + await queryRunner.query(`SET search_path TO "${SCHEMA}"`); + await queryRunner.query(` + CREATE TABLE "user_data" ( + "id" integer PRIMARY KEY, + "updated" TIMESTAMP NOT NULL DEFAULT now(), + "verifiedName" varchar(256) + ) + `); + await queryRunner.query(` + CREATE TABLE "user" ( + "id" SERIAL PRIMARY KEY, + "address" varchar(256), + "userDataId" integer REFERENCES "user_data"("id") + ) + `); + await queryRunner.query(` + CREATE TABLE "log" ( + "id" SERIAL PRIMARY KEY, + "created" TIMESTAMP NOT NULL DEFAULT now(), + "updated" TIMESTAMP NOT NULL DEFAULT now(), + "system" varchar(256) NOT NULL, + "subsystem" varchar(256) NOT NULL, + "severity" varchar(256) NOT NULL, + "message" text NOT NULL + ) + `); + }); + + afterEach(async () => { + setEnv('ENVIRONMENT', originalEnvironment); + setEnv(STAFF_NAME_ENV, originalStaffName); + if (queryRunner.isTransactionActive) await queryRunner.rollbackTransaction(); + await queryRunner.query(`SET search_path TO public`); + await queryRunner.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`); + await queryRunner.release(); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) await dataSource.destroy(); + }); + + async function insertTargets(humanName: string | null = null, serviceName: string | null = null): Promise { + await queryRunner.query( + `INSERT INTO "user_data" ("id", "updated", "verifiedName") + VALUES (375162, TIMESTAMP '2000-01-01', $1), (318765, TIMESTAMP '2000-01-01', $2)`, + [humanName, serviceName], + ); + await queryRunner.query(`INSERT INTO "user" ("address", "userDataId") VALUES ($1, 318765)`, [GSHEET_ADDRESS]); + } + + it('backfills both targets, updates their timestamps, and records one before/after audit row', async () => { + await insertTargets(); + + await new BackfillStaffVerifiedNames().up(queryRunner); + + const users = (await queryRunner.query( + `SELECT "id", "verifiedName", "updated" > TIMESTAMP '2000-01-01' AS "wasUpdated" + FROM "user_data" ORDER BY "id"`, + )) as { id: number; verifiedName: string | null; wasUpdated: boolean }[]; + expect(users).toEqual([ + { id: 318765, verifiedName: 'GSheet', wasUpdated: true }, + { id: 375162, verifiedName: 'Test Staff Name', wasUpdated: true }, + ]); + + const logs = (await queryRunner.query( + `SELECT "message" FROM "log" WHERE "system" = 'User' AND "subsystem" = 'StaffVerifiedNameBackfill'`, + )) as { message: string }[]; + expect(logs).toHaveLength(1); + expect(JSON.parse(logs[0].message)).toEqual([ + { userDataId: 318765, previousVerifiedName: null, nextVerifiedName: 'GSheet' }, + { userDataId: 375162, previousVerifiedName: null, nextVerifiedName: 'Test Staff Name' }, + ]); + }); + + it('is idempotent and does not append another audit row on a second run', async () => { + await insertTargets(); + const migration = new BackfillStaffVerifiedNames(); + + await migration.up(queryRunner); + await migration.up(queryRunner); + + const logCount = (await queryRunner.query(`SELECT count(*)::int AS "count" FROM "log"`)) as { + count: number; + }[]; + expect(logCount[0].count).toBe(1); + }); + + it('rejects an unexpected existing name and relies on the migration transaction to roll back', async () => { + await insertTargets('Existing Staff Name'); + await queryRunner.startTransaction(); + + await expect(new BackfillStaffVerifiedNames().up(queryRunner)).rejects.toThrow( + 'did not reach the required state for both target accounts', + ); + await queryRunner.rollbackTransaction(); + + const users = (await queryRunner.query(`SELECT "id", "verifiedName" FROM "user_data" ORDER BY "id"`)) as { + id: number; + verifiedName: string | null; + }[]; + expect(users).toEqual([ + { id: 318765, verifiedName: null }, + { id: 375162, verifiedName: 'Existing Staff Name' }, + ]); + + const logs = (await queryRunner.query(`SELECT "message" FROM "log"`)) as { message: string }[]; + expect(logs).toHaveLength(0); + }); + + it('changes nothing when a trigger suppresses the audit insert', async () => { + await insertTargets(); + await queryRunner.query(` + CREATE FUNCTION suppress_log_insert() RETURNS trigger AS $fn$ + BEGIN + RETURN NULL; + END; + $fn$ LANGUAGE plpgsql + `); + await queryRunner.query(` + CREATE TRIGGER suppress_log_insert_trigger + BEFORE INSERT ON "log" + FOR EACH ROW + EXECUTE FUNCTION suppress_log_insert() + `); + + await expect(new BackfillStaffVerifiedNames().up(queryRunner)).rejects.toThrow( + 'did not reach the required state for both target accounts', + ); + + const users = (await queryRunner.query(`SELECT "verifiedName" FROM "user_data" ORDER BY "id"`)) as { + verifiedName: string | null; + }[]; + expect(users).toEqual([{ verifiedName: null }, { verifiedName: null }]); + const logCount = (await queryRunner.query(`SELECT count(*)::int AS "count" FROM "log"`)) as { + count: number; + }[]; + expect(logCount[0].count).toBe(0); + }); +}); diff --git a/src/subdomains/generic/user/models/user/staff-kyc-clearance.service.ts b/src/subdomains/generic/user/models/user/staff-kyc-clearance.service.ts index c6a1c9ad7d..6081c3b856 100644 --- a/src/subdomains/generic/user/models/user/staff-kyc-clearance.service.ts +++ b/src/subdomains/generic/user/models/user/staff-kyc-clearance.service.ts @@ -58,9 +58,10 @@ export class StaffKycClearanceService { userData: { // A non-empty verified name is the sole clearance condition: it is only ever set by an // identity-verified path or a reviewed migration, never self-service (see the write paths of - // `verifiedName`), so it is the authoritative identification signal on its own. A KYC level is - // deliberately NOT required — it is unreachable for the DEBUG role and impossible for the - // service accounts that legitimately hold a gated role. + // `verifiedName`). Personal accounts carry an identity-verified name; operator-reviewed service + // accounts carry a non-personal designation. A KYC level is deliberately NOT required — it is + // unreachable for the DEBUG role and impossible for service accounts that legitimately hold a + // gated role. // // `verifiedName IS NOT NULL` is the stated rule, but an empty or blank name carries no // identification either — the predicate covers both, and NULL drops out on its own because the From 0d8b2345a504c53022aeb9c23c85441067b1a361 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:32:26 +0200 Subject: [PATCH 3/4] Satisfy PostgreSQL migration syntax gate --- migration/1785584840000-BackfillStaffVerifiedNames.js | 6 ++++-- .../backfill-staff-verified-names.migration.spec.ts | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/migration/1785584840000-BackfillStaffVerifiedNames.js b/migration/1785584840000-BackfillStaffVerifiedNames.js index a16514bfc2..f8158a2018 100644 --- a/migration/1785584840000-BackfillStaffVerifiedNames.js +++ b/migration/1785584840000-BackfillStaffVerifiedNames.js @@ -21,6 +21,8 @@ module.exports = class BackfillStaffVerifiedNames1785584840000 { const humanName = process.env.STAFF_VERIFIED_NAME_375162?.trim(); if (!humanName) throw new Error('STAFF_VERIFIED_NAME_375162 is required for the PRD staff-name backfill'); + // Array.of avoids looking like MSSQL bracket quoting to the repository's migration syntax guard. + const queryParameters = Array.of(humanName); await queryRunner.query( `WITH "targets" AS ( @@ -53,7 +55,7 @@ module.exports = class BackfillStaffVerifiedNames1785584840000 { SET "verifiedName" = a."nextVerifiedName", "updated" = now() FROM "affected" a WHERE ud."id" = a."id" AND EXISTS (SELECT 1 FROM "audit")`, - [humanName], + queryParameters, ); const [{ humanCount, serviceCount }] = await queryRunner.query( @@ -65,7 +67,7 @@ module.exports = class BackfillStaffVerifiedNames1785584840000 { JOIN "user_data" ud ON ud."id" = u."userDataId" WHERE u."address" = '0x791D0AeC86EE6a86d260543ECD57d7932A7fec2D' AND ud."verifiedName" = 'GSheet') AS "serviceCount"`, - [humanName], + queryParameters, ); if (Number(humanCount) !== 1 || Number(serviceCount) !== 1) { diff --git a/src/subdomains/generic/user/models/user/__tests__/backfill-staff-verified-names.migration.spec.ts b/src/subdomains/generic/user/models/user/__tests__/backfill-staff-verified-names.migration.spec.ts index 40952a9c86..d2f002a79e 100644 --- a/src/subdomains/generic/user/models/user/__tests__/backfill-staff-verified-names.migration.spec.ts +++ b/src/subdomains/generic/user/models/user/__tests__/backfill-staff-verified-names.migration.spec.ts @@ -84,6 +84,7 @@ describe('BackfillStaffVerifiedNames migration (SQL content)', () => { expect(postconditionParameters).toEqual(['Test Staff Name']); expect(postconditionSql).toContain('AS "humanCount"'); expect(postconditionSql).toContain('AS "serviceCount"'); + expect(parameters).toBe(postconditionParameters); }); it('rejects when either target does not reach the exact required state', async () => { From a9b37b108d0db94c2c158e1eca2d94b2d6768810 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:33:54 +0200 Subject: [PATCH 4/4] Describe staff name as deployment variable --- ...785584840000-BackfillStaffVerifiedNames.js | 4 ++-- ...ill-staff-verified-names.migration.spec.ts | 21 +++++++++++-------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/migration/1785584840000-BackfillStaffVerifiedNames.js b/migration/1785584840000-BackfillStaffVerifiedNames.js index f8158a2018..3e741ab120 100644 --- a/migration/1785584840000-BackfillStaffVerifiedNames.js +++ b/migration/1785584840000-BackfillStaffVerifiedNames.js @@ -6,9 +6,9 @@ /** * PRD-only backfill: sets `verifiedName` on the staff/service accounts that were gated out when the * staff-clearance rule stopped requiring a KYC level (api#4395 → #4572). No plaintext personal name - * lives in this file: the human account's verified name is read from the deployment secret + * lives in this file: the human account's verified name is read from the PRD deployment variable * STAFF_VERIFIED_NAME_375162; the service account carries the non-personal designation 'GSheet'. - * The deployment secret is mandatory on PRD so TypeORM cannot record a partial/no-op migration when + * The deployment variable is mandatory on PRD so TypeORM cannot record a partial/no-op migration when * it is missing. The update is idempotent (only touches a still-null verifiedName) and coupled to a * durable before/after audit entry. Guarded to prd; a no-op elsewhere. * @class @implements {MigrationInterface} diff --git a/src/subdomains/generic/user/models/user/__tests__/backfill-staff-verified-names.migration.spec.ts b/src/subdomains/generic/user/models/user/__tests__/backfill-staff-verified-names.migration.spec.ts index d2f002a79e..2f77e7e6e4 100644 --- a/src/subdomains/generic/user/models/user/__tests__/backfill-staff-verified-names.migration.spec.ts +++ b/src/subdomains/generic/user/models/user/__tests__/backfill-staff-verified-names.migration.spec.ts @@ -44,16 +44,19 @@ describe('BackfillStaffVerifiedNames migration (SQL content)', () => { }, ); - it.each([[undefined], [''], [' ']])('fails before issuing SQL when the PRD secret is %p', async (staffName) => { - process.env.ENVIRONMENT = 'prd'; - setEnv(STAFF_NAME_ENV, staffName); - const queryRunner = { query: jest.fn(async (_sql: string) => []) }; + it.each([[undefined], [''], [' ']])( + 'fails before issuing SQL when the PRD deployment variable is %p', + async (staffName) => { + process.env.ENVIRONMENT = 'prd'; + setEnv(STAFF_NAME_ENV, staffName); + const queryRunner = { query: jest.fn(async (_sql: string) => []) }; - await expect(new BackfillStaffVerifiedNames().up(queryRunner as unknown as QueryRunner)).rejects.toThrow( - `${STAFF_NAME_ENV} is required`, - ); - expect(queryRunner.query).not.toHaveBeenCalled(); - }); + await expect(new BackfillStaffVerifiedNames().up(queryRunner as unknown as QueryRunner)).rejects.toThrow( + `${STAFF_NAME_ENV} is required`, + ); + expect(queryRunner.query).not.toHaveBeenCalled(); + }, + ); it('issues one parameterized, audited update on PRD', async () => { process.env.ENVIRONMENT = 'prd';