Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b85c938
fix: update @appwrite.io/console dependency
lohanidamodar Feb 4, 2026
09ef6f3
feat: implement fingerprint generation for console access tracking
lohanidamodar Feb 4, 2026
6f403ba
Merge main into feat-billing and resolve conflict
lohanidamodar Feb 4, 2026
ad097ec
cached finterprint data
lohanidamodar Feb 4, 2026
266ee4a
fix: restrict console access tracking to cloud environments only
lohanidamodar Feb 4, 2026
100a967
feat: add paused project modal and update console access tracking logic
lohanidamodar Feb 5, 2026
a080abe
feat: update active project query to include paused status
lohanidamodar Feb 8, 2026
83bd6af
feat: add paused project status indication and modal integration
lohanidamodar Feb 8, 2026
4148c69
fix: update paused project modal message for clarity
lohanidamodar Feb 8, 2026
cb13e06
feat: update paused project modal integration to use layout data
lohanidamodar Feb 8, 2026
7b0511a
Merge remote-tracking branch 'origin/main' into feat-billing
lohanidamodar Feb 13, 2026
520c491
fix: ensure fingerprint header is removed after console access creation
lohanidamodar Feb 13, 2026
e04dfa3
fixes and sdk update
lohanidamodar Feb 13, 2026
0cc3ebb
Merge remote-tracking branch 'origin/main' into feat-billing
lohanidamodar Feb 17, 2026
ff73a2c
fix: update @appwrite.io/console dependency version in package.json a…
lohanidamodar Feb 17, 2026
e3fa833
feat: add ProjectResume enum for tracking project resume events
lohanidamodar Feb 17, 2026
878975a
fix: format code for better readability in layout and pausedProjectMo…
lohanidamodar Feb 17, 2026
73f69c5
fix status check
lohanidamodar Feb 17, 2026
d35a403
remove unused dependency
lohanidamodar Feb 17, 2026
96bd28b
Merge remote-tracking branch 'origin/main' into feat-billing
lohanidamodar Feb 18, 2026
e4f32ec
update publish keys
lohanidamodar Feb 18, 2026
52eecc9
add PUBLIC_CONSOLE_FINGERPRINT_KEY to Dockerfile and update fingerpri…
lohanidamodar Feb 18, 2026
55a8cf6
Merge branch 'main' into feat-billing
lohanidamodar Feb 24, 2026
1f3a6be
Merge remote-tracking branch 'origin/main' into feat-billing
lohanidamodar Feb 25, 2026
fadeb6f
fix: improve fingerprint generation and project status handling for c…
lohanidamodar Feb 25, 2026
657366d
update console
lohanidamodar Feb 26, 2026
736e667
fix: enhance fingerprint token generation and improve error handling …
lohanidamodar Feb 26, 2026
831eb50
fix: streamline console access update by simplifying fingerprint toke…
lohanidamodar Feb 26, 2026
48172a3
Update src/routes/(console)/project-[region]-[project]/+layout.ts
lohanidamodar Feb 26, 2026
4d35cc6
Update src/lib/helpers/fingerprint.ts
lohanidamodar Feb 26, 2026
042490a
fix: update project status handling to use Status enum for consistency
lohanidamodar Feb 26, 2026
809d683
Merge branch 'feat-billing' of github.com:appwrite/console into feat-…
lohanidamodar Feb 26, 2026
3c7f679
Update src/lib/helpers/fingerprint.ts
lohanidamodar Feb 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
},
"dependencies": {
"@ai-sdk/svelte": "^1.1.24",
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@c6f60aa",
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@e264dec",
"@appwrite.io/pink-icons": "0.25.0",
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@df765cc",
"@appwrite.io/pink-legacy": "^1.0.3",
Expand Down
217 changes: 217 additions & 0 deletions src/lib/helpers/fingerprint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { env } from '$env/dynamic/public';

const SECRET = env.PUBLIC_CONSOLE_FINGERPRINT_KEY ?? '';
Comment on lines +1 to +3

@coderabbitai coderabbitai Bot Feb 4, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: HMAC signing secret is exposed to the client.

The PUBLIC_CONSOLE_FINGERPRINT_KEY uses SvelteKit's PUBLIC_ prefix, which means this value is bundled into client-side JavaScript and visible to anyone inspecting the browser. This completely undermines the integrity guarantee of HMAC signing—users can forge valid fingerprint tokens.

Additionally, the empty string fallback silently produces signatures with a null key when the env var is missing.

If server-side verification is intended, the signing must occur on the server, or use a different authentication mechanism. If this is intentional client-side obfuscation only, consider documenting that limitation.

🤖 Prompt for AI Agents
In `@src/lib/helpers/fingerprint.ts` around lines 1 - 3, The code exposes the HMAC
signing secret by reading PUBLIC_CONSOLE_FINGERPRINT_KEY into the client-visible
SECRET constant in fingerprint.ts; move signing/verification to server-only code
and stop using any env var with the PUBLIC_ prefix. Replace the client-side
import of env and SECRET usage by implementing server endpoints or server-only
utilities that read a non-public env var (e.g., CONSOLE_FINGERPRINT_KEY) and
perform HMAC sign/verify there, and remove the empty-string fallback so the
server throws or fails fast if the secret is missing to avoid null-key
signatures; update any callers of the client-side SECRET to call the new server
endpoints/functions instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lohanidamodar this is a public key from public/private pair, right?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour

async function sha256(message: string): Promise<string> {
const data = new TextEncoder().encode(message);
const hash = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
Comment thread
lohanidamodar marked this conversation as resolved.

async function hmacSha256(message: string, secret: string): Promise<string> {
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));
return Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}

