diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_15.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_15.sql
new file mode 100644
index 000000000..23829241d
--- /dev/null
+++ b/src/backend/clients/database/migrations/mysql/mysql_mig_15.sql
@@ -0,0 +1,47 @@
+-- Copyright (C) 2024-present Puter Technologies Inc.
+--
+-- This file is part of Puter.
+--
+-- Puter is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as published
+-- by the Free Software Foundation, either version 3 of the License, or
+-- (at your option) any later version.
+--
+-- This program is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+
+-- Card fingerprint column. Mirrors SQLite migration 0060. `card_fingerprint`
+-- is the Stripe card fingerprint (stable per card number) recorded when a user
+-- clears card verification — the card sibling of `phone`, indexed like it so
+-- admin tooling can find the accounts that verified with a given card. The card
+-- itself never touches our DB, only Stripe's fingerprint for it.
+--
+-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
+-- leaves it resident for later migrations); the index add is guarded against
+-- INFORMATION_SCHEMA.STATISTICS so the directory replays safely.
+
+CALL _puter_add_col('user', 'card_fingerprint', '`card_fingerprint` varchar(128) DEFAULT NULL');
+
+DROP PROCEDURE IF EXISTS _puter_add_user_card_fingerprint_index;
+DELIMITER //
+CREATE PROCEDURE _puter_add_user_card_fingerprint_index()
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'user'
+ AND INDEX_NAME = 'idx_user_card_fingerprint'
+ ) THEN
+ ALTER TABLE `user` ADD INDEX `idx_user_card_fingerprint` (`card_fingerprint`);
+ END IF;
+END//
+DELIMITER ;
+
+CALL _puter_add_user_card_fingerprint_index();
+
+DROP PROCEDURE IF EXISTS _puter_add_user_card_fingerprint_index;
diff --git a/src/backend/clients/database/migrations/postgres/postgres_mig_4.sql b/src/backend/clients/database/migrations/postgres/postgres_mig_4.sql
new file mode 100644
index 000000000..4f1c867e8
--- /dev/null
+++ b/src/backend/clients/database/migrations/postgres/postgres_mig_4.sql
@@ -0,0 +1,26 @@
+-- Copyright (C) 2024-present Puter Technologies Inc.
+--
+-- This file is part of Puter.
+--
+-- Puter is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as published
+-- by the Free Software Foundation, either version 3 of the License, or
+-- (at your option) any later version.
+--
+-- This program is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+
+-- Card fingerprint column. Mirrors SQLite migration 0060. `card_fingerprint`
+-- is the Stripe card fingerprint (stable per card number) recorded when a user
+-- clears card verification — the card sibling of `phone`, indexed like it so
+-- admin tooling can find the accounts that verified with a given card. The card
+-- itself never touches our DB, only Stripe's fingerprint for it.
+-- Idempotent via IF NOT EXISTS.
+
+ALTER TABLE "user" ADD COLUMN IF NOT EXISTS card_fingerprint varchar(128);
+CREATE INDEX IF NOT EXISTS idx_user_card_fingerprint ON "user" (card_fingerprint);
diff --git a/src/backend/clients/database/migrations/sqlite/0060_add_card_fingerprint.sql b/src/backend/clients/database/migrations/sqlite/0060_add_card_fingerprint.sql
new file mode 100644
index 000000000..4c4834925
--- /dev/null
+++ b/src/backend/clients/database/migrations/sqlite/0060_add_card_fingerprint.sql
@@ -0,0 +1,25 @@
+-- Copyright (C) 2024-present Puter Technologies Inc.
+--
+-- This file is part of Puter.
+--
+-- Puter is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as published
+-- by the Free Software Foundation, either version 3 of the License, or
+-- (at your option) any later version.
+--
+-- This program is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+
+-- Card fingerprint column. `card_fingerprint` is the Stripe card fingerprint
+-- (stable per card number) recorded when a user clears card verification — the
+-- card sibling of `phone`. Indexed like `phone` so admin tooling can find the
+-- accounts that verified with a given card. The card itself never touches our
+-- DB, only Stripe's fingerprint for it. Server-only (not on the whoami
+-- allowlist), so it never reaches clients.
+ALTER TABLE `user` ADD COLUMN `card_fingerprint` varchar(128) DEFAULT NULL;
+CREATE INDEX IF NOT EXISTS idx_user_card_fingerprint ON `user` (`card_fingerprint`);
diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts
index 1e0f36b98..8cea64bb9 100644
--- a/src/backend/controllers/auth/AuthController.test.ts
+++ b/src/backend/controllers/auth/AuthController.test.ts
@@ -4086,6 +4086,77 @@ describe('AuthController.handleSignup additional branches', () => {
expect(claimed!.password).not.toBeNull();
});
+ it('claim clears stale phone/card gates on the placeholder when the decision no longer requires them', async () => {
+ // Placeholder seeded with both gates already set. A benign claim
+ // (no validate override → no requirements) must reset them rather
+ // than silently inheriting the stale requirement.
+ const targetEmail = `pseudo_${uniq()}@test.local`;
+ const placeholder = await server.stores.user.create({
+ username: `placeholder_${uniq()}`,
+ uuid: uuidv4(),
+ password: null,
+ email: targetEmail,
+ clean_email: targetEmail,
+ email_confirmed: 0,
+ requires_phone_verification: 1,
+ requires_card_verification: 1,
+ } as never);
+
+ const res = makeRes();
+ await controller.handleSignup(
+ makeReq({
+ username: `claim_${uniq()}`,
+ email: targetEmail,
+ password: 'correct-horse-battery',
+ }),
+ res,
+ );
+ expect(isCompleteLoginResponse(res.body)).toBe(true);
+
+ const claimed = await server.stores.user.getById(placeholder.id, {
+ force: true,
+ });
+ expect(claimed!.requires_phone_verification).toBe(false);
+ expect(claimed!.requires_card_verification).toBe(false);
+ });
+
+ it('claim carries the phone/card gates when the decision requires them', async () => {
+ const targetEmail = `pseudo_${uniq()}@test.local`;
+ const placeholder = await server.stores.user.create({
+ username: `placeholder_${uniq()}`,
+ uuid: uuidv4(),
+ password: null,
+ email: targetEmail,
+ clean_email: targetEmail,
+ email_confirmed: 0,
+ } as never);
+
+ await withSignupValidateOverride(
+ (event) => {
+ event.requires_phone_verification = true;
+ event.requires_card_verification = true;
+ },
+ async () => {
+ const res = makeRes();
+ await controller.handleSignup(
+ makeReq({
+ username: `claim_${uniq()}`,
+ email: targetEmail,
+ password: 'correct-horse-battery',
+ }),
+ res,
+ );
+ expect(isCompleteLoginResponse(res.body)).toBe(true);
+ },
+ );
+
+ const claimed = await server.stores.user.getById(placeholder.id, {
+ force: true,
+ });
+ expect(claimed!.requires_phone_verification).toBe(true);
+ expect(claimed!.requires_card_verification).toBe(true);
+ });
+
it('extension hook can require email confirmation via requires_email_confirmation=true', async () => {
await withSignupValidateOverride(
(event) => {
diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts
index 96647f86a..386a1af22 100644
--- a/src/backend/controllers/auth/AuthController.ts
+++ b/src/backend/controllers/auth/AuthController.ts
@@ -690,24 +690,13 @@ export class AuthController extends PuterController {
email_confirm_code,
email_confirm_token,
email_confirmed: 0,
- // Pseudo claims always require email confirmation — the
- // validate hook can only tighten, not loosen, so `1`
- // stays hardcoded here.
requires_email_confirmation: 1,
last_activity_ts: signupSqlTs,
- // Record the v2 reputation from this claim (skip if unset so we
- // don't clobber an existing score with a placeholder).
...(validateEvent.reputation != null
? { reputation: validateEvent.reputation }
: {}),
- // Carry the phone gate onto the claimed account when required.
- ...(force_phone_verification
- ? { requires_phone_verification: 1 }
- : {}),
- // Likewise for the card gate.
- ...(force_card_verification
- ? { requires_card_verification: 1 }
- : {}),
+ requires_phone_verification: force_phone_verification ? 1 : 0,
+ requires_card_verification: force_card_verification ? 1 : 0,
});
// Move from temp group to regular user group
diff --git a/src/backend/services/auth/OIDCService.ts b/src/backend/services/auth/OIDCService.ts
index 59603ff39..c4fac5626 100644
--- a/src/backend/services/auth/OIDCService.ts
+++ b/src/backend/services/auth/OIDCService.ts
@@ -482,6 +482,9 @@ export class OIDCService extends PuterService {
allow: true,
no_temp_user: false,
requires_email_confirmation: false,
+ requires_phone_verification: false,
+ requires_card_verification: false,
+ reputation: null as number | null,
message: null as string | null,
code: null as string | null,
};
@@ -532,6 +535,17 @@ export class OIDCService extends PuterService {
};
}
+ const cfg = this.config as {
+ always_require_phone_verification?: boolean;
+ always_require_card_verification?: boolean;
+ };
+ const force_phone_verification =
+ Boolean(validateEvent.requires_phone_verification) ||
+ Boolean(cfg.always_require_phone_verification);
+ const force_card_verification =
+ Boolean(validateEvent.requires_card_verification) ||
+ Boolean(cfg.always_require_card_verification);
+
const created = await this.stores.user.create({
username,
uuid: uuidv4(),
@@ -539,7 +553,14 @@ export class OIDCService extends PuterService {
email,
clean_email: cleanEmail(email),
free_storage: this.config.storage_capacity ?? null,
+ // Email is provider-verified, so the email step is always skipped;
+ // the phone/card gates still apply when the harness flagged them.
requires_email_confirmation: false,
+ requires_phone_verification: force_phone_verification,
+ requires_card_verification: force_card_verification,
+ ...(validateEvent.reputation != null
+ ? { reputation: validateEvent.reputation }
+ : {}),
audit_metadata: {
ip: clientIp,
ip_fwd: proxyIpChain,