Add Bitwarden push relay support

This commit is contained in:
shuaiplus
2026-06-22 22:09:38 +08:00
parent 9a21504f40
commit 79ed7c9f85
12 changed files with 492 additions and 31 deletions
+1 -1
View File
@@ -37,7 +37,7 @@
| **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** | | **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** |
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** | | **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** |
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** | | **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 | | 实时同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV | | 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
| Send | ✅ | ✅ | 支持文本与文件 Send | | Send | ✅ | ✅ | 支持文本与文件 Send |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** | | 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
+1 -1
View File
@@ -40,7 +40,7 @@
| **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** | | **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** |
| **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** | | **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** |
| **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** | | **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** |
| Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients | | Real-time sync | ✅ | ✅ | Web, browser extension, desktop, and mobile clients stay in sync in real time |
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV | | Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
| Send | ✅ | ✅ | Supports both text and file Sends | | Send | ✅ | ✅ | Supports both text and file Sends |
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** | | Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
+3
View File
@@ -176,6 +176,8 @@ CREATE TABLE IF NOT EXISTS devices (
encrypted_user_key TEXT, encrypted_user_key TEXT,
encrypted_public_key TEXT, encrypted_public_key TEXT,
encrypted_private_key TEXT, encrypted_private_key TEXT,
push_uuid TEXT,
push_token TEXT,
banned INTEGER NOT NULL DEFAULT 0, banned INTEGER NOT NULL DEFAULT 0,
banned_at TEXT, banned_at TEXT,
device_note TEXT, device_note TEXT,
@@ -187,6 +189,7 @@ CREATE TABLE IF NOT EXISTS devices (
); );
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at); CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at); CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at);
CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token);
CREATE TABLE IF NOT EXISTS auth_requests ( CREATE TABLE IF NOT EXISTS auth_requests (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
+11
View File
@@ -1,5 +1,6 @@
import { DurableObject, waitUntil } from 'cloudflare:workers'; import { DurableObject, waitUntil } from 'cloudflare:workers';
import type { Env } from '../types'; import type { Env } from '../types';
import { notifyMobilePush } from '../services/push-relay';
const SIGNALR_RECORD_SEPARATOR = 0x1e; const SIGNALR_RECORD_SEPARATOR = 0x1e;
const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]); const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]);
@@ -767,6 +768,16 @@ async function notifyUserUpdate(
}, },
}), }),
}); });
await notifyMobilePush(env, {
userId,
updateType,
revisionDate,
contextId,
payload: payloadOverride || {
UserId: userId,
Date: revisionDate,
},
});
} catch (error) { } catch (error) {
console.error('Failed to broadcast realtime notification:', error); console.error('Failed to broadcast realtime notification:', error);
} }
+40 -9
View File
@@ -3,6 +3,7 @@ import { Env } from '../types';
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub'; import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { registerMobilePushDevice, unregisterMobilePushDevice } from '../services/push-relay';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { errorResponse, jsonResponse } from '../utils/response'; import { errorResponse, jsonResponse } from '../utils/response';
import { readKnownDeviceProbe } from '../utils/device'; import { readKnownDeviceProbe } from '../utils/device';
@@ -223,6 +224,8 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
encryptedUserKey: null, encryptedUserKey: null,
encryptedPublicKey: null, encryptedPublicKey: null,
encryptedPrivateKey: null, encryptedPrivateKey: null,
pushUuid: null,
pushToken: null,
devicePendingAuthRequest: null, devicePendingAuthRequest: null,
deviceNote: null, deviceNote: null,
lastSeenAt: null, lastSeenAt: null,
@@ -325,10 +328,12 @@ export async function handleDeleteDevice(
if (!normalized) return errorResponse('Invalid device identifier', 400); if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized); await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await storage.deleteRefreshTokensByDevice(userId, normalized); await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized); const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) { if (deleted) {
await unregisterMobilePushDevice(env, device?.pushUuid);
AuthService.invalidateDeviceCache(userId, normalized); AuthService.invalidateDeviceCache(userId, normalized);
notifyUserLogout(env, userId, normalized); notifyUserLogout(env, userId, normalized);
} }
@@ -537,10 +542,12 @@ export async function handleDeactivateDevice(
if (!normalized) return errorResponse('Invalid device identifier', 400); if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized); await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await storage.deleteRefreshTokensByDevice(userId, normalized); await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized); const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) { if (deleted) {
await unregisterMobilePushDevice(env, device?.pushUuid);
AuthService.invalidateDeviceCache(userId, normalized); AuthService.invalidateDeviceCache(userId, normalized);
notifyUserLogout(env, userId, normalized); notifyUserLogout(env, userId, normalized);
} }
@@ -557,18 +564,36 @@ export async function handleDeactivateDevice(
} }
// PUT /api/devices/identifier/{deviceIdentifier}/token // PUT /api/devices/identifier/{deviceIdentifier}/token
// Bitwarden mobile reports push token updates to this endpoint. // Bitwarden mobile reports APNs/FCM push token updates to this endpoint.
// NodeWarden does not implement push notifications, so accept and no-op.
export async function handleUpdateDeviceToken( export async function handleUpdateDeviceToken(
request: Request, request: Request,
env: Env, env: Env,
userId: string, userId: string,
deviceIdentifier: string deviceIdentifier: string
): Promise<Response> { ): Promise<Response> {
void request; const normalized = normalizeIdentifier(deviceIdentifier);
void env; if (!normalized) return errorResponse('Invalid device identifier', 400);
void userId;
void deviceIdentifier; const body = await readJsonBody(request);
const pushToken = String(body?.pushToken ?? body?.PushToken ?? '').trim();
if (!pushToken) return errorResponse('Invalid push token', 400);
const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
if (!device) return errorResponse('Device not found', 404);
const pushUuid = device.pushUuid || generateUUID();
const updated = await storage.updateDevicePushToken(userId, normalized, pushUuid, pushToken);
if (updated) {
await registerMobilePushDevice(env, {
userId,
deviceIdentifier: normalized,
type: device.type,
pushUuid,
pushToken,
});
}
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
} }
@@ -594,9 +619,15 @@ export async function handleClearDeviceToken(
deviceIdentifier: string deviceIdentifier: string
): Promise<Response> { ): Promise<Response> {
void request; void request;
void env; const normalized = normalizeIdentifier(deviceIdentifier);
void userId; if (!normalized) return errorResponse('Invalid device identifier', 400);
void deviceIdentifier;
const storage = new StorageService(env.DB);
const cleared = await storage.clearDevicePushToken(userId, normalized);
if (cleared?.pushUuid) {
await unregisterMobilePushDevice(env, cleared.pushUuid);
}
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
} }
+42
View File
@@ -10,6 +10,7 @@ import { readAuthRequestDeviceInfo } from '../utils/device';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { issueSendAccessToken } from './sends'; import { issueSendAccessToken } from './sends';
import { registerMobilePushDevice } from '../services/push-relay';
import { import {
buildAccountKeys, buildAccountKeys,
buildUserDecryptionOptions, buildUserDecryptionOptions,
@@ -50,6 +51,44 @@ async function resolveDeviceSession(
return { identifier: deviceInfo.deviceIdentifier, sessionStamp }; return { identifier: deviceInfo.deviceIdentifier, sessionStamp };
} }
function readDevicePushToken(body: Record<string, string>): string {
return String(readBodyValue(body, ['devicePushToken', 'DevicePushToken', 'device_push_token']) || '').trim();
}
async function persistIdentityDevicePushToken(
env: Env,
storage: StorageService,
userId: string,
deviceSession: { identifier: string; sessionStamp: string } | null,
deviceType: number,
body: Record<string, string>
): Promise<void> {
if (!deviceSession) return;
const pushToken = readDevicePushToken(body);
if (!pushToken) return;
const device = await storage.getDevice(userId, deviceSession.identifier);
if (!device) return;
const pushUuid = device.pushUuid || generateUUID();
await storage.updateDevicePushToken(userId, deviceSession.identifier, pushUuid, pushToken);
const registered = await registerMobilePushDevice(env, {
userId,
deviceIdentifier: deviceSession.identifier,
type: device.type || deviceType,
pushUuid,
pushToken,
});
console.info('Mobile push token updated from identity token request', {
userId,
deviceIdentifier: deviceSession.identifier,
deviceType: device.type || deviceType,
pushUuid,
pushTokenLength: pushToken.length,
relayRegistered: registered,
});
}
function shouldUseWebSession(request: Request): boolean { function shouldUseWebSession(request: Request): boolean {
return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1'; return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1';
} }
@@ -414,6 +453,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
deviceInfo.deviceType, deviceInfo.deviceType,
deviceSession.sessionStamp deviceSession.sessionStamp
); );
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
} }
// Successful login - clear failed attempts // Successful login - clear failed attempts
@@ -536,6 +576,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
deviceInfo.deviceType, deviceInfo.deviceType,
deviceSession.sessionStamp deviceSession.sessionStamp
); );
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
} }
await rateLimit.clearLoginAttempts(loginIdentifier); await rateLimit.clearLoginAttempts(loginIdentifier);
@@ -664,6 +705,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
deviceInfo.deviceType, deviceInfo.deviceType,
deviceSession.sessionStamp deviceSession.sessionStamp
); );
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
} }
// Successful login - clear failed attempts // Successful login - clear failed attempts
+19 -15
View File
@@ -20,6 +20,10 @@ import {
handleClearDeviceToken, handleClearDeviceToken,
} from './handlers/devices'; } from './handlers/devices';
function devicesPath(pattern: string): RegExp {
return new RegExp(`^/(?:api/)?devices${pattern}$`, 'i');
}
export async function handleAuthenticatedDeviceRoute( export async function handleAuthenticatedDeviceRoute(
request: Request, request: Request,
env: Env, env: Env,
@@ -27,31 +31,31 @@ export async function handleAuthenticatedDeviceRoute(
path: string, path: string,
method: string method: string
): Promise<Response | null> { ): Promise<Response | null> {
if (path === '/api/devices') { if (path === '/api/devices' || path === '/devices') {
if (method === 'GET') return handleGetDevices(request, env, userId); if (method === 'GET') return handleGetDevices(request, env, userId);
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId); if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
return null; return null;
} }
if (path === '/api/devices/authorized') { if (path === '/api/devices/authorized' || path === '/devices/authorized') {
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId); if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId); if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
return null; return null;
} }
const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i); const authorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)'));
if (authorizedDeviceMatch && method === 'DELETE') { if (authorizedDeviceMatch && method === 'DELETE') {
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]); const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier); return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
} }
const permanentAuthorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)\/permanent$/i); const permanentAuthorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)/permanent'));
if (permanentAuthorizedDeviceMatch && method === 'POST') { if (permanentAuthorizedDeviceMatch && method === 'POST') {
const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]); const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]);
return handleTrustDevicePermanently(request, env, userId, deviceIdentifier); return handleTrustDevicePermanently(request, env, userId, deviceIdentifier);
} }
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i); const deleteDeviceMatch = path.match(devicesPath('/([^/]+)'));
if (deleteDeviceMatch && method === 'GET') { if (deleteDeviceMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]); const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
return handleGetDevice(request, env, userId, deviceIdentifier); return handleGetDevice(request, env, userId, deviceIdentifier);
@@ -61,59 +65,59 @@ export async function handleAuthenticatedDeviceRoute(
return handleDeleteDevice(request, env, userId, deviceIdentifier); return handleDeleteDevice(request, env, userId, deviceIdentifier);
} }
const updateDeviceNameMatch = path.match(/^\/api\/devices\/([^/]+)\/name$/i); const updateDeviceNameMatch = path.match(devicesPath('/([^/]+)/name'));
if (updateDeviceNameMatch && method === 'PUT') { if (updateDeviceNameMatch && method === 'PUT') {
const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]); const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]);
return handleUpdateDeviceName(request, env, userId, deviceIdentifier); return handleUpdateDeviceName(request, env, userId, deviceIdentifier);
} }
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i); const identifierMatch = path.match(devicesPath('/identifier/([^/]+)'));
if (identifierMatch && method === 'GET') { if (identifierMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(identifierMatch[1]); const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier); return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
} }
const deviceKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/keys$/i) || path.match(/^\/api\/devices\/identifier\/([^/]+)\/keys$/i); const deviceKeysMatch = path.match(devicesPath('/([^/]+)/keys')) || path.match(devicesPath('/identifier/([^/]+)/keys'));
if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) { if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]); const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]);
return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier); return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier);
} }
const identifierTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i); const identifierTokenMatch = path.match(devicesPath('/identifier/([^/]+)/token'));
if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) { if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]); const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]);
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier); return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
} }
const identifierWebPushMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/web-push-auth$/i); const identifierWebPushMatch = path.match(devicesPath('/identifier/([^/]+)/web-push-auth'));
if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) { if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]); const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]);
return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier); return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier);
} }
const identifierClearTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i); const identifierClearTokenMatch = path.match(devicesPath('/identifier/([^/]+)/clear-token'));
if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) { if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]); const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]);
return handleClearDeviceToken(request, env, userId, deviceIdentifier); return handleClearDeviceToken(request, env, userId, deviceIdentifier);
} }
const identifierRetrieveKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/retrieve-keys$/i); const identifierRetrieveKeysMatch = path.match(devicesPath('/([^/]+)/retrieve-keys'));
if (identifierRetrieveKeysMatch && method === 'POST') { if (identifierRetrieveKeysMatch && method === 'POST') {
const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]); const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]);
return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier); return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier);
} }
const identifierDeactivateMatch = path.match(/^\/api\/devices\/([^/]+)\/deactivate$/i); const identifierDeactivateMatch = path.match(devicesPath('/([^/]+)/deactivate'));
if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) { if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) {
const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]); const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]);
return handleDeactivateDevice(request, env, userId, deviceIdentifier); return handleDeactivateDevice(request, env, userId, deviceIdentifier);
} }
if (path === '/api/devices/update-trust' && method === 'POST') { if ((path === '/api/devices/update-trust' || path === '/devices/update-trust') && method === 'POST') {
return handleUpdateDeviceTrust(request, env, userId); return handleUpdateDeviceTrust(request, env, userId);
} }
if (path === '/api/devices/untrust' && method === 'POST') { if ((path === '/api/devices/untrust' || path === '/devices/untrust') && method === 'POST') {
return handleUntrustDevices(request, env, userId); return handleUntrustDevices(request, env, userId);
} }
+275
View File
@@ -0,0 +1,275 @@
import type { Env } from '../types';
import {
setConfigValue as saveConfigValue,
} from './storage-config-repo';
const PUSH_RELAY_URI = 'https://push.bitwarden.com';
const PUSH_IDENTITY_URI = 'https://identity.bitwarden.com';
const INSTALLATIONS_URI = 'https://api.bitwarden.com/installations';
const PUSH_INSTALLATION_ID_KEY = 'push.installation.id';
const PUSH_INSTALLATION_KEY_KEY = 'push.installation.key';
const PUSH_REQUEST_TIMEOUT_MS = 5000;
interface CachedPushAccessToken {
token: string;
expiresAt: number;
}
let cachedPushAccessToken: CachedPushAccessToken | null = null;
async function fetchPushEndpoint(url: string, init: RequestInit, errorMessage: string): Promise<Response | null> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PUSH_REQUEST_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} catch (error) {
console.error(errorMessage, error);
return null;
} finally {
clearTimeout(timeout);
}
}
function randomInstallationEmail(): string {
const bytes = new Uint8Array(10);
crypto.getRandomValues(bytes);
const localPart = Array.from(bytes, (byte) => (byte % 36).toString(36)).join('');
return `${localPart}@nodewarden.app`;
}
async function getConfigKeyPresence(db: D1Database, key: string): Promise<string | null> {
const row = await db.prepare('SELECT value FROM config WHERE key = ? LIMIT 1').bind(key).first<{ value: string }>();
return typeof row?.value === 'string' ? row.value : null;
}
async function getPushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> {
const [id, key] = await Promise.all([
getConfigKeyPresence(db, PUSH_INSTALLATION_ID_KEY),
getConfigKeyPresence(db, PUSH_INSTALLATION_KEY_KEY),
]);
const normalizedId = String(id || '').trim();
const normalizedKey = String(key || '').trim();
return normalizedId && normalizedKey ? { id: normalizedId, key: normalizedKey } : null;
}
export async function ensurePushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> {
const existing = await getPushInstallationCredentials(db);
if (existing) return existing;
const response = await fetchPushEndpoint(
INSTALLATIONS_URI,
{
method: 'POST',
headers: {
accept: 'application/json',
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
'cache-control': 'no-cache',
'content-type': 'application/json',
origin: 'https://bitwarden.com',
pragma: 'no-cache',
priority: 'u=1, i',
referer: 'https://bitwarden.com/host/',
'sec-ch-ua': '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-site',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
},
body: JSON.stringify({
formName: 'request_host',
url: '/host/',
locale: 'zh-CN',
email: randomInstallationEmail(),
region: 'us',
}),
},
'Failed to request Bitwarden push installation:'
);
if (!response) return null;
if (!response.ok) {
console.error('Failed to request Bitwarden push installation:', response.status, await response.text().catch(() => ''));
return null;
}
const body = (await response.json().catch(() => null)) as { id?: string; key?: string; enabled?: boolean } | null;
const id = String(body?.id || '').trim();
const key = String(body?.key || '').trim();
if (!id || !key) {
console.error('Bitwarden push installation response did not include id/key');
return null;
}
await Promise.all([
saveConfigValue(db, PUSH_INSTALLATION_ID_KEY, id),
saveConfigValue(db, PUSH_INSTALLATION_KEY_KEY, key),
]);
return { id, key };
}
async function getPushAccessToken(env: Env): Promise<string | null> {
const credentials = await ensurePushInstallationCredentials(env.DB);
if (!credentials) return null;
const now = Date.now();
if (cachedPushAccessToken && cachedPushAccessToken.expiresAt > now + 30_000) {
return cachedPushAccessToken.token;
}
const params = new URLSearchParams({
grant_type: 'client_credentials',
scope: 'api.push',
client_id: `installation.${credentials.id}`,
client_secret: credentials.key,
});
const response = await fetchPushEndpoint(
`${PUSH_IDENTITY_URI}/connect/token`,
{
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
},
'Failed to get Bitwarden push relay token:'
);
if (!response) return null;
if (!response.ok) {
console.error('Failed to get Bitwarden push relay token:', response.status, await response.text().catch(() => ''));
return null;
}
const body = (await response.json().catch(() => null)) as { access_token?: string; expires_in?: number } | null;
const token = String(body?.access_token || '').trim();
if (!token) {
console.error('Bitwarden push relay token response did not include an access_token');
return null;
}
const expiresInSeconds = Math.max(60, Number(body?.expires_in || 3600));
cachedPushAccessToken = {
token,
expiresAt: now + Math.floor(expiresInSeconds * 500),
};
return token;
}
async function postToPushRelay(env: Env, path: string, body?: unknown): Promise<boolean> {
const token = await getPushAccessToken(env);
if (!token) return false;
const response = await fetchPushEndpoint(
`${PUSH_RELAY_URI}${path}`,
{
method: 'POST',
headers: {
accept: 'application/json',
authorization: `Bearer ${token}`,
...(body === undefined ? {} : { 'content-type': 'application/json' }),
},
body: body === undefined ? undefined : JSON.stringify(body),
},
`Bitwarden push relay request failed: ${path}`
);
if (!response) return false;
if (!response.ok) {
console.error('Bitwarden push relay request failed:', path, response.status, await response.text().catch(() => ''));
return false;
}
return true;
}
function mobilePayloadFromSignalR(updateType: number, userId: string, revisionDate: string, payload: Record<string, unknown> | null | undefined): Record<string, unknown> {
const source = payload || {};
const id = source.Id ?? source.id;
const organizationId = source.OrganizationId ?? source.organizationId ?? null;
const collectionIds = source.CollectionIds ?? source.collectionIds ?? null;
if (id != null) {
return {
id,
userId: source.UserId ?? source.userId ?? userId,
organizationId,
collectionIds,
revisionDate: source.RevisionDate ?? source.revisionDate ?? revisionDate,
};
}
return {
userId: source.UserId ?? source.userId ?? userId,
date: source.Date ?? source.date ?? revisionDate,
};
}
export async function registerMobilePushDevice(
env: Env,
input: {
userId: string;
deviceIdentifier: string;
type: number;
pushUuid: string;
pushToken: string;
}
): Promise<boolean> {
const credentials = await ensurePushInstallationCredentials(env.DB);
if (!credentials) return false;
return postToPushRelay(env, '/push/register', {
deviceId: input.pushUuid,
pushToken: input.pushToken,
userId: input.userId,
type: input.type,
identifier: input.deviceIdentifier,
installationId: credentials.id,
});
}
export async function unregisterMobilePushDevice(env: Env, pushUuid: string | null | undefined): Promise<boolean> {
const normalized = String(pushUuid || '').trim();
if (!normalized) return false;
return postToPushRelay(env, `/push/delete/${encodeURIComponent(normalized)}`);
}
export async function notifyMobilePush(
env: Env,
input: {
userId: string;
updateType: number;
revisionDate: string;
contextId: string | null;
payload: Record<string, unknown> | null | undefined;
}
): Promise<void> {
const hasPushDevice = await env.DB
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND push_token IS NOT NULL AND push_token <> ? LIMIT 1')
.bind(input.userId, '')
.first<{ '1': number }>();
if (!hasPushDevice) return;
let actingPushUuid: string | null = null;
if (input.contextId) {
const row = await env.DB
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
.bind(input.userId, input.contextId)
.first<{ push_uuid: string | null }>();
actingPushUuid = row?.push_uuid ?? null;
}
await postToPushRelay(env, '/push/send', {
userId: input.userId,
organizationId: null,
deviceId: actingPushUuid,
identifier: input.contextId,
type: input.updateType,
payload: mobilePayloadFromSignalR(input.updateType, input.userId, input.revisionDate, input.payload),
clientType: null,
installationId: null,
});
}
+66 -3
View File
@@ -1,4 +1,5 @@
import type { Device, TrustedDeviceTokenSummary, User } from '../types'; import type { Device, TrustedDeviceTokenSummary, User } from '../types';
import { generateUUID } from '../utils/uuid';
type GetUserByEmail = (email: string) => Promise<User | null>; type GetUserByEmail = (email: string) => Promise<User | null>;
type TrustedTokenKeyFn = (token: string) => Promise<string>; type TrustedTokenKeyFn = (token: string) => Promise<string>;
@@ -14,6 +15,8 @@ function mapDeviceRow(row: any): Device {
encryptedUserKey: row.encrypted_user_key ?? null, encryptedUserKey: row.encrypted_user_key ?? null,
encryptedPublicKey: row.encrypted_public_key ?? null, encryptedPublicKey: row.encrypted_public_key ?? null,
encryptedPrivateKey: row.encrypted_private_key ?? null, encryptedPrivateKey: row.encrypted_private_key ?? null,
pushUuid: row.push_uuid ?? null,
pushToken: row.push_token ?? null,
lastSeenAt: row.last_seen_at ?? null, lastSeenAt: row.last_seen_at ?? null,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
@@ -38,13 +41,15 @@ export async function upsertDevice(
const existingDevice = await getDeviceById(userId, deviceIdentifier); const existingDevice = await getDeviceById(userId, deviceIdentifier);
const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || ''; const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || '';
const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim(); const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim();
const effectivePushUuid = String(existingDevice?.pushUuid || '').trim() || generateUUID();
await db await db
.prepare( .prepare(
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' + 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' + 'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' + 'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' + 'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' + 'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
'push_uuid=COALESCE(push_uuid, excluded.push_uuid), ' +
'last_seen_at=excluded.last_seen_at, ' + 'last_seen_at=excluded.last_seen_at, ' +
'updated_at=excluded.updated_at' 'updated_at=excluded.updated_at'
) )
@@ -57,6 +62,7 @@ export async function upsertDevice(
keys?.encryptedUserKey ?? null, keys?.encryptedUserKey ?? null,
keys?.encryptedPublicKey ?? null, keys?.encryptedPublicKey ?? null,
keys?.encryptedPrivateKey ?? null, keys?.encryptedPrivateKey ?? null,
effectivePushUuid,
existingDevice?.deviceNote ?? null, existingDevice?.deviceNote ?? null,
now, now,
now, now,
@@ -166,7 +172,7 @@ export async function isKnownDeviceByEmail(
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> { export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
const res = await db const res = await db
.prepare( .prepare(
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' + 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, push_token, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC' 'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC'
) )
.bind(userId) .bind(userId)
@@ -177,7 +183,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> { export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
const row = await db const row = await db
.prepare( .prepare(
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' + 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, push_token, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1' 'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
) )
.bind(userId, deviceIdentifier) .bind(userId, deviceIdentifier)
@@ -185,6 +191,63 @@ export async function getDevice(db: D1Database, userId: string, deviceIdentifier
return row ? mapDeviceRow(row) : null; return row ? mapDeviceRow(row) : null;
} }
export async function updateDevicePushToken(
db: D1Database,
userId: string,
deviceIdentifier: string,
pushUuid: string,
pushToken: string
): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
.prepare(
'UPDATE devices SET push_uuid = ?, push_token = ?, updated_at = ? ' +
'WHERE user_id = ? AND device_identifier = ?'
)
.bind(pushUuid, pushToken, now, userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function clearDevicePushToken(
db: D1Database,
userId: string,
deviceIdentifier: string
): Promise<{ pushUuid: string | null } | null> {
const existing = await db
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
.bind(userId, deviceIdentifier)
.first<{ push_uuid: string | null }>();
if (!existing) return null;
await db
.prepare('UPDATE devices SET push_token = NULL, updated_at = ? WHERE user_id = ? AND device_identifier = ?')
.bind(new Date().toISOString(), userId, deviceIdentifier)
.run();
return { pushUuid: existing.push_uuid ?? null };
}
export async function getDevicePushUuid(
db: D1Database,
userId: string,
deviceIdentifier: string
): Promise<string | null> {
const row = await db
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
.bind(userId, deviceIdentifier)
.first<{ push_uuid: string | null }>();
return row?.push_uuid ?? null;
}
export async function userHasPushDevice(db: D1Database, userId: string): Promise<boolean> {
const row = await db
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND push_token IS NOT NULL AND push_token <> ? LIMIT 1')
.bind(userId, '')
.first<{ '1': number }>();
return !!row;
}
export async function deleteDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> { export async function deleteDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
const result = await db const result = await db
.prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?') .prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?')
+4 -1
View File
@@ -94,7 +94,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at)', 'CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at)',
'CREATE TABLE IF NOT EXISTS devices (' + 'CREATE TABLE IF NOT EXISTS devices (' +
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' + 'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, push_uuid TEXT, push_token TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'PRIMARY KEY (user_id, device_identifier), ' + 'PRIMARY KEY (user_id, device_identifier), ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
@@ -103,11 +103,14 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT', 'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT',
'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT', 'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT',
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT', 'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
'ALTER TABLE devices ADD COLUMN push_uuid TEXT',
'ALTER TABLE devices ADD COLUMN push_token TEXT',
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0', 'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE devices ADD COLUMN banned_at TEXT', 'ALTER TABLE devices ADD COLUMN banned_at TEXT',
'ALTER TABLE devices ADD COLUMN device_note TEXT', 'ALTER TABLE devices ADD COLUMN device_note TEXT',
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT', 'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)', 'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
'CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)',
'CREATE TABLE IF NOT EXISTS auth_requests (' + 'CREATE TABLE IF NOT EXISTS auth_requests (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' +
+28 -1
View File
@@ -1,5 +1,6 @@
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain, AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential, AuthRequestRecord } from '../types'; import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain, AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential, AuthRequestRecord } from '../types';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { ensurePushInstallationCredentials } from './push-relay';
import { ensureStorageSchema } from './storage-schema'; import { ensureStorageSchema } from './storage-schema';
import { import {
getConfigValue as getStoredConfigValue, getConfigValue as getStoredConfigValue,
@@ -87,10 +88,12 @@ import {
import { import {
deleteDevice as deleteStoredDevice, deleteDevice as deleteStoredDevice,
deleteDevicesByUserId as deleteStoredDevicesByUserId, deleteDevicesByUserId as deleteStoredDevicesByUserId,
clearDevicePushToken as clearStoredDevicePushToken,
clearDeviceKeys as clearStoredDeviceKeys, clearDeviceKeys as clearStoredDeviceKeys,
deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice, deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice,
deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId, deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId,
getDevice as findStoredDevice, getDevice as findStoredDevice,
getDevicePushUuid as findStoredDevicePushUuid,
getDevicesByUserId as listStoredDevicesByUserId, getDevicesByUserId as listStoredDevicesByUserId,
getTrustedDeviceTokenSummariesByUserId as listStoredTrustedTokenSummaries, getTrustedDeviceTokenSummariesByUserId as listStoredTrustedTokenSummaries,
getTrustedTwoFactorDeviceTokenUserId as findStoredTrustedTokenUserId, getTrustedTwoFactorDeviceTokenUserId as findStoredTrustedTokenUserId,
@@ -101,7 +104,9 @@ import {
upsertDevice as saveStoredDevice, upsertDevice as saveStoredDevice,
updateDeviceName as updateStoredDeviceName, updateDeviceName as updateStoredDeviceName,
updateDeviceKeys as updateStoredDeviceKeys, updateDeviceKeys as updateStoredDeviceKeys,
updateDevicePushToken as updateStoredDevicePushToken,
updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice, updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice,
userHasPushDevice as getUserHasPushDevice,
} from './storage-device-repo'; } from './storage-device-repo';
import { import {
createAuthRequest as createStoredAuthRequest, createAuthRequest as createStoredAuthRequest,
@@ -143,7 +148,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-12-auth-requests'; const STORAGE_SCHEMA_VERSION = '2026-06-22-push-notifications';
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.
@@ -235,6 +240,7 @@ export class StorageService {
await ensureStorageSchema(this.db); await ensureStorageSchema(this.db);
await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION); await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION);
} }
await ensurePushInstallationCredentials(this.db);
StorageService.schemaVerified = true; StorageService.schemaVerified = true;
} }
@@ -713,6 +719,27 @@ export class StorageService {
return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier); return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier);
} }
async updateDevicePushToken(
userId: string,
deviceIdentifier: string,
pushUuid: string,
pushToken: string
): Promise<boolean> {
return updateStoredDevicePushToken(this.db, userId, deviceIdentifier, pushUuid, pushToken);
}
async clearDevicePushToken(userId: string, deviceIdentifier: string): Promise<{ pushUuid: string | null } | null> {
return clearStoredDevicePushToken(this.db, userId, deviceIdentifier);
}
async getDevicePushUuid(userId: string, deviceIdentifier: string): Promise<string | null> {
return findStoredDevicePushUuid(this.db, userId, deviceIdentifier);
}
async userHasPushDevice(userId: string): Promise<boolean> {
return getUserHasPushDevice(this.db, userId);
}
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> { async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers); return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
} }
+2
View File
@@ -231,6 +231,8 @@ export interface Device {
encryptedUserKey: string | null; encryptedUserKey: string | null;
encryptedPublicKey: string | null; encryptedPublicKey: string | null;
encryptedPrivateKey: string | null; encryptedPrivateKey: string | null;
pushUuid: string | null;
pushToken: string | null;
devicePendingAuthRequest?: DevicePendingAuthRequest | null; devicePendingAuthRequest?: DevicePendingAuthRequest | null;
lastSeenAt: string | null; lastSeenAt: string | null;
createdAt: string; createdAt: string;