mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-24 06:20:14 +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')) {
|
if (msg.includes('unique') || msg.includes('constraint')) {
|
||||||
return errorResponse('Email already registered', 409);
|
return errorResponse('Email already registered', 409);
|
||||||
}
|
}
|
||||||
|
console.error('Registration failed after invite reservation:', error);
|
||||||
throw 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, {
|
await writeAuditEvent(storage, {
|
||||||
actorUserId: user.id,
|
actorUserId: user.id,
|
||||||
action: 'user.register.invite',
|
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> {
|
export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
|
||||||
|
void userId;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const result = await db
|
const result = await db
|
||||||
.prepare(
|
.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();
|
.run();
|
||||||
return (result.meta.changes ?? 0) > 0;
|
return (result.meta.changes ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revertInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
|
export async function revertInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
|
||||||
|
void userId;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const result = await db
|
const result = await db
|
||||||
.prepare(
|
.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();
|
.run();
|
||||||
return (result.meta.changes ?? 0) > 0;
|
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, ' +
|
'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 (created_by) REFERENCES users(id) ON DELETE CASCADE, ' +
|
||||||
'FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL)',
|
'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_status_expires ON invites(status, expires_at)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
|
'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
type AuditLogListOptions,
|
type AuditLogListOptions,
|
||||||
createAuditLog as createStoredAuditLog,
|
createAuditLog as createStoredAuditLog,
|
||||||
clearAuditLogs as clearStoredAuditLogs,
|
clearAuditLogs as clearStoredAuditLogs,
|
||||||
|
assignInviteUsedBy as assignStoredInviteUsedBy,
|
||||||
createInvite as createStoredInvite,
|
createInvite as createStoredInvite,
|
||||||
deleteAllInvites as deleteStoredInvites,
|
deleteAllInvites as deleteStoredInvites,
|
||||||
getInvite as findStoredInvite,
|
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
|
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
||||||
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
||||||
// differs from config.schema.version.
|
// 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;
|
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const;
|
||||||
|
|
||||||
// D1-backed storage.
|
// D1-backed storage.
|
||||||
@@ -314,6 +315,10 @@ export class StorageService {
|
|||||||
return markStoredInviteUsed(this.db, code, userId);
|
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> {
|
async revertInviteUsed(code: string, userId: string): Promise<boolean> {
|
||||||
return revertStoredInviteUsed(this.db, code, userId);
|
return revertStoredInviteUsed(this.db, code, userId);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -2046,11 +2046,15 @@ export default function App() {
|
|||||||
}),
|
}),
|
||||||
onSaveBackupSettings: async (masterPassword: string, settings: AdminBackupSettings) => {
|
onSaveBackupSettings: async (masterPassword: string, settings: AdminBackupSettings) => {
|
||||||
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
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) => {
|
onRunRemoteBackup: async (masterPassword: string, destinationId?: string | null) => {
|
||||||
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
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,
|
onListRemoteBackups: backupActions.listRemoteBackups,
|
||||||
onDownloadRemoteBackup: async (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => {
|
onDownloadRemoteBackup: async (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user