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快捷方式** |
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** |
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 |
| 实时同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
| Send | ✅ | ✅ | 支持文本与文件 Send |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
+1 -1
View File
@@ -40,7 +40,7 @@
| **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** |
| **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** |
| **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 |
| Send | ✅ | ✅ | Supports both text and file Sends |
| 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_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,
@@ -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_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 (
id TEXT PRIMARY KEY,
+11
View File
@@ -1,5 +1,6 @@
import { DurableObject, waitUntil } from 'cloudflare:workers';
import type { Env } from '../types';
import { notifyMobilePush } from '../services/push-relay';
const SIGNALR_RECORD_SEPARATOR = 0x1e;
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) {
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 { AuthService } from '../services/auth';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { registerMobilePushDevice, unregisterMobilePushDevice } from '../services/push-relay';
import { StorageService } from '../services/storage';
import { errorResponse, jsonResponse } from '../utils/response';
import { readKnownDeviceProbe } from '../utils/device';
@@ -223,6 +224,8 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
encryptedUserKey: null,
encryptedPublicKey: null,
encryptedPrivateKey: null,
pushUuid: null,
pushToken: null,
devicePendingAuthRequest: null,
deviceNote: null,
lastSeenAt: null,
@@ -325,10 +328,12 @@ export async function handleDeleteDevice(
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) {
await unregisterMobilePushDevice(env, device?.pushUuid);
AuthService.invalidateDeviceCache(userId, normalized);
notifyUserLogout(env, userId, normalized);
}
@@ -537,10 +542,12 @@ export async function handleDeactivateDevice(
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) {
await unregisterMobilePushDevice(env, device?.pushUuid);
AuthService.invalidateDeviceCache(userId, normalized);
notifyUserLogout(env, userId, normalized);
}
@@ -557,18 +564,36 @@ export async function handleDeactivateDevice(
}
// PUT /api/devices/identifier/{deviceIdentifier}/token
// Bitwarden mobile reports push token updates to this endpoint.
// NodeWarden does not implement push notifications, so accept and no-op.
// Bitwarden mobile reports APNs/FCM push token updates to this endpoint.
export async function handleUpdateDeviceToken(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
void env;
void userId;
void deviceIdentifier;
const normalized = normalizeIdentifier(deviceIdentifier);
if (!normalized) return errorResponse('Invalid device identifier', 400);
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 });
}
@@ -594,9 +619,15 @@ export async function handleClearDeviceToken(
deviceIdentifier: string
): Promise<Response> {
void request;
void env;
void userId;
void deviceIdentifier;
const normalized = normalizeIdentifier(deviceIdentifier);
if (!normalized) return errorResponse('Invalid device identifier', 400);
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 });
}
+42
View File
@@ -10,6 +10,7 @@ import { readAuthRequestDeviceInfo } from '../utils/device';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { generateUUID } from '../utils/uuid';
import { issueSendAccessToken } from './sends';
import { registerMobilePushDevice } from '../services/push-relay';
import {
buildAccountKeys,
buildUserDecryptionOptions,
@@ -50,6 +51,44 @@ async function resolveDeviceSession(
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 {
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,
deviceSession.sessionStamp
);
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
}
// Successful login - clear failed attempts
@@ -536,6 +576,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
deviceInfo.deviceType,
deviceSession.sessionStamp
);
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
}
await rateLimit.clearLoginAttempts(loginIdentifier);
@@ -664,6 +705,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
deviceInfo.deviceType,
deviceSession.sessionStamp
);
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
}
// Successful login - clear failed attempts
+19 -15
View File
@@ -20,6 +20,10 @@ import {
handleClearDeviceToken,
} from './handlers/devices';
function devicesPath(pattern: string): RegExp {
return new RegExp(`^/(?:api/)?devices${pattern}$`, 'i');
}
export async function handleAuthenticatedDeviceRoute(
request: Request,
env: Env,
@@ -27,31 +31,31 @@ export async function handleAuthenticatedDeviceRoute(
path: string,
method: string
): Promise<Response | null> {
if (path === '/api/devices') {
if (path === '/api/devices' || path === '/devices') {
if (method === 'GET') return handleGetDevices(request, env, userId);
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
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 === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
return null;
}
const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i);
const authorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)'));
if (authorizedDeviceMatch && method === 'DELETE') {
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
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') {
const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]);
return handleTrustDevicePermanently(request, env, userId, deviceIdentifier);
}
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
const deleteDeviceMatch = path.match(devicesPath('/([^/]+)'));
if (deleteDeviceMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
return handleGetDevice(request, env, userId, deviceIdentifier);
@@ -61,59 +65,59 @@ export async function handleAuthenticatedDeviceRoute(
return handleDeleteDevice(request, env, userId, deviceIdentifier);
}
const updateDeviceNameMatch = path.match(/^\/api\/devices\/([^/]+)\/name$/i);
const updateDeviceNameMatch = path.match(devicesPath('/([^/]+)/name'));
if (updateDeviceNameMatch && method === 'PUT') {
const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]);
return handleUpdateDeviceName(request, env, userId, deviceIdentifier);
}
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
const identifierMatch = path.match(devicesPath('/identifier/([^/]+)'));
if (identifierMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
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')) {
const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]);
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')) {
const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]);
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')) {
const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]);
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')) {
const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]);
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') {
const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]);
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')) {
const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]);
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);
}
if (path === '/api/devices/untrust' && method === 'POST') {
if ((path === '/api/devices/untrust' || path === '/devices/untrust') && method === 'POST') {
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 { generateUUID } from '../utils/uuid';
type GetUserByEmail = (email: string) => Promise<User | null>;
type TrustedTokenKeyFn = (token: string) => Promise<string>;
@@ -14,6 +15,8 @@ function mapDeviceRow(row: any): Device {
encryptedUserKey: row.encrypted_user_key ?? null,
encryptedPublicKey: row.encrypted_public_key ?? null,
encryptedPrivateKey: row.encrypted_private_key ?? null,
pushUuid: row.push_uuid ?? null,
pushToken: row.push_token ?? null,
lastSeenAt: row.last_seen_at ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
@@ -38,13 +41,15 @@ export async function upsertDevice(
const existingDevice = await getDeviceById(userId, deviceIdentifier);
const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || '';
const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim();
const effectivePushUuid = String(existingDevice?.pushUuid || '').trim() || generateUUID();
await db
.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, ' +
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_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, ' +
'updated_at=excluded.updated_at'
)
@@ -57,6 +62,7 @@ export async function upsertDevice(
keys?.encryptedUserKey ?? null,
keys?.encryptedPublicKey ?? null,
keys?.encryptedPrivateKey ?? null,
effectivePushUuid,
existingDevice?.deviceNote ?? null,
now,
now,
@@ -166,7 +172,7 @@ export async function isKnownDeviceByEmail(
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
const res = await db
.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'
)
.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> {
const row = await db
.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'
)
.bind(userId, deviceIdentifier)
@@ -185,6 +191,63 @@ export async function getDevice(db: D1Database, userId: string, deviceIdentifier
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> {
const result = await db
.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 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, ' +
'PRIMARY KEY (user_id, device_identifier), ' +
'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_public_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_at TEXT',
'ALTER TABLE devices ADD COLUMN device_note 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_push ON devices(user_id, push_token)',
'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, ' +
+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 { LIMITS } from '../config/limits';
import { ensurePushInstallationCredentials } from './push-relay';
import { ensureStorageSchema } from './storage-schema';
import {
getConfigValue as getStoredConfigValue,
@@ -87,10 +88,12 @@ import {
import {
deleteDevice as deleteStoredDevice,
deleteDevicesByUserId as deleteStoredDevicesByUserId,
clearDevicePushToken as clearStoredDevicePushToken,
clearDeviceKeys as clearStoredDeviceKeys,
deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice,
deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId,
getDevice as findStoredDevice,
getDevicePushUuid as findStoredDevicePushUuid,
getDevicesByUserId as listStoredDevicesByUserId,
getTrustedDeviceTokenSummariesByUserId as listStoredTrustedTokenSummaries,
getTrustedTwoFactorDeviceTokenUserId as findStoredTrustedTokenUserId,
@@ -101,7 +104,9 @@ import {
upsertDevice as saveStoredDevice,
updateDeviceName as updateStoredDeviceName,
updateDeviceKeys as updateStoredDeviceKeys,
updateDevicePushToken as updateStoredDevicePushToken,
updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice,
userHasPushDevice as getUserHasPushDevice,
} from './storage-device-repo';
import {
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
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// 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;
// D1-backed storage.
@@ -235,6 +240,7 @@ export class StorageService {
await ensureStorageSchema(this.db);
await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION);
}
await ensurePushInstallationCredentials(this.db);
StorageService.schemaVerified = true;
}
@@ -713,6 +719,27 @@ export class StorageService {
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> {
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
}
+2
View File
@@ -231,6 +231,8 @@ export interface Device {
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
encryptedPrivateKey: string | null;
pushUuid: string | null;
pushToken: string | null;
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
lastSeenAt: string | null;
createdAt: string;