mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: add master password hint functionality
- Updated user model to include masterPasswordHint. - Modified sync handler to return masterPasswordHint. - Implemented password hint retrieval in public API. - Enhanced user profile management to allow updating of password hint. - Added UI components for displaying and editing password hint. - Updated localization files for new password hint strings. - Improved rate limiting for sensitive public requests. - Adjusted database schema to accommodate master password hint.
This commit is contained in:
@@ -44,6 +44,12 @@
|
||||
// Sensitive public/auth request budget per IP per minute.
|
||||
// 敏感公开/认证接口每 IP 每分钟请求配额。
|
||||
sensitivePublicRequestsPerMinute: 30,
|
||||
// Password hint lookup budget per IP per minute.
|
||||
// 密码提示查询接口每 IP 每分钟请求配额。
|
||||
passwordHintRequestsPerMinute: 1,
|
||||
// Password hint lookup budget per IP per hour.
|
||||
// 密码提示查询接口每 IP 每小时请求配额。
|
||||
passwordHintRequestsPerHour: 3,
|
||||
// Register endpoint budget per IP per minute.
|
||||
// 注册接口每 IP 每分钟请求配额。
|
||||
registerRequestsPerMinute: 5,
|
||||
|
||||
+113
-1
@@ -62,6 +62,11 @@ function normalizeRecoveryCodeInput(input: string): string {
|
||||
return String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
|
||||
}
|
||||
|
||||
function normalizeMasterPasswordHint(input: string | null | undefined): string | null {
|
||||
const normalized = String(input || '').trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret) return 'missing';
|
||||
@@ -80,7 +85,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
|
||||
premium: true,
|
||||
premiumFromOrganization: false,
|
||||
usesKeyConnector: false,
|
||||
masterPasswordHint: null,
|
||||
masterPasswordHint: user.masterPasswordHint,
|
||||
culture: 'en-US',
|
||||
twoFactorEnabled: !!user.totpSecret,
|
||||
key: user.key,
|
||||
@@ -125,6 +130,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
kdfMemory?: number;
|
||||
kdfParallelism?: number;
|
||||
inviteCode?: string;
|
||||
masterPasswordHint?: string;
|
||||
keys?: {
|
||||
publicKey?: string;
|
||||
encryptedPrivateKey?: string;
|
||||
@@ -144,6 +150,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
const privateKey = body.keys?.encryptedPrivateKey;
|
||||
const publicKey = body.keys?.publicKey;
|
||||
const inviteCode = (body.inviteCode || '').trim();
|
||||
const masterPasswordHint = normalizeMasterPasswordHint(body.masterPasswordHint);
|
||||
|
||||
if (!email || !masterPasswordHash || !key) {
|
||||
return errorResponse('Email, masterPasswordHash, and key are required', 400);
|
||||
@@ -160,6 +167,9 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
if (!looksLikeEncString(privateKey)) {
|
||||
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
|
||||
}
|
||||
if (masterPasswordHint && masterPasswordHint.length > 120) {
|
||||
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
|
||||
}
|
||||
|
||||
const kdfErr = validateKdfParams(body.kdf, body.kdfIterations, body.kdfMemory, body.kdfParallelism);
|
||||
if (kdfErr) return errorResponse(kdfErr, 400);
|
||||
@@ -172,6 +182,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
id: generateUUID(),
|
||||
email,
|
||||
name: name || email,
|
||||
masterPasswordHint,
|
||||
masterPasswordHash: serverHash,
|
||||
key,
|
||||
privateKey,
|
||||
@@ -242,6 +253,80 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
return jsonResponse({ success: true, role: user.role }, 200);
|
||||
}
|
||||
|
||||
// POST /api/accounts/password-hint
|
||||
export async function handleGetPasswordHint(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const clientIdentifier = getClientIdentifier(request);
|
||||
if (!clientIdentifier) {
|
||||
return errorResponse('Client IP is required', 403);
|
||||
}
|
||||
|
||||
let body: { email?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const email = String(body.email || '').trim().toLowerCase();
|
||||
if (!email) {
|
||||
return errorResponse('Email is required', 400);
|
||||
}
|
||||
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
const minuteBudget = await rateLimit.consumeBudgetWithWindow(
|
||||
`${clientIdentifier}:password-hint`,
|
||||
LIMITS.rateLimit.passwordHintRequestsPerMinute,
|
||||
60
|
||||
);
|
||||
if (!minuteBudget.allowed) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Too many requests',
|
||||
error_description: `Rate limit exceeded. Try again in ${minuteBudget.retryAfterSeconds || 60} seconds.`,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': String(minuteBudget.retryAfterSeconds || 60),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const hourlyBudget = await rateLimit.consumeBudgetWithWindow(
|
||||
`${clientIdentifier}:password-hint-hour`,
|
||||
LIMITS.rateLimit.passwordHintRequestsPerHour,
|
||||
60 * 60
|
||||
);
|
||||
if (!hourlyBudget.allowed) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Too many requests',
|
||||
error_description: `Rate limit exceeded. Try again in ${hourlyBudget.retryAfterSeconds || 3600} seconds.`,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': String(hourlyBudget.retryAfterSeconds || 3600),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const user = await storage.getUser(email);
|
||||
const hint = user?.status === 'active' ? normalizeMasterPasswordHint(user.masterPasswordHint) : null;
|
||||
return jsonResponse({
|
||||
object: 'passwordHint',
|
||||
hasHint: !!hint,
|
||||
masterPasswordHint: hint,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/accounts/profile
|
||||
export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
void request;
|
||||
@@ -251,6 +336,33 @@ export async function handleGetProfile(request: Request, env: Env, userId: strin
|
||||
return jsonResponse(toProfile(user, env));
|
||||
}
|
||||
|
||||
// PUT /api/accounts/profile
|
||||
export async function handleUpdateProfile(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
let body: {
|
||||
masterPasswordHint?: string | null;
|
||||
};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const masterPasswordHint = normalizeMasterPasswordHint(body.masterPasswordHint);
|
||||
if (masterPasswordHint && masterPasswordHint.length > 120) {
|
||||
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
|
||||
}
|
||||
|
||||
user.masterPasswordHint = masterPasswordHint;
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
|
||||
return jsonResponse(toProfile(user, env));
|
||||
}
|
||||
|
||||
// POST /api/accounts/keys
|
||||
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
@@ -135,7 +135,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
premium: true,
|
||||
premiumFromOrganization: false,
|
||||
usesKeyConnector: false,
|
||||
masterPasswordHint: null,
|
||||
masterPasswordHint: user.masterPasswordHint,
|
||||
culture: 'en-US',
|
||||
twoFactorEnabled: !!user.totpSecret,
|
||||
key: user.key,
|
||||
|
||||
+9
-1
@@ -32,6 +32,10 @@ function injectBootstrapIntoHtml(html: string, env: Env): string {
|
||||
return `${script}${html}`;
|
||||
}
|
||||
|
||||
function responseStatusCannotHaveBody(status: number): boolean {
|
||||
return status === 101 || status === 204 || status === 205 || status === 304;
|
||||
}
|
||||
|
||||
async function maybeServeAsset(request: Request, env: Env): Promise<Response | null> {
|
||||
if (!env.ASSETS) return null;
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') return null;
|
||||
@@ -40,7 +44,11 @@ async function maybeServeAsset(request: Request, env: Env): Promise<Response | n
|
||||
|
||||
const assetResponse = await env.ASSETS.fetch(request);
|
||||
const contentType = String(assetResponse.headers.get('Content-Type') || '').toLowerCase();
|
||||
if (request.method === 'GET' && contentType.includes('text/html')) {
|
||||
if (
|
||||
request.method === 'GET' &&
|
||||
contentType.includes('text/html') &&
|
||||
!responseStatusCannotHaveBody(assetResponse.status)
|
||||
) {
|
||||
const html = await assetResponse.text();
|
||||
const injected = injectBootstrapIntoHtml(html, env);
|
||||
return new Response(injected, {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Env, User } from './types';
|
||||
import { errorResponse, jsonResponse } from './utils/response';
|
||||
import {
|
||||
handleGetProfile,
|
||||
handleUpdateProfile,
|
||||
handleSetKeys,
|
||||
handleGetRevisionDate,
|
||||
handleVerifyPassword,
|
||||
@@ -79,6 +80,7 @@ export async function handleAuthenticatedRoute(
|
||||
|
||||
if (path === '/api/accounts/profile') {
|
||||
if (method === 'GET') return handleGetProfile(request, env, userId);
|
||||
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { handleKnownDevice } from './handlers/devices';
|
||||
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
|
||||
import {
|
||||
handleRegister,
|
||||
handleGetPasswordHint,
|
||||
handleRecoverTwoFactor,
|
||||
} from './handlers/accounts';
|
||||
import { handlePublicDownloadAttachment } from './handlers/attachments';
|
||||
@@ -252,6 +253,18 @@ export async function handlePublicRoute(
|
||||
return handleRecoverTwoFactor(request, env);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/password-hint' && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
if (!isSameOriginWriteRequest(request)) {
|
||||
return new Response(JSON.stringify({ error: 'Forbidden origin' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return handleGetPasswordHint(request, env);
|
||||
}
|
||||
|
||||
if ((path === '/config' || path === '/api/config') && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
|
||||
@@ -370,7 +370,7 @@ export async function buildBackupArchive(env: Env, date: Date = new Date()): Pro
|
||||
const encoder = new TextEncoder();
|
||||
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows, sendRows] = await Promise.all([
|
||||
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
|
||||
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
||||
|
||||
@@ -270,7 +270,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db']): P
|
||||
...buildInsertStatements(
|
||||
db,
|
||||
'users',
|
||||
['id', 'email', 'name', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
|
||||
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
|
||||
payload.users || []
|
||||
),
|
||||
...buildInsertStatements(db, 'user_revisions', ['user_id', 'revision_date'], payload.user_revisions || [], true),
|
||||
|
||||
@@ -184,6 +184,14 @@ export class RateLimitService {
|
||||
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||
return this.consumeFixedWindowBudget(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS);
|
||||
}
|
||||
|
||||
async consumeBudgetWithWindow(
|
||||
identifier: string,
|
||||
maxRequests: number,
|
||||
windowSeconds: number
|
||||
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||
return this.consumeFixedWindowBudget(identifier, maxRequests, windowSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
function parseIpv4Octets(input: string): number[] | null {
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
// Any new table/column/index must be added to both places together.
|
||||
const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'CREATE TABLE IF NOT EXISTS users (' +
|
||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
|
||||
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
||||
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
|
||||
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
||||
|
||||
@@ -7,6 +7,7 @@ function mapUserRow(row: any): User {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
masterPasswordHint: row.master_password_hint ?? null,
|
||||
masterPasswordHash: row.master_password_hash,
|
||||
key: row.key,
|
||||
privateKey: row.private_key,
|
||||
@@ -28,7 +29,7 @@ function mapUserRow(row: any): User {
|
||||
export async function getUser(db: D1Database, email: string): Promise<User | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE email = ?'
|
||||
'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE email = ?'
|
||||
)
|
||||
.bind(email.toLowerCase())
|
||||
.first<any>();
|
||||
@@ -39,7 +40,7 @@ export async function getUser(db: D1Database, email: string): Promise<User | nul
|
||||
export async function getUserById(db: D1Database, id: string): Promise<User | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE id = ?'
|
||||
'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE id = ?'
|
||||
)
|
||||
.bind(id)
|
||||
.first<any>();
|
||||
@@ -55,7 +56,7 @@ export async function getUserCount(db: D1Database): Promise<number> {
|
||||
export async function getAllUsers(db: D1Database): Promise<User[]> {
|
||||
const res = await db
|
||||
.prepare(
|
||||
'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'
|
||||
'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'
|
||||
)
|
||||
.all<any>();
|
||||
return (res.results || []).map((row) => mapUserRow(row));
|
||||
@@ -64,10 +65,10 @@ export async function getAllUsers(db: D1Database): Promise<User[]> {
|
||||
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'email=excluded.email, name=excluded.name, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
|
||||
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
|
||||
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at'
|
||||
);
|
||||
await safeBind(
|
||||
@@ -75,6 +76,7 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
|
||||
user.id,
|
||||
email,
|
||||
user.name,
|
||||
user.masterPasswordHint,
|
||||
user.masterPasswordHash,
|
||||
user.key,
|
||||
user.privateKey,
|
||||
@@ -100,8 +102,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User)
|
||||
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
||||
);
|
||||
const result = await safeBind(
|
||||
@@ -109,6 +111,7 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
|
||||
user.id,
|
||||
email,
|
||||
user.name,
|
||||
user.masterPasswordHint,
|
||||
user.masterPasswordHash,
|
||||
user.key,
|
||||
user.privateKey,
|
||||
|
||||
@@ -102,7 +102,7 @@ import {
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||
const STORAGE_SCHEMA_VERSION = '2026-03-18.1';
|
||||
const STORAGE_SCHEMA_VERSION = '2026-03-19.1';
|
||||
|
||||
// D1-backed storage.
|
||||
// Contract:
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
masterPasswordHint: string | null;
|
||||
masterPasswordHash: string;
|
||||
key: string;
|
||||
privateKey: string | null;
|
||||
|
||||
Reference in New Issue
Block a user