function getCanvasFingerprint(): string {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return '';

canvas.width = 200;
canvas.height = 50;

ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillStyle = '#f60';
ctx.fillRect(125, 1, 62, 20);
ctx.fillStyle = '#069';
ctx.fillText('Appwrite Console', 2, 15);
ctx.fillStyle = 'rgba(102, 204, 0, 0.7)';
ctx.fillText('Appwrite Console', 4, 17);

return canvas.toDataURL();
} catch {
return '';
}
}

function getWebGLFingerprint(): string {
try {
const canvas = document.createElement('canvas');
const gl =
canvas.getContext('webgl') ||
(canvas.getContext('experimental-webgl') as WebGLRenderingContext | null);
if (!gl) return '';

const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
if (!debugInfo) return 'webgl-no-debug';

const vendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) || '';
const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) || '';

return `${vendor}~${renderer}`;
} catch {
return '';
}
}

function getAudioFingerprint(): Promise<string> {
return new Promise((resolve) => {
try {
const AudioContext =
window.AudioContext ||
(window as unknown as { webkitAudioContext: typeof window.AudioContext })
.webkitAudioContext;
if (!AudioContext) {
resolve('');
return;
}

const context = new AudioContext();
const oscillator = context.createOscillator();
const analyser = context.createAnalyser();
const gain = context.createGain();
const processor = context.createScriptProcessor(4096, 1, 1);

gain.gain.value = 0;
oscillator.type = 'triangle';
oscillator.frequency.value = 10000;

oscillator.connect(analyser);
analyser.connect(processor);
processor.connect(gain);
gain.connect(context.destination);

oscillator.start(0);

const dataArray = new Float32Array(analyser.frequencyBinCount);
analyser.getFloatFrequencyData(dataArray);

let sum = 0;
for (let i = 0; i < dataArray.length; i++) {
sum += Math.abs(dataArray[i]);
}

oscillator.stop();
processor.disconnect();
context.close();

resolve(sum.toString());
} catch {
resolve('');
}
});
}
Comment thread
lohanidamodar marked this conversation as resolved.
Outdated

interface StaticSignals {
userAgent: string;
language: string;
languages: string[];
platform: string;
hardwareConcurrency: number;
deviceMemory: number | undefined;
maxTouchPoints: number;
screenWidth: number;
screenHeight: number;
screenColorDepth: number;
screenPixelDepth: number;
devicePixelRatio: number;
timezoneOffset: number;
timezone: string;
canvas: string;
webgl: string;
audio: string;
}

interface BrowserSignals extends StaticSignals {
timestamp: number;
}

interface SignalsCache {
signals: StaticSignals;
collectedAt: number;
}

let cache: SignalsCache | null = null;
let cachePromise: Promise<StaticSignals> | null = null;

