mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-23 22:10:15 +00:00
fix: two-phase invite consumption to prevent registration race condition
This commit is contained in:
@@ -366,9 +366,20 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
if (msg.includes('unique') || msg.includes('constraint')) {
|
||||
return errorResponse('Email already registered', 409);
|
||||
}
|
||||
console.error('Registration failed after invite reservation:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const assigned = await storage.assignInviteUsedBy(inviteCode, user.id);
|
||||
if (!assigned) {
|
||||
console.warn('Invite used_by was not assigned after registration', { inviteCode, userId: user.id });
|
||||
}
|
||||
} catch (error) {
|
||||
// The invite is already consumed. Do not reactivate it after the user row exists.
|
||||
console.error('Invite used_by assignment failed after registration:', error);
|
||||
}
|
||||
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: 'user.register.invite',
|
||||
|
||||
@@ -117,23 +117,36 @@ export async function listInvites(db: D1Database, includeInactive: boolean = fal
|
||||
}
|
||||
|
||||
export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
|
||||
void userId;
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare(
|
||||
"UPDATE invites SET status = 'used', used_by = ?, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?"
|
||||
"UPDATE invites SET status = 'used', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?"
|
||||
)
|
||||
.bind(userId, now, code, now)
|
||||
.bind(now, code, now)
|
||||
.run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function assignInviteUsedBy(db: D1Database, code: string, userId: string): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare(
|
||||
"UPDATE invites SET used_by = ?, updated_at = ? WHERE code = ? AND status = 'used' AND used_by IS NULL"
|
||||
)
|
||||
.bind(userId, now, code)
|
||||
.run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function revertInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
|
||||
void userId;
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare(
|
||||
"UPDATE invites SET status = 'active', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'used' AND used_by = ?"
|
||||
"UPDATE invites SET status = 'active', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'used' AND used_by IS NULL"
|
||||
)
|
||||
.bind(now, code, userId)
|
||||
.bind(now, code)
|
||||
.run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, ' +
|
||||
'FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL)',
|
||||
'ALTER TABLE invites ADD COLUMN used_by TEXT',
|
||||
'CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type AuditLogListOptions,
|
||||
createAuditLog as createStoredAuditLog,
|
||||
clearAuditLogs as clearStoredAuditLogs,
|
||||
assignInviteUsedBy as assignStoredInviteUsedBy,
|
||||
createInvite as createStoredInvite,
|
||||
deleteAllInvites as deleteStoredInvites,
|
||||
getInvite as findStoredInvite,
|
||||
@@ -149,7 +150,7 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
||||
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
||||
// differs from config.schema.version.
|
||||
const STORAGE_SCHEMA_VERSION = '2026-06-22-push-notifications';
|
||||
const STORAGE_SCHEMA_VERSION = '2026-06-23-invite-used-by';
|
||||
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const;
|
||||
|
||||
// D1-backed storage.
|
||||
@@ -314,6 +315,10 @@ export class StorageService {
|
||||
return markStoredInviteUsed(this.db, code, userId);
|
||||
}
|
||||
|
||||
async assignInviteUsedBy(code: string, userId: string): Promise<boolean> {
|
||||
return assignStoredInviteUsedBy(this.db, code, userId);
|
||||
}
|
||||
|
||||
async revertInviteUsed(code: string, userId: string): Promise<boolean> {
|
||||
return revertStoredInviteUsed(this.db, code, userId);
|
||||
}
|
||||
|
||||
+6
-2
@@ -2046,11 +2046,15 @@ export default function App() {
|
||||
}),
|
||||
onSaveBackupSettings: async (masterPassword: string, settings: AdminBackupSettings) => {
|
||||
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||
return backupActions.saveSettings(hash, settings);
|
||||
const saved = await backupActions.saveSettings(hash, settings);
|
||||
queryClient.setQueryData(['admin-backup-settings', vaultCacheKey], saved);
|
||||
return saved;
|
||||
},
|
||||
onRunRemoteBackup: async (masterPassword: string, destinationId?: string | null) => {
|
||||
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||
return backupActions.runRemoteBackup(hash, destinationId);
|
||||
const result = await backupActions.runRemoteBackup(hash, destinationId);
|
||||
queryClient.setQueryData(['admin-backup-settings', vaultCacheKey], result.settings);
|
||||
return result;
|
||||
},
|
||||
onListRemoteBackups: backupActions.listRemoteBackups,
|
||||
onDownloadRemoteBackup: async (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => {
|
||||
|
||||
Reference in New Issue
Block a user