Merge remote-tracking branch 'origin/main' into chore/usage-update

This commit is contained in:
Damodar Lohani
2026-03-30 04:46:09 +00:00
10 changed files with 7108 additions and 93 deletions
+7021
View File
File diff suppressed because it is too large Load Diff
+18 -9
View File
@@ -155,12 +155,15 @@ export const defaultRoles: string[] = ['owner'];
// these are kept for backwards compatibility with keys and events that already exists.
// for the new ones, we use the new terminology.
export const scopes: {
export type ScopeDefinition = {
scope: string;
description: string;
category: string;
icon: string;
}[] = [
deprecated?: boolean;
};
export const scopes: ScopeDefinition[] = [
{
scope: 'sessions.write',
description: "Access to create, update and delete your project's sessions",
@@ -207,13 +210,15 @@ export const scopes: {
scope: 'collections.read',
description: "Access to read your project's database collections",
category: 'Database',
icon: 'database'
icon: 'database',
deprecated: true
},
{
scope: 'collections.write',
description: "Access to create, update, and delete your project's database collections",
category: 'Database',
icon: 'database'
icon: 'database',
deprecated: true
},
{
scope: 'tables.read',
@@ -231,14 +236,16 @@ export const scopes: {
scope: 'attributes.read',
description: "Access to read your project's database collection's attributes",
category: 'Database',
icon: 'database'
icon: 'database',
deprecated: true
},
{
scope: 'attributes.write',
description:
"Access to create, update, and delete your project's database collection's attributes",
category: 'Database',
icon: 'database'
icon: 'database',
deprecated: true
},
{
scope: 'columns.read',
@@ -268,13 +275,15 @@ export const scopes: {
scope: 'documents.read',
description: "Access to read your project's database documents",
category: 'Database',
icon: 'database'
icon: 'database',
deprecated: true
},
{
scope: 'documents.write',
description: "Access to create, update, and delete your project's database documents",
category: 'Database',
icon: 'database'
icon: 'database',
deprecated: true
},
{
scope: 'rows.read',
@@ -466,7 +475,7 @@ export const scopes: {
}
];
export const cloudOnlyBackupScopes = [
export const cloudOnlyBackupScopes: ScopeDefinition[] = [
{
scope: 'policies.read',
description: 'Access to read your database backup policies',
+24 -1
View File
@@ -3,6 +3,29 @@ 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;
/**
* Cache the server's clock so fingerprint timestamps always align with the
* backend's clock, regardless of local clock drift.
*
* @param serverTimeSecs - the server's unix timestamp in seconds
* (e.g. parsed from a response Date header)
*/
export function syncServerTime(serverTimeSecs: number): void {
if (serverTimeCache) return;
serverTimeCache = { serverSecs: serverTimeSecs, fetchedAtMs: Date.now() };
}
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<string> {
if (!crypto?.subtle) {
console.warn('crypto.subtle unavailable, fingerprinting disabled');
@@ -204,7 +227,7 @@ export async function generateFingerprintToken(): Promise<string> {
const signals: BrowserSignals = {
...staticSignals,
timestamp: Math.floor(Date.now() / 1000)
timestamp: getServerTimestamp()
};
const payload = JSON.stringify(signals);
-3
View File
@@ -1,11 +1,8 @@
<script lang="ts">
import { page } from '$app/state';
import { getContext } from 'svelte';
import type { Writable } from 'svelte/store';
export let subNavigation;
$: subNavigation = page.data.subNavigation;
// We need to have this second variable, because we only want narrow
// to change automatically if we change from having a second side nav to
// not having one, not when the second side nav changes to a different value.
+4 -3
View File
@@ -15,6 +15,7 @@
import { hasOnboardingDismissed } from '$lib/helpers/onboarding';
import { isSidebarOpen, noWidthTransition } from '$lib/stores/sidebar';
import { page } from '$app/state';
import { page as pageStore } from '$app/stores';
import { BillingPlanGroup, type Models } from '@appwrite.io/console';
import { getSidebarState, isInDatabasesRoute, updateSidebarState } from '$lib/helpers/sidebar';
import { isTabletViewport } from '$lib/stores/viewport';
@@ -191,7 +192,7 @@
$: state = $isSidebarOpen ? 'open' : 'closed';
$: subNavigation = page.data.subNavigation;
$: subNavigation = $pageStore.data.subNavigation;
$: shouldRenderSidebar =
!$isNewWizardStatusOpen && showSideNavigation && !$showOnboardingAnimation;
@@ -235,14 +236,14 @@
project={activeProject}
progressCard={getProgressCard()}
avatar={navbarProps.avatar}
bind:subNavigation
{subNavigation}
bind:sideBarIsOpen={$isSidebarOpen}
bind:showAccountMenu
bind:state />
{/if}
{#if !$showOnboardingAnimation}
<SideNavigation bind:subNavigation />
<SideNavigation {subNavigation} />
{/if}
</div>
+4 -1
View File
@@ -54,7 +54,10 @@ export function getApiEndpoint(region?: string): string {
const hostname = url.host; // "hostname:port" (or just "hostname" if no port)
// If instance supports multi-region, add the region subdomain.
const subdomain = isMultiRegionSupported(url) ? getSubdomain(region) : '';
let subdomain = isMultiRegionSupported(url) ? getSubdomain(region) : '';
if (subdomain && hostname.startsWith(subdomain)) {
subdomain = '';
}
return `${protocol}//${subdomain}${hostname}/v1`;
}
+9 -1
View File
@@ -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,14 @@ 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');
const parsed = dateHeader ? new Date(dateHeader).getTime() : NaN;
if (Number.isFinite(parsed)) {
syncServerTime(Math.floor(parsed / 1000));
}
return response.json() as { version?: string };
}),
sdk.forConsole.console.variables()
]);
@@ -16,7 +16,6 @@
import { symmetricDifference } from '$lib/helpers/array';
import Scopes from '../api-keys/scopes.svelte';
import { InteractiveText, Layout, Typography } from '@appwrite.io/pink-svelte';
import { getEffectiveScopes } from '../api-keys/scopes.svelte';
export let key: Models.DevKey | Models.Key;
export let keyType: 'api' | 'dev' = 'api';
@@ -163,8 +162,6 @@
{#if isApiKey}
<Form onSubmit={updateScopes}>
{@const apiKey = asApiKey(key)}
{@const apiKeyCorrectScopes = getEffectiveScopes(apiKey.scopes)}
{@const currentEffective = scopes ? getEffectiveScopes(scopes) : null}
<CardGrid>
<svelte:fragment slot="title">Scopes</svelte:fragment>
You can choose which permission scope to grant your application. It is a best practice
@@ -178,8 +175,7 @@
<svelte:fragment slot="actions">
<Button
submit
disabled={scopes &&
!symmetricDifference(currentEffective, apiKeyCorrectScopes).length}
disabled={scopes && !symmetricDifference(scopes, apiKey.scopes).length}
>Update</Button>
</svelte:fragment>
</CardGrid>
@@ -11,7 +11,6 @@
import { Badge, Layout, Table } from '@appwrite.io/pink-svelte';
import DeleteBatch from './deleteBatch.svelte';
import { capitalize } from '$lib/helpers/string';
import { getEffectiveScopes } from '../api-keys/scopes.svelte';
let {
keyType = 'api',
@@ -31,7 +30,7 @@
function getApiKeyScopeCount(key: Models.Key | Models.DevKey) {
const apiKey = key as Models.Key;
return getEffectiveScopes(apiKey.scopes).length;
return apiKey.scopes.length;
}
function getExpiryDetails(key: Models.Key | Models.DevKey): {
@@ -31,29 +31,21 @@
import { Button } from '$lib/elements/forms';
import { symmetricDifference } from '$lib/helpers/array';
import { scopes as allScopes, cloudOnlyBackupScopes } from '$lib/constants';
import { Accordion, Divider, Layout, Selector } from '@appwrite.io/pink-svelte';
import { Accordion, Badge, Divider, Layout, Selector } from '@appwrite.io/pink-svelte';
import type { Scopes } from '@appwrite.io/console';
export let scopes: Scopes[];
const baseFilteredScopes = allScopes.filter((scope) => {
const val = scope.scope;
if (!val) return false;
const legacyPrefixes = ['collections.', 'attributes.', 'documents.'];
return !legacyPrefixes.some((prefix) => val.startsWith(prefix));
});
// insert cloud-only scopes right after databases.write
const databasesWriteIndex = baseFilteredScopes.findIndex((s) => s.scope === 'databases.write');
const databasesWriteIndex = allScopes.findIndex((s) => s.scope === 'databases.write');
const filteredScopes =
isCloud && databasesWriteIndex !== -1
? [
...baseFilteredScopes.slice(0, databasesWriteIndex + 1),
...allScopes.slice(0, databasesWriteIndex + 1),
...cloudOnlyBackupScopes,
...baseFilteredScopes.slice(databasesWriteIndex + 1)
...allScopes.slice(databasesWriteIndex + 1)
]
: baseFilteredScopes;
: allScopes;
// include all scopes
const scopeCatalog = new Set([
@@ -90,9 +82,8 @@
onMount(() => {
scopes.forEach((scope) => {
const newerScope = toNewerScope(scope);
if (newerScope in activeScopes) {
activeScopes[newerScope] = true;
if (scope in activeScopes) {
activeScopes[scope] = true;
}
});
@@ -111,36 +102,11 @@
}
}
function toNewerScope(scope: string): string {
for (const pair of compatPairs) {
if (scope.startsWith(pair.legacy)) {
return scope.replace(pair.legacy, pair.newer);
}
}
return scope;
}
function getAllScopeVariants(scope: string): string[] {
const variants = new Set([scope]);
for (const pair of compatPairs) {
if (scope.startsWith(pair.newer)) {
variants.add(scope.replace(pair.newer, pair.legacy));
} else if (scope.startsWith(pair.legacy)) {
variants.add(scope.replace(pair.legacy, pair.newer));
}
}
return Array.from(variants);
}
function categoryState(category: string, s: string[]): boolean | 'indeterminate' {
const scopesByCategory = filteredScopes.filter((n) => n.category === category);
const activeInCategory = scopesByCategory.filter((scopeItem) => {
const newerScope = scopeItem.scope;
return s.some((scope) => toNewerScope(scope) === newerScope);
});
const activeInCategory = scopesByCategory.filter((scopeItem) =>
s.includes(scopeItem.scope as Scopes)
);
if (activeInCategory.length === 0) {
return false;
@@ -154,27 +120,16 @@
function onCategoryChange(event: CustomEvent<boolean | 'indeterminate'>, category: Category) {
if (event.detail === 'indeterminate') return;
filteredScopes.forEach((s) => {
if (s.category === category) {
if (s.category === category && !s.deprecated) {
activeScopes[s.scope] = event.detail;
}
});
}
function generateSyncedScopes(activeScopesObj: Record<string, boolean>): Scopes[] {
const result = new Set<string>();
Object.entries(activeScopesObj).forEach(([scope, isActive]) => {
if (isActive) {
const variants = getAllScopeVariants(scope);
variants.forEach((variant) => {
if (scopeCatalog.has(variant)) {
result.add(variant);
}
});
}
});
return Array.from(result) as Scopes[];
return Object.entries(activeScopesObj)
.filter(([scope, isActive]) => isActive && scopeCatalog.has(scope))
.map(([scope]) => scope as Scopes);
}
$: {
@@ -203,9 +158,7 @@
{@const checked = categoryState(category, scopes)}
{@const isLastItem = index === categories.length - 1}
{@const scopesLength = filteredScopes.filter(
(n) =>
n.category === category &&
scopes.some((scope) => toNewerScope(scope) === n.scope)
(n) => n.category === category && scopes.includes(n.scope as Scopes)
).length}
<Accordion
selectable
@@ -216,12 +169,17 @@
on:change={(event) => onCategoryChange(event, category)}>
<Layout.Stack>
{#each filteredScopes.filter((s) => s.category === category) as scope}
<Selector.Checkbox
size="s"
id={scope.scope}
label={scope.scope}
description={scope.description}
bind:checked={activeScopes[scope.scope]} />
<Layout.Stack direction="row" alignItems="center" gap="s">
<Selector.Checkbox
size="s"
id={scope.scope}
label={`${scope.scope}${scope.deprecated ? ' (Deprecated)' : ''}`}
description={scope.description}
bind:checked={activeScopes[scope.scope]} />
{#if scope.deprecated}
<Badge size="xs" variant="secondary" content="Deprecated" />
{/if}
</Layout.Stack>
{/each}
</Layout.Stack>
</Accordion>