From e8938c31130b7ebc89604c2c24e2d16adb8b9ba7 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 29 Mar 2026 06:58:06 +0000 Subject: [PATCH 1/3] fix: use server time for fingerprint timestamps to prevent clock drift rejections Fingerprint validation was failing with "Timestamp expired" for users whose local clock drifted beyond the server's tolerance. Now we fetch the server's time via health.getTime() once per session and use it for all fingerprint timestamps, eliminating dependency on the user's local clock. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/helpers/fingerprint.ts | 33 ++++++++++++++++++- .../project-[region]-[project]/+layout.ts | 8 +++-- .../pausedProjectModal.svelte | 6 +++- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/lib/helpers/fingerprint.ts b/src/lib/helpers/fingerprint.ts index a4d53b13de..8ecbc8b606 100644 --- a/src/lib/helpers/fingerprint.ts +++ b/src/lib/helpers/fingerprint.ts @@ -3,6 +3,37 @@ import { env } from '$env/dynamic/public'; const SECRET = env.PUBLIC_CONSOLE_FINGERPRINT_KEY ?? ''; const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour +/** Cached server timestamp and the local time it was fetched at, for interpolation. */ +let serverTimeCache: { serverSecs: number; fetchedAtMs: number } | null = null; + +/** + * Fetch and cache the server's clock so fingerprint timestamps always align + * with the backend's clock, regardless of local clock drift. + * + * @param getServerTime - callback that returns the server's unix timestamp in seconds + * (e.g. `health.getTime()` → `response.localTime`) + */ +export async function syncServerTime( + getServerTime: () => Promise +): Promise { + if (serverTimeCache) return; + try { + const fetchedAtMs = Date.now(); + const serverSecs = await getServerTime(); + serverTimeCache = { serverSecs, fetchedAtMs }; + } catch { + console.warn('Failed to sync server time for fingerprint'); + } +} + +function getServerTimestamp(): number { + if (!serverTimeCache) { + return Math.floor(Date.now() / 1000); + } + const elapsedSecs = Math.floor((Date.now() - serverTimeCache.fetchedAtMs) / 1000); + return serverTimeCache.serverSecs + elapsedSecs; +} + async function sha256(message: string): Promise { if (!crypto?.subtle) { console.warn('crypto.subtle unavailable, fingerprinting disabled'); @@ -204,7 +235,7 @@ export async function generateFingerprintToken(): Promise { const signals: BrowserSignals = { ...staticSignals, - timestamp: Math.floor(Date.now() / 1000) + timestamp: getServerTimestamp() }; const payload = JSON.stringify(signals); diff --git a/src/routes/(console)/project-[region]-[project]/+layout.ts b/src/routes/(console)/project-[region]-[project]/+layout.ts index 8da51bb512..760241a159 100644 --- a/src/routes/(console)/project-[region]-[project]/+layout.ts +++ b/src/routes/(console)/project-[region]-[project]/+layout.ts @@ -11,7 +11,7 @@ import { loadAvailableRegions } from '$routes/(console)/regions'; import { type Models, Platform } from '@appwrite.io/console'; import { redirect } from '@sveltejs/kit'; import { resolve } from '$app/paths'; -import { generateFingerprintToken } from '$lib/helpers/fingerprint'; +import { generateFingerprintToken, syncServerTime } from '$lib/helpers/fingerprint'; import { normalizeConsoleVariables } from '$lib/helpers/domains'; import { browser } from '$app/environment'; @@ -107,7 +107,11 @@ export const load: LayoutLoad = async ({ params, depends, parent }) => { // Track console access for cloud projects (fire-and-forget, backend has 6-day cooldown). // Skip if paused — user must explicitly resume via the paused project modal. if (isCloud && browser && project.status !== 'paused') { - generateFingerprintToken() + syncServerTime(async () => { + const { localTime } = await sdk.forConsole.health.getTime(); + return localTime; + }) + .then(() => generateFingerprintToken()) .then((fingerprint) => { sdk.forConsole.client.headers['X-Appwrite-Console-Fingerprint'] = fingerprint; return sdk.forConsole.projects.updateConsoleAccess({ diff --git a/src/routes/(console)/project-[region]-[project]/pausedProjectModal.svelte b/src/routes/(console)/project-[region]-[project]/pausedProjectModal.svelte index 8c5fd1f177..734416965b 100644 --- a/src/routes/(console)/project-[region]-[project]/pausedProjectModal.svelte +++ b/src/routes/(console)/project-[region]-[project]/pausedProjectModal.svelte @@ -6,7 +6,7 @@ import { Dependencies } from '$lib/constants'; import { addNotification } from '$lib/stores/notifications'; import { Submit, trackError } from '$lib/actions/analytics'; - import { generateFingerprintToken } from '$lib/helpers/fingerprint'; + import { generateFingerprintToken, syncServerTime } from '$lib/helpers/fingerprint'; import { Alert, Layout, Modal, Typography } from '@appwrite.io/pink-svelte'; import { Status } from '@appwrite.io/console'; @@ -28,6 +28,10 @@ error = null; try { + await syncServerTime(async () => { + const { localTime } = await sdk.forConsole.health.getTime(); + return localTime; + }); const fingerprint = await generateFingerprintToken(); sdk.forConsole.client.headers['X-Appwrite-Console-Fingerprint'] = fingerprint; From 2fbb9532325fa932e891e62ac591d226199874a9 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 29 Mar 2026 07:06:34 +0000 Subject: [PATCH 2/3] fix: use Date header from health/version instead of health.getTime() health.getTime() requires health.read scope which regular console users don't have. Instead, parse the Date header from the existing health/version fetch that already happens on console load. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/helpers/fingerprint.ts | 20 ++++++------------- src/routes/(console)/+layout.ts | 9 ++++++++- .../project-[region]-[project]/+layout.ts | 8 ++------ .../pausedProjectModal.svelte | 6 +----- 4 files changed, 17 insertions(+), 26 deletions(-) diff --git a/src/lib/helpers/fingerprint.ts b/src/lib/helpers/fingerprint.ts index 8ecbc8b606..25de4e4525 100644 --- a/src/lib/helpers/fingerprint.ts +++ b/src/lib/helpers/fingerprint.ts @@ -7,23 +7,15 @@ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour let serverTimeCache: { serverSecs: number; fetchedAtMs: number } | null = null; /** - * Fetch and cache the server's clock so fingerprint timestamps always align - * with the backend's clock, regardless of local clock drift. + * Cache the server's clock so fingerprint timestamps always align with the + * backend's clock, regardless of local clock drift. * - * @param getServerTime - callback that returns the server's unix timestamp in seconds - * (e.g. `health.getTime()` → `response.localTime`) + * @param serverTimeSecs - the server's unix timestamp in seconds + * (e.g. parsed from a response Date header) */ -export async function syncServerTime( - getServerTime: () => Promise -): Promise { +export function syncServerTime(serverTimeSecs: number): void { if (serverTimeCache) return; - try { - const fetchedAtMs = Date.now(); - const serverSecs = await getServerTime(); - serverTimeCache = { serverSecs, fetchedAtMs }; - } catch { - console.warn('Failed to sync server time for fingerprint'); - } + serverTimeCache = { serverSecs: serverTimeSecs, fetchedAtMs: Date.now() }; } function getServerTimestamp(): number { diff --git a/src/routes/(console)/+layout.ts b/src/routes/(console)/+layout.ts index 5edbc55456..1d766b48a0 100644 --- a/src/routes/(console)/+layout.ts +++ b/src/routes/(console)/+layout.ts @@ -6,6 +6,7 @@ import { Platform, Query } from '@appwrite.io/console'; import { makePlansMap } from '$lib/helpers/billing'; import { plansInfo as plansInfoStore } from '$lib/stores/billing'; import { normalizeConsoleVariables } from '$lib/helpers/domains'; +import { syncServerTime } from '$lib/helpers/fingerprint'; export const load: LayoutLoad = async ({ depends, parent }) => { const { organizations, plansInfo } = await parent(); @@ -28,7 +29,13 @@ export const load: LayoutLoad = async ({ depends, parent }) => { plansArrayPromise, fetch(`${endpoint}/health/version`, { headers: { 'X-Appwrite-Project': project as string } - }).then((response) => response.json() as { version?: string }), + }).then((response) => { + const dateHeader = response.headers.get('Date'); + if (dateHeader) { + syncServerTime(Math.floor(new Date(dateHeader).getTime() / 1000)); + } + return response.json() as { version?: string }; + }), sdk.forConsole.console.variables() ]); diff --git a/src/routes/(console)/project-[region]-[project]/+layout.ts b/src/routes/(console)/project-[region]-[project]/+layout.ts index 760241a159..8da51bb512 100644 --- a/src/routes/(console)/project-[region]-[project]/+layout.ts +++ b/src/routes/(console)/project-[region]-[project]/+layout.ts @@ -11,7 +11,7 @@ import { loadAvailableRegions } from '$routes/(console)/regions'; import { type Models, Platform } from '@appwrite.io/console'; import { redirect } from '@sveltejs/kit'; import { resolve } from '$app/paths'; -import { generateFingerprintToken, syncServerTime } from '$lib/helpers/fingerprint'; +import { generateFingerprintToken } from '$lib/helpers/fingerprint'; import { normalizeConsoleVariables } from '$lib/helpers/domains'; import { browser } from '$app/environment'; @@ -107,11 +107,7 @@ export const load: LayoutLoad = async ({ params, depends, parent }) => { // Track console access for cloud projects (fire-and-forget, backend has 6-day cooldown). // Skip if paused — user must explicitly resume via the paused project modal. if (isCloud && browser && project.status !== 'paused') { - syncServerTime(async () => { - const { localTime } = await sdk.forConsole.health.getTime(); - return localTime; - }) - .then(() => generateFingerprintToken()) + generateFingerprintToken() .then((fingerprint) => { sdk.forConsole.client.headers['X-Appwrite-Console-Fingerprint'] = fingerprint; return sdk.forConsole.projects.updateConsoleAccess({ diff --git a/src/routes/(console)/project-[region]-[project]/pausedProjectModal.svelte b/src/routes/(console)/project-[region]-[project]/pausedProjectModal.svelte index 734416965b..8c5fd1f177 100644 --- a/src/routes/(console)/project-[region]-[project]/pausedProjectModal.svelte +++ b/src/routes/(console)/project-[region]-[project]/pausedProjectModal.svelte @@ -6,7 +6,7 @@ import { Dependencies } from '$lib/constants'; import { addNotification } from '$lib/stores/notifications'; import { Submit, trackError } from '$lib/actions/analytics'; - import { generateFingerprintToken, syncServerTime } from '$lib/helpers/fingerprint'; + import { generateFingerprintToken } from '$lib/helpers/fingerprint'; import { Alert, Layout, Modal, Typography } from '@appwrite.io/pink-svelte'; import { Status } from '@appwrite.io/console'; @@ -28,10 +28,6 @@ error = null; try { - await syncServerTime(async () => { - const { localTime } = await sdk.forConsole.health.getTime(); - return localTime; - }); const fingerprint = await generateFingerprintToken(); sdk.forConsole.client.headers['X-Appwrite-Console-Fingerprint'] = fingerprint; From 65f29e706c328435d8a74c3fa6ed52a8134fd65c Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 29 Mar 2026 07:12:24 +0000 Subject: [PATCH 3/3] fix: improve server time synchronization logic in layout load --- src/routes/(console)/+layout.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/routes/(console)/+layout.ts b/src/routes/(console)/+layout.ts index 1d766b48a0..b221432942 100644 --- a/src/routes/(console)/+layout.ts +++ b/src/routes/(console)/+layout.ts @@ -31,8 +31,9 @@ export const load: LayoutLoad = async ({ depends, parent }) => { headers: { 'X-Appwrite-Project': project as string } }).then((response) => { const dateHeader = response.headers.get('Date'); - if (dateHeader) { - syncServerTime(Math.floor(new Date(dateHeader).getTime() / 1000)); + const parsed = dateHeader ? new Date(dateHeader).getTime() : NaN; + if (Number.isFinite(parsed)) { + syncServerTime(Math.floor(parsed / 1000)); } return response.json() as { version?: string }; }),