async function collectStaticSignals(): Promise<StaticSignals> {
const [canvasRaw, webgl, audio] = await Promise.all([
Promise.resolve(getCanvasFingerprint()),
Promise.resolve(getWebGLFingerprint()),
getAudioFingerprint()
]);

const canvas = canvasRaw ? await sha256(canvasRaw) : '';

return {
userAgent: navigator.userAgent,
language: navigator.language,
languages: [...(navigator.languages || [])],
platform: navigator.platform,
hardwareConcurrency: navigator.hardwareConcurrency || 0,
deviceMemory: (navigator as Navigator & { deviceMemory?: number }).deviceMemory,
maxTouchPoints: navigator.maxTouchPoints || 0,
screenWidth: screen.width,
screenHeight: screen.height,
screenColorDepth: screen.colorDepth,
screenPixelDepth: screen.pixelDepth,
devicePixelRatio: window.devicePixelRatio || 1,
timezoneOffset: new Date().getTimezoneOffset(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
canvas,
webgl,
audio
};
}
Comment on lines +150 to +178

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Privacy/Compliance: Browser fingerprinting collects device-identifying data.

This function collects extensive device fingerprinting signals (hardware concurrency, device memory, screen properties, canvas/WebGL/audio fingerprints) that can uniquely identify users across sessions. Under GDPR/CCPA, browser fingerprinting may require:

  • User disclosure in privacy policy
  • Consent mechanisms depending on jurisdiction and purpose
  • Data retention policies for the collected fingerprints

Ensure legal/compliance review has approved this data collection, and that appropriate disclosures exist.

🤖 Prompt for AI Agents
In `@src/lib/helpers/fingerprint.ts` around lines 152 - 180, collectStaticSignals
is gathering high-risk fingerprinting fields (hardwareConcurrency, deviceMemory,
screen dimensions, canvas/webgl/audio) without any consent check or
minimization; update collectStaticSignals to only run when an explicit
consent/config flag (e.g., analyticsConsent or allowFingerprinting) is true, and
add a privacy-safe mode that omits or coarsens identifiable fields (remove or
bucket deviceMemory, hardwareConcurrency, screenWidth/Height, and avoid raw
audio/webgl data), keep only necessary hashed values (canvas is already hashed)
and a short audit/comment referencing that collection requires legal approval
and retention policy; ensure the gating flag is plumbed from your app config and
that StaticSignals consumers handle missing fields gracefully.


async function getCachedSignals(): Promise<StaticSignals> {
const now = Date.now();

if (cache && now - cache.collectedAt < CACHE_TTL_MS) {
return cache.signals;
}

if (cachePromise) {
return cachePromise;
}

cachePromise = collectStaticSignals();

try {
const signals = await cachePromise;
cache = { signals, collectedAt: now };
return signals;
} finally {
cachePromise = null;
}
}

export async function generateFingerprintToken(): Promise<string> {
const staticSignals = await getCachedSignals();

const signals: BrowserSignals = {
...staticSignals,
timestamp: Math.floor(Date.now() / 1000)
};

const payload = JSON.stringify(signals);
const encoded = btoa(payload);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Large payload sent with every request: the fingerprint token includes full browser signals (user agent, screen resolution, etc.) in base64-encoded JSON. Consider hashing the signals and only sending the hash + signature to reduce payload size and improve performance.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

const signature = await hmacSha256(encoded, SECRET);

return `${encoded}.${signature}`;
}
Comment thread
lohanidamodar marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
IconAndroid,
IconApple,
IconCode,
IconExclamationCircle,
IconFlutter,
IconPlus,
IconReact,
Expand Down Expand Up @@ -272,7 +273,12 @@
</svelte:fragment>

<svelte:fragment slot="status">
{#if isSetToArchive(project)}
{#if project.status === 'paused'}
<Tag size="s" style="white-space: nowrap;">
<Icon icon={IconExclamationCircle} size="s" slot="start" />
Paused
</Tag>
{:else if isSetToArchive(project)}
<Tag
size="s"
style="white-space: nowrap;"
Expand Down
2 changes: 1 addition & 1 deletion src/routes/(console)/organization-[organization]/+page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const load: PageLoad = async ({ params, url, route, depends, parent }) =>
: [];
const commonQueries = [Query.equal('teamId', params.organization)];
const activeQueries = isCloud
? [Query.or([Query.equal('status', 'active'), Query.isNull('status')])]
? [Query.or([Query.equal('status', ['active', 'paused']), Query.isNull('status')])]
: [];

const [activeProjects, archivedProjects, activeTotal, archivedTotal] = await Promise.all([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
canWriteSites
} from '$lib/stores/roles';
import CsvImportBox from '$lib/components/csvImportBox.svelte';
import { isCloud } from '$lib/system';
import PausedProjectModal from './pausedProjectModal.svelte';
import type { LayoutData } from './$types';

export let data: LayoutData;

onMount(() => {
return realtime.forProject(page.params.region, ['project', 'console'], (response) => {
Expand Down Expand Up @@ -114,6 +119,10 @@

<slot />

{#if isCloud && data.project?.status === 'paused'}
<PausedProjectModal show={true} projectId={data.project.$id} />
{/if}

<div class="layout-level-progress-bars">
<UploadBox />
<MigrationBox />
Expand Down
28 changes: 28 additions & 0 deletions src/routes/(console)/project-[region]-[project]/+layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +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 { normalizeConsoleVariables } from '$lib/helpers/domains';

export const load: LayoutLoad = async ({ params, depends, parent }) => {
Expand Down Expand Up @@ -92,6 +93,33 @@ export const load: LayoutLoad = async ({ params, depends, parent }) => {
plansInfo.set(organization.billingPlanId, organizationPlan);
}

// Track console access for cloud only (fire-and-forget, backend has 6-day cooldown)
// Don't call if project is paused - user must explicitly resume via createConsoleAccess
if (isCloud) {
const projectInactivityDays = organizationPlan?.projectInactivityDays ?? 0;
const consoleAccessedAt = (project as { consoleAccessedAt?: string }).consoleAccessedAt;

let isPaused = false;
if (projectInactivityDays > 0 && consoleAccessedAt) {
const lastAccess = new Date(consoleAccessedAt);
const now = new Date();
const diffDays = Math.floor((now.getTime() - lastAccess.getTime()) / (1000 * 60 * 60 * 24));
isPaused = diffDays >= projectInactivityDays;
}

if (!isPaused) {
generateFingerprintToken()
.then((fingerprint) => {
sdk.forConsole.client.headers['X-Appwrite-Console-Fingerprint'] = fingerprint;
return sdk.forConsole.projects.updateConsoleAccess({ projectId: params.project });
})
.catch(() => {})
.finally(() => {
delete sdk.forConsole.client.headers['X-Appwrite-Console-Fingerprint'];
});
}
}
Comment thread
lohanidamodar marked this conversation as resolved.

return {
project,
organization,
Expand Down
Loading
Loading