feat(desktop): bundled renderer, modules and instance accounts (#3254)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-07 16:48:07 +02:00
1 parent c748c8af4e
commit 7c5fa2180a
1275 files changed
+215074 -81246

No files matched your search

+6
View File
@@ -6,6 +6,8 @@ export const Headers = {
CONTENT_DISPOSITION: 'Content-Disposition',
AUTHORIZATION: 'Authorization',
ACCEPT: 'Accept',
ORIGIN: 'Origin',
ACCEPT_LANGUAGE: 'Accept-Language',
CACHE_CONTROL: 'Cache-Control',
ETAG: 'ETag',
LAST_MODIFIED: 'Last-Modified',
@@ -16,15 +18,19 @@ export const Headers = {
X_FORWARDED_PROTO: 'X-Forwarded-Proto',
X_FORWARDED_HOST: 'X-Forwarded-Host',
X_REQUEST_ID: 'X-Request-ID',
X_REQUESTED_WITH: 'X-Requested-With',
X_RATELIMIT_LIMIT: 'X-RateLimit-Limit',
X_RATELIMIT_REMAINING: 'X-RateLimit-Remaining',
X_RATELIMIT_RESET: 'X-RateLimit-Reset',
X_CONTENT_TYPE_OPTIONS: 'X-Content-Type-Options',
X_FRAME_OPTIONS: 'X-Frame-Options',
X_XSS_PROTECTION: 'X-XSS-Protection',
X_FLUXER_CLIENT_INSTALLATION_ID: 'X-Fluxer-Client-Installation-ID',
X_FLUXER_FEATURES: 'X-Fluxer-Features',
X_FLUXER_PLATFORM: 'X-Fluxer-Platform',
X_FLUXER_SUDO_MODE_JWT: 'X-Fluxer-Sudo-Mode-JWT',
X_FLUXER_VERSION: 'X-Fluxer-Version',
X_CAPTCHA_ID: 'X-Captcha-Id',
X_CAPTCHA_TOKEN: 'X-Captcha-Token',
X_AUDIT_LOG_REASON: 'X-Audit-Log-Reason',
X_INTERNAL_API_KEY: 'X-Internal-API-Key',
@@ -116,11 +116,3 @@ export function getRelativeDateString(
hour12: use12Hour,
}).format(date);
}
export function formatLastActive(date: DateInput, locale: string = DEFAULT_LOCALE): string {
const dateObj = parseDate(date);
if (Number.isNaN(dateObj.getTime())) {
return String(date);
}
return getDateFormatter(locale, {dateStyle: 'medium', timeStyle: 'short'}).format(dateObj);
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@fluxer/desktop_ipc",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./*": "./*"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:ts7"
}
}
@@ -0,0 +1,74 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const DESKTOP_ACCOUNT_CHANNELS = Object.freeze({
getAll: 'desktop-accounts:get-all',
get: 'desktop-accounts:get',
upsert: 'desktop-accounts:upsert',
compareAndSwap: 'desktop-accounts:compare-and-swap',
delete: 'desktop-accounts:delete',
import: 'desktop-accounts:import',
prune: 'desktop-accounts:prune',
} as const);
export interface DesktopStoreJSONObject {
[key: string]: DesktopStoreJSONValue;
}
export type DesktopStoreJSONValue =
| string
| number
| boolean
| null
| Array<DesktopStoreJSONValue>
| DesktopStoreJSONObject;
export interface DesktopAccountRecord {
readonly storageKey: string;
readonly record: DesktopStoreJSONObject;
}
export interface DesktopAccountCompareAndSwapRequest {
readonly expected: DesktopAccountRecord;
readonly replacement: DesktopAccountRecord;
}
export interface DesktopStoreMarker {
readonly key: string;
readonly value: string;
}
export interface DesktopStoreSkippedRecord {
readonly key: string;
readonly reason: string;
}
export interface DesktopAccountImportRequest {
readonly records: ReadonlyArray<DesktopAccountRecord>;
readonly marker?: DesktopStoreMarker | null;
}
export interface DesktopAccountImportReport {
readonly imported: number;
readonly skipped: ReadonlyArray<DesktopStoreSkippedRecord>;
readonly unusableInstances: ReadonlyArray<string>;
}
export interface DesktopAccountPruneRequest {
readonly knownStorageKeys: ReadonlyArray<string>;
readonly listIsAuthoritative: boolean;
}
export interface DesktopAccountPruneReport {
readonly pruned: ReadonlyArray<string>;
readonly refusedReason: string | null;
}
export interface DesktopAccountStorageAPI {
getAll: () => Promise<Array<DesktopAccountRecord>>;
get: (storageKey: string) => Promise<DesktopAccountRecord | null>;
upsert: (account: DesktopAccountRecord) => Promise<void>;
compareAndSwap: (request: DesktopAccountCompareAndSwapRequest) => Promise<boolean>;
delete: (storageKey: string) => Promise<void>;
import: (request: DesktopAccountImportRequest) => Promise<DesktopAccountImportReport>;
prune: (request: DesktopAccountPruneRequest) => Promise<DesktopAccountPruneReport>;
}
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const DESKTOP_HANDOFF_CHANNELS = Object.freeze({
initiate: 'desktop-handoff:initiate',
status: 'desktop-handoff:status',
} as const);
export const DesktopHandoffStatus = Object.freeze({
PENDING: 'pending',
EXPIRED: 'expired',
COMPLETED: 'completed',
} as const);
export type DesktopHandoffStatus = (typeof DesktopHandoffStatus)[keyof typeof DesktopHandoffStatus];
export interface DesktopHandoffInstance {
readonly apiEndpoint: string;
readonly apiVersion: number;
readonly webAppEndpoint: string;
}
export interface DesktopHandoffSession {
readonly instance: DesktopHandoffInstance;
readonly code: string;
readonly expiresAt: string;
}
export interface DesktopHandoffUser {
readonly username: string;
readonly discriminator: string;
readonly global_name: string | null;
readonly avatar: string | null;
readonly email?: string | null;
}
export interface DesktopHandoffIncompleteResult {
readonly status: typeof DesktopHandoffStatus.PENDING | typeof DesktopHandoffStatus.EXPIRED;
}
export interface DesktopHandoffCompletedResult {
readonly status: typeof DesktopHandoffStatus.COMPLETED;
readonly token: string;
readonly userId: string;
readonly user: DesktopHandoffUser | null;
}
export type DesktopHandoffStatusResult = DesktopHandoffIncompleteResult | DesktopHandoffCompletedResult;
export interface DesktopHandoffAPI {
initiate: (instance: DesktopHandoffInstance) => Promise<DesktopHandoffSession>;
status: (code: string) => Promise<DesktopHandoffStatusResult>;
}
@@ -0,0 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const DESKTOP_CAPABILITY_MANIFEST_CHANNEL = 'desktop-capability-manifest:get';
export interface DesktopCapabilityManifest {
readonly appStore: boolean;
readonly gatewaySocket: boolean;
}
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const NativeGatewayTransportMode = Object.freeze({
GATEWAY: 'gateway',
VOICE: 'voice',
} as const);
export type NativeGatewayTransportMode = (typeof NativeGatewayTransportMode)[keyof typeof NativeGatewayTransportMode];
export const NativeGatewayTransportEventKind = Object.freeze({
OPEN: 'open',
MESSAGE: 'message',
BINARY: 'binary',
CLOSE: 'close',
ERROR: 'error',
} as const);
export type NativeGatewayTransportEventKind =
(typeof NativeGatewayTransportEventKind)[keyof typeof NativeGatewayTransportEventKind];
export interface NativeGatewayTransportEvent {
readonly connectionId: string;
readonly kind: NativeGatewayTransportEventKind;
readonly data: string | null;
readonly binary: ArrayBuffer | null;
readonly code: number | null;
readonly reason: string | null;
readonly wasClean: boolean | null;
readonly message: string | null;
}
export interface NativeGatewayTransportCreateRequest {
readonly connectionId: string;
readonly url: string;
readonly mode: NativeGatewayTransportMode;
}
export interface NativeGatewayTransportCreateResult {
readonly connectionId: string;
}
export interface NativeGatewayTransportSendTextRequest {
readonly connectionId: string;
readonly payload: string;
}
export interface NativeGatewayTransportSendBinaryRequest {
readonly connectionId: string;
readonly payload: ArrayBuffer;
}
export interface NativeGatewayTransportCloseRequest {
readonly connectionId: string;
readonly code: number;
readonly reason: string;
}
export interface NativeGatewayTransportAPI {
readonly isAvailable: boolean;
create: (request: NativeGatewayTransportCreateRequest) => Promise<NativeGatewayTransportCreateResult>;
sendText: (request: NativeGatewayTransportSendTextRequest) => Promise<void>;
sendBinary: (request: NativeGatewayTransportSendBinaryRequest) => Promise<void>;
close: (request: NativeGatewayTransportCloseRequest) => Promise<void>;
dispose: (connectionId: string) => Promise<void>;
onEvent: (callback: (event: NativeGatewayTransportEvent) => void) => () => void;
}
export const NATIVE_GATEWAY_TRANSPORT_CHANNELS = Object.freeze({
create: 'native-gateway-transport:create',
sendText: 'native-gateway-transport:send-text',
sendBinary: 'native-gateway-transport:send-binary',
close: 'native-gateway-transport:close',
dispose: 'native-gateway-transport:dispose',
isAvailable: 'native-gateway-transport:is-available',
} as const);
export const NATIVE_GATEWAY_TRANSPORT_EVENT_CHANNEL = 'native-gateway-transport:event';
export const NATIVE_GATEWAY_TRANSPORT_AVAILABLE_RENDERER_ARG = '--fluxer-native-gateway-available=1';
export const NATIVE_GATEWAY_TRANSPORT_CONNECTION_ID_MAX_BYTES = 192;
export const NATIVE_GATEWAY_TRANSPORT_URL_MAX_BYTES = 2048;
export const NATIVE_GATEWAY_TRANSPORT_FRAME_MAX_BYTES = 8 * 1024 * 1024;
export const NATIVE_GATEWAY_TRANSPORT_ERROR_MAX_BYTES = 16 * 1024;
export const NATIVE_GATEWAY_TRANSPORT_CLOSE_REASON_MAX_BYTES = 123;
export const NATIVE_GATEWAY_TRANSPORT_MAX_CONNECTIONS_PER_DOCUMENT = 8;
const NATIVE_GATEWAY_TRANSPORT_CONNECTION_ID_PATTERN =
/^gateway-renderer-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
export function isNativeGatewayTransportConnectionId(value: string): boolean {
return NATIVE_GATEWAY_TRANSPORT_CONNECTION_ID_PATTERN.test(value);
}
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface DesktopHTTPTransportRequest {
readonly url: string;
readonly method: string;
readonly headers: Readonly<Record<string, string>>;
readonly body: string | null;
readonly timeoutMs: number;
}
export interface DesktopHTTPTransportResponse {
readonly ok: boolean;
readonly status: number;
readonly statusText: string;
readonly headers: Readonly<Record<string, string>>;
readonly body: string | null;
}
export interface DesktopHTTPTransportAPI {
fetch(request: DesktopHTTPTransportRequest): Promise<DesktopHTTPTransportResponse>;
}
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const DESKTOP_KNOWN_INSTANCE_CHANNELS = Object.freeze({
getAll: 'desktop-known-instances:get-all',
upsert: 'desktop-known-instances:upsert',
delete: 'desktop-known-instances:delete',
} as const);
export interface DesktopKnownInstanceRecord {
readonly instanceKey: string;
readonly domain: string;
readonly displayName: string;
readonly lastUsed: number;
}
export interface DesktopKnownInstanceStorageAPI {
getAll: () => Promise<ReadonlyArray<DesktopKnownInstanceRecord>>;
upsert: (instance: DesktopKnownInstanceRecord) => Promise<void>;
delete: (instanceKey: string) => Promise<void>;
}
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const DESKTOP_LAST_ROUTE_CHANNEL = 'desktop-last-route:report';
const MAX_LAST_ROUTE_LENGTH = 512;
function containsControlCharacter(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 0x1f || code === 0x7f) {
return true;
}
}
return false;
}
export function isRestorableDesktopRoutePath(value: unknown): value is string {
if (typeof value !== 'string') return false;
if (value.length === 0 || value.length > MAX_LAST_ROUTE_LENGTH) return false;
if (!value.startsWith('/')) return false;
if (value.startsWith('//')) return false;
if (value.includes('\\')) return false;
if (value.includes('://')) return false;
if (containsControlCharacter(value)) return false;
for (const segment of value.split('/')) {
if (segment === '..') return false;
}
return true;
}
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const DESKTOP_LEGACY_HARVEST_CHANNELS = Object.freeze({
read: 'desktop-legacy-harvest:read',
readRawSync: 'desktop-legacy-harvest:read-raw',
markReplanted: 'desktop-legacy-harvest:replanted',
discard: 'desktop-legacy-harvest:discard',
submit: 'desktop-legacy-harvest:submit',
} as const);
export const DESKTOP_LEGACY_HARVEST_SENTINEL_PATH = '/__fluxer-desktop-storage-harvest';
export const DESKTOP_LEGACY_HARVEST_MARKER_KEY = 'legacy_origin_harvest.v1';
export const DESKTOP_LEGACY_REPLANT_MARKER_KEY = 'legacy_origin_replant.v1';
export const DESKTOP_LEGACY_HARVEST_FAILURE_MARKER_KEY = 'legacy_origin_harvest.failures';
export const DESKTOP_LEGACY_HARVEST_MAX_FAILURES = 5;
export const DESKTOP_LEGACY_RAW_STORAGE_SEED_MARKER_KEY = 'fluxer:desktop:legacy-raw-storage-seed.v1';
export const DESKTOP_LEGACY_RAW_STORAGE_SEED_MARKER_VALUE = '1';
export const DESKTOP_LEGACY_HARVEST_VALUE_ENCODING_KEY = '__fluxerLegacyHarvestValue';
export const DESKTOP_LEGACY_HARVEST_VALUE_ENCODING_VERSION = 1;
export const DESKTOP_LEGACY_HARVEST_BLOB_REF_KEY = '__blobRef';
export const DESKTOP_LEGACY_HARVEST_BLOB_BYTES_KEY = 'bytes';
export const DESKTOP_LEGACY_HARVEST_BLOB_TRANSPORT_TYPE = 'blob';
export const DesktopLegacyHarvestEncodedValueType = Object.freeze({
ARRAY_BUFFER: 'array-buffer',
BIGINT: 'bigint',
DATE: 'date',
MAP: 'map',
NUMBER: 'number',
OBJECT: 'object',
REGEXP: 'regexp',
SET: 'set',
TYPED_ARRAY: 'typed-array',
UNDEFINED: 'undefined',
} as const);
export const DesktopLegacyHarvestEncodedNumber = Object.freeze({
NEGATIVE_INFINITY: '-infinity',
NEGATIVE_ZERO: '-zero',
NOT_A_NUMBER: 'nan',
POSITIVE_INFINITY: 'infinity',
} as const);
export const DesktopLegacyHarvestStatus = Object.freeze({
PENDING: 'pending',
HARVESTED: 'harvested',
EMPTY: 'empty',
ABANDONED: 'abandoned',
} as const);
export type DesktopLegacyHarvestStatus = (typeof DesktopLegacyHarvestStatus)[keyof typeof DesktopLegacyHarvestStatus];
const DESKTOP_LEGACY_REPLANT_MARKER_VERSION = 1;
export function desktopLegacyReplantMarker(replantedAt: number): string {
return JSON.stringify({version: DESKTOP_LEGACY_REPLANT_MARKER_VERSION, replantedAt});
}
export function isDesktopLegacyReplantMarker(value: string | null): boolean {
if (value === null) return false;
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return false;
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
const record = parsed as {version?: unknown; replantedAt?: unknown};
return (
record.version === DESKTOP_LEGACY_REPLANT_MARKER_VERSION &&
typeof record.replantedAt === 'number' &&
Number.isFinite(record.replantedAt) &&
record.replantedAt > 0
);
}
export interface DesktopLegacyHarvestRecord {
readonly key: unknown | null;
readonly value: unknown;
}
export interface DesktopLegacyHarvestStore {
readonly database: string;
readonly version: number;
readonly store: string;
readonly records: ReadonlyArray<DesktopLegacyHarvestRecord>;
}
export interface DesktopLegacyHarvest {
readonly version: 1;
readonly origin: string;
readonly capturedAt: number;
readonly localStorage: Readonly<Record<string, string>>;
readonly stores: ReadonlyArray<DesktopLegacyHarvestStore>;
readonly mediaDevices?: ReadonlyArray<DesktopLegacyHarvestMediaDevice>;
readonly serviceWorkersUnregistered: number;
readonly cachesDeleted: number;
readonly truncated: ReadonlyArray<string>;
}
export interface DesktopLegacyHarvestMediaDevice {
readonly deviceId: string;
readonly kind: string;
readonly label: string;
readonly groupId?: string;
}
@@ -0,0 +1,289 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const LEGACY_SKELETON_LAYOUT_MEMORY_KEY = 'SkeletonLayoutMemory';
const LEGACY_SIDEBAR_WIDTH_KEY = 'fluxer:ui:sidebar-width';
const DM_ROOT_PATHNAME = '/channels/@me';
export const LEGACY_SHELL_HINT_SEED_VERSION = 4;
export const LEGACY_SHELL_HINT_NAGBAR_TONE_ORDER: ReadonlyArray<string> = Object.freeze([
'neutral',
'maintenance',
'maintenance_scheduled',
'maintenance_active',
'maintenance_completed',
'brand',
'danger',
'alert',
'premium',
'legal',
'voice',
'critical',
'development',
'streamer',
'encoder',
]);
const RAIL_ITEM_INDICATOR_CODES: Readonly<Record<string, number>> = Object.freeze({none: 0, unread: 1, mention: 2});
const RAIL_ITEM_KIND_GUILD = 'guild';
const RAIL_ITEM_KIND_COLLAPSED_FOLDER = 'collapsed_folder';
const RAIL_ITEM_KIND_EXPANDED_FOLDER = 'expanded_folder';
const RAIL_ITEM_GUILD_CODE = 0;
const RAIL_ITEM_COLLAPSED_FOLDER_CODE = 1;
const RAIL_ITEM_EXPANDED_FOLDER_CODE = 2;
const RAIL_BOTTOM_DISCOVERY_BIT = 1;
const RAIL_BOTTOM_ADD_GUILD_BIT = 2;
const RAIL_BOTTOM_DOWNLOAD_BIT = 4;
const RAIL_BOTTOM_HELP_BIT = 8;
const DM_ACTION_FRIENDS_BIT = 1;
const DM_ACTION_PERSONAL_NOTES_BIT = 2;
const DM_ACTION_PREMIUM_BIT = 4;
const MAX_RAIL_INLINE_DM_ROWS = 16;
const MAX_RAIL_ORGANIZED_ITEMS = 24;
const MAX_RAIL_COLLAPSED_FOLDER_CHILDREN = 4;
const MAX_RAIL_SCROLL_TOP_PX = 100_000;
const MAX_DM_SIDEBAR_ROWS = 40;
const MAX_NAGBAR_ROWS = 3;
const MAX_COMPOSER_ACTION_COUNT = 8;
const MAX_ACTIVE_NOW_CARDS = 24;
const MAX_MESSAGE_METRIC_PX = 256;
const MIN_FONT_SIZE_PX = 8;
const MAX_FONT_SIZE_PX = 64;
const MAX_VIEWPORT_HEIGHT_PX = 20_000;
const FALLBACK_RAIL_GUILD_COUNT = 6;
const CHANNEL_KIND_DM_CODE = 2;
const UNSET_ACTION_COUNT = -1;
const MIN_SCALED_BANNER_ASPECT_RATIO = 250;
const MIN_SIDEBAR_WIDTH_PX = 200;
const MAX_SIDEBAR_WIDTH_PX = 480;
const MAX_ACCOUNT_KEY_LENGTH = 256;
const NO_SELECTED_ROW_INDEX = -1;
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function parseRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== 'string') return null;
try {
const parsed: unknown = JSON.parse(value);
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function flag(value: unknown): number {
return value === true ? 1 : 0;
}
function flags(value: unknown, limit: number): Array<number> {
return Array.isArray(value) ? value.slice(0, limit).map(flag) : [];
}
function clampRounded(value: unknown, min: number, max: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, Math.round(value)));
}
function indicatorCode(value: unknown): number {
return (typeof value === 'string' ? RAIL_ITEM_INDICATOR_CODES[value] : undefined) ?? 0;
}
function encodeRailItem(item: unknown): Array<number> | null {
if (!isRecord(item)) return null;
const indicator = indicatorCode(item.indicator);
if (item.kind === RAIL_ITEM_KIND_GUILD) {
return [RAIL_ITEM_GUILD_CODE, indicator];
}
if (item.kind === RAIL_ITEM_KIND_COLLAPSED_FOLDER) {
return [
RAIL_ITEM_COLLAPSED_FOLDER_CODE,
indicator,
clampRounded(item.childCount, 0, MAX_RAIL_COLLAPSED_FOLDER_CHILDREN),
flag(item.showIconWhenCollapsed),
];
}
if (item.kind === RAIL_ITEM_KIND_EXPANDED_FOLDER) {
const childCount = clampRounded(item.childCount, 0, MAX_RAIL_ORGANIZED_ITEMS - 1);
const childIndicators = Array.isArray(item.childIndicators) ? item.childIndicators : [];
const encoded = [
RAIL_ITEM_EXPANDED_FOLDER_CODE,
indicator,
childCount,
clampRounded(item.selectedChildIndex, NO_SELECTED_ROW_INDEX, childCount - 1),
];
for (let index = 0; index < childCount; index++) {
encoded.push(indicatorCode(childIndicators[index]));
}
return encoded;
}
return null;
}
function seedGuildRail(hint: Record<string, unknown>, rail: unknown): void {
if (!isRecord(rail) || !Array.isArray(rail.organizedItems)) return;
const items: Array<Array<number>> = [];
for (const item of rail.organizedItems.slice(0, MAX_RAIL_ORGANIZED_ITEMS)) {
const encoded = encodeRailItem(item);
if (encoded === null) return;
items.push(encoded);
}
const inlineDmRowCount = clampRounded(rail.inlineDmRowCount, 0, MAX_RAIL_INLINE_DM_ROWS);
const unread = Array.isArray(rail.inlineDmUnreadFlags) ? rail.inlineDmUnreadFlags : [];
let bottomMask = 0;
if (rail.discoveryVisible === true) bottomMask |= RAIL_BOTTOM_DISCOVERY_BIT;
if (rail.addGuildVisible === true) bottomMask |= RAIL_BOTTOM_ADD_GUILD_BIT;
if (rail.downloadVisible === true) bottomMask |= RAIL_BOTTOM_DOWNLOAD_BIT;
if (rail.helpVisible === true) bottomMask |= RAIL_BOTTOM_HELP_BIT;
hint.rf = flag(rail.fluxerVisible);
hint.rti = rail.fluxerVisible === true ? 0 : NO_SELECTED_ROW_INDEX;
hint.rv = flag(rail.favoritesVisible);
hint.ru = Array.from({length: inlineDmRowCount}, (_unused, index) => flag(unread[index]));
hint.ro = flag(rail.outageVisible);
hint.ri = items;
hint.rbm = bottomMask;
hint.rs = clampRounded(rail.scrollTopPx, 0, MAX_RAIL_SCROLL_TOP_PX);
}
function seedDMSidebar(hint: Record<string, unknown>, sidebar: unknown): void {
if (!isRecord(sidebar) || sidebar.isMobile !== false) return;
let actionMask = 0;
if (sidebar.friendsVisible === true) actionMask |= DM_ACTION_FRIENDS_BIT;
if (sidebar.personalNotesVisible === true) actionMask |= DM_ACTION_PERSONAL_NOTES_BIT;
if (sidebar.premiumVisible === true) actionMask |= DM_ACTION_PREMIUM_BIT;
hint.sr = clampRounded(sidebar.channelRowCount, 0, MAX_DM_SIDEBAR_ROWS);
hint.sam = actionMask;
hint.ss = flag(sidebar.sectionVisible);
hint.sb = flags(sidebar.channelSubtextFlags, MAX_DM_SIDEBAR_ROWS);
}
function seedNagbar(hint: Record<string, unknown>, nagbar: unknown): void {
if (!isRecord(nagbar) || !Array.isArray(nagbar.rows)) return;
const rows: Array<Array<number>> = [];
for (const row of nagbar.rows.slice(0, MAX_NAGBAR_ROWS)) {
if (!isRecord(row) || typeof row.tone !== 'string') return;
const tone = LEGACY_SHELL_HINT_NAGBAR_TONE_ORDER.indexOf(row.tone);
if (tone < 0) return;
rows.push([tone, flag(row.hasActions), flag(row.dismissible)]);
}
hint.nr = rows;
}
function seedSidebarWidth(hint: Record<string, unknown>, value: unknown): void {
if (typeof value !== 'string' || value === '') return;
const width = Number(value);
if (!Number.isFinite(width)) return;
hint.sw = clampRounded(width, MIN_SIDEBAR_WIDTH_PX, MAX_SIDEBAR_WIDTH_PX);
}
function seedChannelHeader(hint: Record<string, unknown>, header: unknown): void {
if (!isRecord(header)) return;
hint.cfv = flag(header.favoritesVisible);
hint.cst = flag(header.staffToolsVisible);
hint.cuv = flag(header.updaterVisible);
}
function seedFriends(hint: Record<string, unknown>, friends: unknown): void {
if (!isRecord(friends) || friends.rowCount === undefined) return;
const visible = friends.activeNowVisible === true;
const count = visible ? clampRounded(friends.activeNowCardCount, 0, MAX_ACTIVE_NOW_CARDS) : 0;
hint.fa = flag(visible);
hint.fn = Array.from({length: count}, () => [0, 0]);
}
function seedComposer(hint: Record<string, unknown>, composer: unknown): void {
if (!isRecord(composer)) return;
hint.co = clampRounded(composer.desktopActionCount, 0, MAX_COMPOSER_ACTION_COUNT);
hint.com = clampRounded(composer.mobileActionCount, 0, MAX_COMPOSER_ACTION_COUNT);
hint.cdv = flag(composer.sendDividerVisible);
}
function seedMessagePresentation(hint: Record<string, unknown>, message: unknown): void {
if (!isRecord(message)) return;
hint.pc = flag(message.compact);
hint.pg = clampRounded(message.messageGutterPx, 0, MAX_MESSAGE_METRIC_PX);
hint.pf = clampRounded(message.fontSizePx, MIN_FONT_SIZE_PX, MAX_FONT_SIZE_PX);
hint.ps = clampRounded(message.groupSpacingPx, 0, MAX_MESSAGE_METRIC_PX);
hint.pa = flag(message.compactAvatarsVisible);
hint.pt = clampRounded(message.compactTimestampWidthPx, 0, MAX_MESSAGE_METRIC_PX);
if (typeof message.viewportHeightPx === 'number') {
hint.pv = clampRounded(message.viewportHeightPx, 0, MAX_VIEWPORT_HEIGHT_PX);
}
}
function dmRootShellHint(accountKey: string, now: number): Record<string, unknown> {
return {
v: LEGACY_SHELL_HINT_SEED_VERSION,
t: now,
p: DM_ROOT_PATHNAME,
a: accountKey.slice(0, MAX_ACCOUNT_KEY_LENGTH),
mo: 0,
ua: 0,
fs: 0,
sw: 0,
sv: 'dm',
ck: 'other',
cb: 1,
rf: 1,
rv: 1,
ru: [],
rti: 0,
ro: 0,
ri: Array.from({length: FALLBACK_RAIL_GUILD_COUNT}, () => [RAIL_ITEM_GUILD_CODE, 0]),
rgi: NO_SELECTED_ROW_INDEX,
rbm: RAIL_BOTTOM_DISCOVERY_BIT | RAIL_BOTTOM_ADD_GUILD_BIT | RAIL_BOTTOM_HELP_BIT,
rbi: NO_SELECTED_ROW_INDEX,
rs: 0,
gb: 0,
ga: MIN_SCALED_BANNER_ASPECT_RATIO,
gn: 0,
gd: 0,
gm: 0,
sr: 0,
sam: DM_ACTION_FRIENDS_BIT | DM_ACTION_PERSONAL_NOTES_BIT | DM_ACTION_PREMIUM_BIT,
ss: 1,
cid: '',
ch: CHANNEL_KIND_DM_CODE,
ct: 0,
cnw: 0,
ctw: 0,
cda: UNSET_ACTION_COUNT,
cma: UNSET_ACTION_COUNT,
cfv: 1,
cst: 0,
cuv: 0,
ml: 0,
co: 3,
com: 2,
cdv: 0,
pc: 0,
pg: 16,
pf: 16,
ps: 16,
pa: 0,
pt: 56,
nr: [],
vh: 0,
vc: 0,
mr: 0,
ms: 0,
mn: 0,
};
}
export function legacyShellHintSeed(raw: Record<string, unknown>, accountKey: string, now: number): string | null {
const memory = parseRecord(raw[LEGACY_SKELETON_LAYOUT_MEMORY_KEY]);
if (memory === null) return null;
const hint = dmRootShellHint(accountKey, now);
const chrome = isRecord(memory.chrome) ? memory.chrome : {};
seedGuildRail(hint, chrome.guildRail);
seedDMSidebar(hint, chrome.dmSidebar);
seedNagbar(hint, memory.nagbar);
seedChannelHeader(hint, memory.channelHeader);
seedFriends(hint, memory.friends);
seedComposer(hint, memory.composer);
seedMessagePresentation(hint, memory.messagePresentation);
seedSidebarWidth(hint, raw[LEGACY_SIDEBAR_WIDTH_KEY]);
return JSON.stringify(hint);
}
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const DESKTOP_LOCAL_APP_SCHEME = 'fluxer-app';
export const DESKTOP_LOCAL_APP_PROTOCOL = `${DESKTOP_LOCAL_APP_SCHEME}:`;
export const DESKTOP_LOCAL_APP_HOST = 'app';
export const DESKTOP_LOCAL_APP_ORIGIN = `${DESKTOP_LOCAL_APP_PROTOCOL}//${DESKTOP_LOCAL_APP_HOST}`;
export const DESKTOP_LOCAL_APP_URL = `${DESKTOP_LOCAL_APP_ORIGIN}/`;
export const LOCAL_APP_API_PATH_PREFIX = '/api';
export const LOCAL_APP_REMOTE_PROXY_PATH_PREFIX = '/proxy';
export const LOCAL_APP_UPLOAD_RELAY_PATH_SUFFIX = '/relay';
export const LOCAL_APP_REMOTE_PROXY_URL_PARAMETER = 'url';
export const LOCAL_APP_UPLOAD_RELAY_TOKEN_PARAMETER = 't';
export const LOCAL_APP_UPLOAD_ID_HEADER = 'X-Fluxer-Local-Upload-Id';
export const LOCAL_APP_UPLOAD_PROGRESS_CHANNELS = Object.freeze({
subscribe: 'desktop-local-app-upload:subscribe',
progress: 'desktop-local-app-upload:progress',
} as const);
export interface DesktopLocalAppUploadProgress {
readonly uploadId: string;
readonly loaded: number;
readonly total: number | null;
readonly done: boolean;
readonly failed: boolean;
}
@@ -0,0 +1,96 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const DESKTOP_RUNTIME_CONFIG_CHANNELS = Object.freeze({
initialInput: 'desktop-runtime-config:initial-input',
resolve: 'desktop-runtime-config:resolve',
cancelResolution: 'desktop-runtime-config:cancel-resolution',
prepare: 'desktop-runtime-config:prepare',
commit: 'desktop-runtime-config:commit',
abort: 'desktop-runtime-config:abort',
finalize: 'desktop-runtime-config:finalize',
rollback: 'desktop-runtime-config:rollback',
deactivate: 'desktop-runtime-config:deactivate',
} as const);
export const DESKTOP_RUNTIME_DISCOVERY_UNREACHABLE_ERROR_NAME = 'DesktopRuntimeDiscoveryUnreachableError';
export interface DesktopRuntimePlan {
readonly instanceKey: string;
readonly apiEndpoint: string;
readonly remoteApiEndpoint: string;
readonly document: unknown;
}
export interface DesktopRuntimePreparation extends DesktopRuntimePlan {
readonly preparationId: string;
readonly baseRevision: number;
readonly baseActiveInstanceKey: string | null;
}
export interface DesktopRuntimeCommit extends DesktopRuntimeTransactionRequest {
readonly revision: number;
}
export type DesktopRuntimeFinalization = DesktopRuntimeCommittedTransactionRequest;
export type DesktopRuntimeAbortDisposition = 'absent' | 'aborted' | 'rolled-back';
export interface DesktopRuntimeAbort {
readonly disposition: DesktopRuntimeAbortDisposition;
readonly revision: number;
readonly activeInstanceKey: string | null;
}
export interface DesktopRuntimeRollback {
readonly revision: number;
readonly activeInstanceKey: string | null;
}
export interface DesktopRuntimeDeactivation {
readonly revision: number;
readonly deactivatedInstanceKey: string;
}
export interface DesktopRuntimeDeactivateRequest {
readonly rendererActiveInstanceKey: string;
}
export interface DesktopRuntimeResolveRequest {
readonly input: string;
readonly requestId: string;
}
export interface DesktopRuntimeCancelRequest {
readonly requestId: string;
}
export interface DesktopRuntimePrepareRequest {
readonly preparationId: string;
readonly instanceKey: string;
}
export interface DesktopRuntimeAbortRequest {
readonly preparationId: string;
}
export interface DesktopRuntimeTransactionRequest {
readonly preparationId: string;
readonly instanceKey: string;
readonly baseRevision: number;
}
export interface DesktopRuntimeCommittedTransactionRequest extends DesktopRuntimeTransactionRequest {
readonly committedRevision: number;
}
export interface DesktopRuntimeConfigAPI {
initialInput: () => Promise<string | null>;
resolve: (request: DesktopRuntimeResolveRequest) => Promise<DesktopRuntimePlan>;
cancelResolution: (request: DesktopRuntimeCancelRequest) => Promise<void>;
prepare: (request: DesktopRuntimePrepareRequest) => Promise<DesktopRuntimePreparation>;
commit: (request: DesktopRuntimeTransactionRequest) => Promise<DesktopRuntimeCommit>;
abort: (request: DesktopRuntimeAbortRequest) => Promise<DesktopRuntimeAbort>;
finalize: (request: DesktopRuntimeCommittedTransactionRequest) => Promise<DesktopRuntimeFinalization>;
rollback: (request: DesktopRuntimeCommittedTransactionRequest) => Promise<DesktopRuntimeRollback>;
deactivate: (request: DesktopRuntimeDeactivateRequest) => Promise<DesktopRuntimeDeactivation>;
}
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const DESKTOP_MODULE_CHANNELS = Object.freeze({
ensure: 'desktop-modules:ensure',
pendingUpdate: 'desktop-modules:pending-update',
applyPendingUpdate: 'desktop-modules:apply-pending-update',
confirmLaunch: 'desktop-modules:confirm-launch',
} as const);
export const DESKTOP_MODULE_EVENTS = Object.freeze({
pendingUpdateChanged: 'desktop-modules:pending-update-changed',
} as const);
const DESKTOP_MODULE_NAME_PATTERN = /^[a-z][a-z0-9_]{0,63}$/u;
export const DESKTOP_GRAMMAR_MODULE_NAME = 'fluxer_grammars';
export const DESKTOP_TWEMOJI_MODULE_NAME = 'fluxer_twemoji';
export const DESKTOP_DEEP_FILTER_MODULE_NAME = 'fluxer_deepfilter';
export const DESKTOP_EMOJI_SPRITES_MODULE_NAME = 'fluxer_emoji_sprites';
export const DESKTOP_EXTRAS_MODULE_NAME = 'fluxer_extras';
export const DESKTOP_SOURCEMAP_MODULE_NAME = 'fluxer_sourcemaps';
export const DESKTOP_FONT_MODULE_NAMES = Object.freeze({
'non-latin': 'fluxer_fonts_nonlatin',
sc: 'fluxer_fonts_sc',
tc: 'fluxer_fonts_tc',
jp: 'fluxer_fonts_jp',
kr: 'fluxer_fonts_kr',
} as const);
export type DesktopFontScript = keyof typeof DESKTOP_FONT_MODULE_NAMES;
export const DesktopModuleEnsureStatus = Object.freeze({
INSTALLED: 'installed',
ALREADY_INSTALLED: 'already-installed',
UNAVAILABLE: 'unavailable',
DISABLED: 'disabled',
} as const);
export type DesktopModuleEnsureStatus = (typeof DesktopModuleEnsureStatus)[keyof typeof DesktopModuleEnsureStatus];
export interface DesktopModuleEnsureResult {
readonly module: string;
readonly status: DesktopModuleEnsureStatus;
}
export interface DesktopPendingModuleUpdate {
readonly modules: ReadonlyArray<string>;
}
export interface DesktopModuleAPI {
ensure: (moduleName: string) => Promise<DesktopModuleEnsureResult>;
pendingUpdate?: () => Promise<DesktopPendingModuleUpdate | null>;
applyPendingUpdate?: () => Promise<boolean>;
onPendingUpdateChanged?: (listener: (pending: DesktopPendingModuleUpdate | null) => void) => () => void;
confirmLaunch?: () => Promise<void>;
}
export function isDesktopModuleName(value: unknown): value is string {
return typeof value === 'string' && DESKTOP_MODULE_NAME_PATTERN.test(value);
}
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const NATIVE_TITLEBAR_CONTROL_ATTR = 'data-window-control';
export type NativeTitlebarControlName = 'minimize' | 'maximize' | 'close';
export type NativeTitlebarLayout = 'macos' | 'controls';
export const NATIVE_TITLEBAR_CLASS = {
root: 'fluxer-native-titlebar',
left: 'fluxer-native-titlebar__left',
wordmark: 'fluxer-native-titlebar__wordmark',
spacer: 'fluxer-native-titlebar__spacer',
controls: 'fluxer-native-titlebar__controls',
control: 'fluxer-native-titlebar__control',
controlClose: 'fluxer-native-titlebar__control--close',
} as const;
export const NATIVE_TITLEBAR_WORDMARK_VIEWBOX = '0 0 1374 366';
export const NATIVE_TITLEBAR_WORDMARK_PATHS: ReadonlyArray<string> = [
'M0 358.4V20.48h82.944V358.4H0Zm41.472-120.32v-67.584h172.032v67.584H41.472Zm0-148.48V20.48h189.44V89.6H41.472ZM258.951 358.4V0h82.944v358.4h-82.944ZM462.92 365.568c-29.35 0-51.2-10.24-65.536-30.72-13.995-20.48-20.992-51.883-20.992-94.208V88.064h82.948v151.552c0 19.797 2.73 33.621 8.19 41.472 5.8 7.851 13.99 11.776 24.57 11.776 7.51 0 14.34-1.877 20.48-5.632 6.49-4.096 12.12-10.069 16.9-17.92 4.78-8.192 8.36-18.603 10.75-31.232 2.73-12.629 4.1-27.819 4.1-45.568V88.064h82.94V358.4h-70.65V252.928h-3.59c-2.05 26.283-6.65 47.787-13.82 64.512-7.17 16.384-17.07 28.501-29.7 36.352-12.63 7.851-28.16 11.776-46.59 11.776ZM647.569 358.4l84.99-135.68-83.97-134.656h96.26l39.42 87.552h2.56l38.4-87.552h95.23l-81.4 134.656 82.43 135.68h-97.79l-37.38-86.016h-2.05l-41.47 86.016h-95.23Z',
'M1045.96 365.568c-25.6 0-47.101-3.584-64.511-10.752-17.06-7.509-30.89-17.579-41.47-30.208-10.58-12.971-18.26-27.648-23.04-44.032-4.44-16.384-6.66-33.621-6.66-51.712 0-19.456 2.39-38.059 7.17-55.808 5.12-17.749 12.8-33.451 23.04-47.104 10.58-13.995 24.07-24.917 40.45-32.768 16.73-8.192 36.691-12.288 59.901-12.288s43.01 4.096 59.4 12.288c16.72 7.851 30.03 18.944 39.93 33.28 9.9 14.336 16.22 30.891 18.95 49.664 3.07 18.773 2.73 39.083-1.03 60.928l-196.091 3.072v-45.056l132.601-2.56-10.75 26.112c2.05-15.701 1.71-28.843-1.02-39.424-2.39-10.923-7-19.115-13.83-24.576-6.82-5.803-16.21-8.704-28.16-8.704-12.63 0-22.69 3.243-30.2 9.728-7.51 6.485-12.801 15.701-15.881 27.648-3.07 11.605-4.6 25.429-4.6 41.472 0 27.648 4.6 47.787 13.821 60.416 9.22 12.629 23.38 18.944 42.5 18.944 8.19 0 15.01-1.024 20.48-3.072 5.46-2.048 9.89-4.949 13.31-8.704 3.41-4.096 5.8-8.875 7.17-14.336 1.36-5.803 1.87-12.288 1.53-19.456l75.78 4.096c1.02 11.264-.17 22.869-3.59 34.816-3.07 11.947-9.04 23.04-17.92 33.28-8.87 10.24-21.33 18.603-37.37 25.088-15.7 6.485-35.67 9.728-59.91 9.728ZM1193.45 358.4V88.0639h71.68V195.584h4.1c2.05-28.672 5.97-51.2 11.77-67.584 6.15-16.725 13.66-28.5011 22.53-35.3281 9.22-7.168 19.46-10.752 30.72-10.752 6.15 0 12.46.853 18.95 2.56 6.82 1.707 13.48 4.437 19.96 8.192l-4.09 92.1601c-7.51-4.437-14.85-7.68-22.02-9.728-7.17-2.389-13.99-3.584-20.48-3.584-10.92 0-20.14 3.072-27.65 9.216-7.51 6.144-13.31 15.189-17.4 27.136-3.76 11.947-5.64 26.453-5.64 43.52V358.4h-82.43Z',
];
export const NATIVE_TITLEBAR_CONTROL_ICON_PATH = {
minimize: 'M228,128a12,12,0,0,1-12,12H40a12,12,0,0,1,0-24H216A12,12,0,0,1,228,128Z',
maximize:
'M208,28H48A20,20,0,0,0,28,48V208a20,20,0,0,0,20,20H208a20,20,0,0,0,20-20V48A20,20,0,0,0,208,28Zm-4,176H52V52H204Z',
restore:
'M180,64H40A12,12,0,0,0,28,76V216a12,12,0,0,0,12,12H180a12,12,0,0,0,12-12V76A12,12,0,0,0,180,64ZM168,204H52V88H168ZM228,40V180a12,12,0,0,1-24,0V52H76a12,12,0,0,1,0-24H216A12,12,0,0,1,228,40Z',
close:
'M208.49,191.51a12,12,0,0,1-17,17L128,145,64.49,208.49a12,12,0,0,1-17-17L111,128,47.51,64.49a12,12,0,0,1,17-17L128,111l63.51-63.52a12,12,0,0,1,17,17L145,128Z',
} as const;
export type NativeTitlebarControlIconName = keyof typeof NATIVE_TITLEBAR_CONTROL_ICON_PATH;
export function nativeTitlebarControlIconSvg(icon: NativeTitlebarControlIconName): string {
return (
'<svg viewBox="0 0 256 256" fill="currentColor" xmlns="http://www.w3.org/2000/svg">' +
`<path d="${NATIVE_TITLEBAR_CONTROL_ICON_PATH[icon]}"/></svg>`
);
}
export function nativeTitlebarWordmarkSvg(className: string = NATIVE_TITLEBAR_CLASS.wordmark): string {
const paths = NATIVE_TITLEBAR_WORDMARK_PATHS.map((d) => `<path fill="currentColor" d="${d}"/>`).join('');
return (
`<svg class="${className}" role="img" aria-label="Fluxer" xmlns="http://www.w3.org/2000/svg" ` +
`fill="none" viewBox="${NATIVE_TITLEBAR_WORDMARK_VIEWBOX}">${paths}</svg>`
);
}
interface StartupControlLabels {
readonly minimize: string;
readonly maximize: string;
readonly close: string;
}
const DEFAULT_STARTUP_CONTROL_LABELS: StartupControlLabels = {
minimize: 'Minimize window',
maximize: 'Maximize window',
close: 'Close window',
};
function controlButtonHtml(
name: NativeTitlebarControlName,
icon: NativeTitlebarControlIconName,
label: string,
extraClass?: string,
): string {
const className = extraClass ? `${NATIVE_TITLEBAR_CLASS.control} ${extraClass}` : NATIVE_TITLEBAR_CLASS.control;
return (
`<button type="button" tabindex="-1" class="${className}" ${NATIVE_TITLEBAR_CONTROL_ATTR}="${name}" ` +
`aria-label="${label}">${nativeTitlebarControlIconSvg(icon)}</button>`
);
}
export function nativeTitlebarControlsHtml(labels: StartupControlLabels = DEFAULT_STARTUP_CONTROL_LABELS): string {
return (
`<div class="${NATIVE_TITLEBAR_CLASS.controls}">` +
controlButtonHtml('minimize', 'minimize', labels.minimize) +
controlButtonHtml('maximize', 'maximize', labels.maximize) +
controlButtonHtml('close', 'close', labels.close, NATIVE_TITLEBAR_CLASS.controlClose) +
'</div>'
);
}
export function buildNativeTitlebarStartupHtml(layout: NativeTitlebarLayout): string {
const brand = `<div class="${NATIVE_TITLEBAR_CLASS.left}">${nativeTitlebarWordmarkSvg()}</div>`;
const spacer = `<div class="${NATIVE_TITLEBAR_CLASS.spacer}"></div>`;
if (layout === 'macos') {
return `${spacer}${brand}`;
}
return `${brand}${spacer}${nativeTitlebarControlsHtml()}`;
}
+117
View File
@@ -0,0 +1,117 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {DesktopStoreMarker, DesktopStoreSkippedRecord} from '@fluxer/desktop_ipc/src/AccountContract';
export const DESKTOP_STORAGE_CHANNELS = Object.freeze({
getStatus: 'desktop-storage:get-status',
load: 'desktop-storage:load',
get: 'desktop-storage:get',
set: 'desktop-storage:set',
delete: 'desktop-storage:delete',
clearAllForScope: 'desktop-storage:clear-all-for-scope',
clearAllExcept: 'desktop-storage:clear-all-except',
setMany: 'desktop-storage:set-many',
import: 'desktop-storage:import',
getMarker: 'desktop-storage:get-marker',
setMarker: 'desktop-storage:set-marker',
} as const);
export const ZOOM_PREBOOT_MIRROR_STORAGE_KEY = 'fluxer:accessibility:zoom-preboot';
export const GATEWAY_PREBOOT_SESSION_STORAGE_KEY = 'fluxer:gateway:preboot:session';
export const GATEWAY_PREBOOT_SESSION_PRESENT = '1';
export const SHELL_HINT_STORAGE_KEY = 'fluxer:ui:shell-hint';
export const DESKTOP_LEGACY_IMPORT_MARKER_KEY = 'legacy_import.v1';
export const DESKTOP_LEGACY_IMPORT_FAILURE_MARKER_KEY = 'legacy_import.failures';
export const DESKTOP_LEGACY_SESSION_MARKER_KEY = 'legacy_session.reconciled_at';
export const DESKTOP_LEGACY_AUTHORITY_MARKER_KEY = 'legacy_import.authority';
export const DESKTOP_LEGACY_AUTHORITY_MARKER_VALUE = 'committed.v1';
const DESKTOP_LEGACY_IMPORT_VERSION = 1;
export const DesktopLegacyImportPhase = Object.freeze({
ACCOUNTS: 'accounts',
ENTRIES: 'entries',
SESSION: 'session',
DONE: 'done',
} as const);
export type DesktopLegacyImportPhase = (typeof DesktopLegacyImportPhase)[keyof typeof DesktopLegacyImportPhase];
export function desktopLegacyImportMarker(phase: DesktopLegacyImportPhase, updatedAt: number): DesktopStoreMarker {
return {
key: DESKTOP_LEGACY_IMPORT_MARKER_KEY,
value: JSON.stringify({version: DESKTOP_LEGACY_IMPORT_VERSION, phase, updatedAt}),
};
}
export function readDesktopLegacyImportPhase(value: string | null): DesktopLegacyImportPhase | null {
if (value == null) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return null;
}
if (parsed == null || typeof parsed !== 'object') {
return null;
}
const {version, phase} = parsed as {version?: unknown; phase?: unknown};
if (version !== DESKTOP_LEGACY_IMPORT_VERSION) {
return null;
}
return Object.values(DesktopLegacyImportPhase).find((candidate) => candidate === phase) ?? null;
}
export type DesktopStoreQuarantineReason = 'newer' | 'schema' | 'corrupt' | 'derivation';
export interface DesktopStoreStatus {
readonly available: boolean;
readonly authorityExpected: boolean;
readonly schemaVersion: number;
readonly quarantined: boolean;
readonly quarantineReason: DesktopStoreQuarantineReason | null;
readonly unavailableReason: string | null;
}
export interface DesktopStorageEntry {
readonly key: string;
readonly value: string;
readonly updatedAt: number;
}
export interface DesktopStorageScopedEntry extends DesktopStorageEntry {
readonly scope: string;
}
export interface DesktopStorageWrite {
readonly scope: string;
readonly key: string;
readonly value: string;
readonly ifAbsent?: boolean;
}
export interface DesktopStorageImportRequest {
readonly entries: ReadonlyArray<DesktopStorageScopedEntry>;
readonly marker?: DesktopStoreMarker | null;
}
export interface DesktopStorageImportReport {
readonly imported: number;
readonly skipped: ReadonlyArray<DesktopStoreSkippedRecord>;
}
export interface DesktopStorageAPI {
getStatus: () => Promise<DesktopStoreStatus>;
load: (scope: string) => Promise<Array<DesktopStorageEntry>>;
get: (scope: string, key: string) => Promise<DesktopStorageEntry | null>;
set: (scope: string, key: string, value: string) => Promise<void>;
delete: (scope: string, key: string) => Promise<void>;
clearAllForScope: (scope: string) => Promise<void>;
clearAllExcept: (keysToKeep: ReadonlyArray<string>) => Promise<void>;
setMany: (writes: ReadonlyArray<DesktopStorageWrite>) => Promise<void>;
import: (request: DesktopStorageImportRequest) => Promise<DesktopStorageImportReport>;
getMarker: (key: string) => Promise<string | null>;
setMarker: (key: string, value: string) => Promise<void>;
}
+14
View File
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export * from '@fluxer/desktop_ipc/src/AccountContract';
export * from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
export * from '@fluxer/desktop_ipc/src/CapabilityManifest';
export * from '@fluxer/desktop_ipc/src/GatewayTransportContract';
export * from '@fluxer/desktop_ipc/src/HTTPTransportContract';
export * from '@fluxer/desktop_ipc/src/KnownInstanceContract';
export * from '@fluxer/desktop_ipc/src/LastRouteContract';
export * from '@fluxer/desktop_ipc/src/LegacyHarvestContract';
export * from '@fluxer/desktop_ipc/src/LocalAppRouteContract';
export * from '@fluxer/desktop_ipc/src/LocalAppRuntimeContract';
export * from '@fluxer/desktop_ipc/src/ModuleContract';
export * from '@fluxer/desktop_ipc/src/StorageContract';
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfigs/package.json",
"compilerOptions": {},
"include": ["src/**/*"]
}
+1
View File
@@ -8,6 +8,7 @@
"./*": "./*"
},
"scripts": {
"test": "node --test 'src/**/*.test.mjs'",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -0,0 +1,164 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type InstanceDiscoveryDocument,
type InstanceGeneration,
InvalidInstanceDiscoveryDocumentError,
} from '@fluxer/instance_bootstrap/src/Discovery';
import type {
InstanceAgePolicy,
InstanceAppPublic,
InstanceCommunity,
InstanceDomainMigration,
InstanceFeatures,
InstanceGif,
InstancePush,
InstanceRegistration,
InstanceServices,
InstanceSso,
} from '@fluxer/instance_bootstrap/src/Types';
import type {LimitConfigSnapshot, LimitConfigWireFormat} from '@fluxer/limits/src/LimitTypes';
const DEFAULT_PREMIUM_PRODUCT_NAME = 'Plutonium';
export interface ClientRuntimeDiscoveryEndpoints {
readonly discoveryApiEndpoint: string;
readonly apiEndpoint: string;
readonly apiPublicEndpoint: string;
readonly gatewayEndpoint: string;
readonly mediaEndpoint: string;
readonly uploadRelayEndpoint: string | null;
readonly staticCdnEndpoint: string;
readonly marketingEndpoint: string;
readonly docsEndpoint: string | null;
readonly adminEndpoint: string;
readonly inviteEndpoint: string;
readonly giftEndpoint: string;
readonly webAppEndpoint: string;
}
export interface ClientRuntimeDiscovery {
readonly generation: InstanceGeneration;
readonly apiCodeVersion: number;
readonly endpoints: ClientRuntimeDiscoveryEndpoints;
readonly features: InstanceFeatures;
readonly gif: InstanceGif;
readonly sso: InstanceSso | null;
readonly registration: InstanceRegistration;
readonly community: InstanceCommunity;
readonly services: InstanceServices;
readonly limits: LimitConfigSnapshot | LimitConfigWireFormat;
readonly push: InstancePush;
readonly appPublic: InstanceAppPublic;
readonly agePolicy: InstanceAgePolicy | null;
readonly domainMigration: InstanceDomainMigration | null;
}
export function projectClientRuntimeDiscovery(document: InstanceDiscoveryDocument): ClientRuntimeDiscovery {
return {
generation: document.generation,
apiCodeVersion: requireValue(document.apiCodeVersion, 'api_code_version'),
endpoints: projectEndpoints(document),
features: projectFeatures(document.features),
gif: requireValue(document.gif, 'gif'),
sso: document.sso,
registration: requireValue(document.registration, 'registration'),
community: requireValue(document.community, 'community'),
services: requireValue(document.services, 'services'),
limits: requireValue(document.limits, 'limits'),
push: projectPush(document.push),
appPublic: projectAppPublic(document.appPublic),
agePolicy: document.agePolicy,
domainMigration: document.domainMigration,
};
}
function projectEndpoints(document: InstanceDiscoveryDocument): ClientRuntimeDiscoveryEndpoints {
const endpoints = document.endpoints;
return {
discoveryApiEndpoint: endpoints.api,
apiEndpoint: requireValue(endpoints.api_client, 'endpoints.api_client'),
apiPublicEndpoint: requireValue(endpoints.api_public, 'endpoints.api_public'),
gatewayEndpoint: endpoints.gateway,
mediaEndpoint: requireValue(endpoints.media, 'endpoints.media'),
uploadRelayEndpoint: endpoints.upload_relay,
staticCdnEndpoint: requireValue(endpoints.static_cdn, 'endpoints.static_cdn'),
marketingEndpoint: requireValue(endpoints.marketing, 'endpoints.marketing'),
docsEndpoint: endpoints.docs,
adminEndpoint: requireValue(endpoints.admin, 'endpoints.admin'),
inviteEndpoint: requireValue(endpoints.invite, 'endpoints.invite'),
giftEndpoint: requireValue(endpoints.gift, 'endpoints.gift'),
webAppEndpoint: requireValue(endpoints.webapp, 'endpoints.webapp'),
};
}
function projectFeatures(features: Readonly<Partial<InstanceFeatures>>): InstanceFeatures {
return {
...features,
voice_enabled: requireDefined(features.voice_enabled, 'features.voice_enabled'),
stripe_enabled: requireDefined(features.stripe_enabled, 'features.stripe_enabled'),
self_hosted: requireDefined(features.self_hosted, 'features.self_hosted'),
presigned_attachment_uploads: requireDefined(
features.presigned_attachment_uploads,
'features.presigned_attachment_uploads',
),
emails_enabled: requireDefined(features.emails_enabled, 'features.emails_enabled'),
premium_enabled: features.premium_enabled ?? !features.self_hosted,
stripe_serviceable: features.stripe_serviceable ?? false,
phone_verification_enabled: false,
};
}
function projectPush(push: Readonly<Partial<InstancePush>>): InstancePush {
return {
...push,
public_vapid_key: requireDefined(push.public_vapid_key, 'push.public_vapid_key'),
};
}
function projectAppPublic(appPublic: InstanceDiscoveryDocument['appPublic']): InstanceAppPublic {
const value = requireValue(appPublic, 'app_public');
return {
branding: {
product_name: requireDefined(value.branding.product_name, 'app_public.branding.product_name'),
icon_url: requireDefined(value.branding.icon_url, 'app_public.branding.icon_url'),
symbol_url: requireDefined(value.branding.symbol_url, 'app_public.branding.symbol_url'),
logo_url: requireDefined(value.branding.logo_url, 'app_public.branding.logo_url'),
wordmark_url: requireDefined(value.branding.wordmark_url, 'app_public.branding.wordmark_url'),
favicon_url: requireDefined(value.branding.favicon_url, 'app_public.branding.favicon_url'),
theme_color: requireDefined(value.branding.theme_color, 'app_public.branding.theme_color'),
status_page_url: value.branding.status_page_url ?? null,
status_page_incident_history_url: value.branding.status_page_incident_history_url ?? null,
premium_product_name: value.branding.premium_product_name ?? DEFAULT_PREMIUM_PRODUCT_NAME,
premium_info_url: value.branding.premium_info_url ?? null,
},
setup: {
configured: requireDefined(value.setup.configured, 'app_public.setup.configured'),
admin_url: requireDefined(value.setup.admin_url, 'app_public.setup.admin_url'),
},
legal: {
terms_url: requireDefined(value.legal.terms_url, 'app_public.legal.terms_url'),
privacy_url: requireDefined(value.legal.privacy_url, 'app_public.legal.privacy_url'),
},
registration: {
collect_date_of_birth: requireDefined(
value.registration.collect_date_of_birth,
'app_public.registration.collect_date_of_birth',
),
},
};
}
function requireValue<T>(value: T | null, path: string): T {
if (value === null) {
throw new InvalidInstanceDiscoveryDocumentError(`${path} is required by the client runtime`);
}
return value;
}
function requireDefined<T>(value: T | undefined, path: string): T {
if (value === undefined) {
throw new InvalidInstanceDiscoveryDocumentError(`${path} is required by the client runtime`);
}
return value;
}
@@ -0,0 +1,914 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AccountIdentityModes, TagStyles} from '@fluxer/constants/src/AccountIdentityConstants';
import {Headers as HttpHeader} from '@fluxer/constants/src/Headers';
import {HttpStatus, MimeType} from '@fluxer/constants/src/HttpConstants';
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
import {InstanceEndpointKind, normalizeInstanceEndpoint} from '@fluxer/instance_bootstrap/src/EndpointNormalization';
import {CanonicalNetworkProtocol} from '@fluxer/instance_bootstrap/src/NetworkOrigin';
import {
AGE_POLICY_ACTIONS,
CURRENT_INSTANCE_CODENAME,
type InstanceAgePolicy,
type InstanceAgePolicyGeo,
type InstanceAppRegistration,
type InstanceBranding,
type InstanceCommunity,
type InstanceDomainMigration,
type InstanceFeatures,
type InstanceGif,
type InstanceLegal,
type InstancePush,
type InstanceRegistration,
type InstanceServices,
type InstanceSetup,
type InstanceSso,
PUSH_DELIVERY_MODES,
REGISTRATION_MODES,
} from '@fluxer/instance_bootstrap/src/Types';
import type {LimitConfigSnapshot, LimitConfigWireFormat, LimitFilter, LimitRule} from '@fluxer/limits/src/LimitTypes';
import {EXPERIMENT_BUCKET_RESOLUTION} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
export const INSTANCE_DISCOVERY_PATH = '/.well-known/fluxer';
const INSTANCE_DISCOVERY_CONVENTIONAL_ENDPOINT_PATH = '/api';
export const INSTANCE_DISCOVERY_MAX_BYTES = 1024 * 1024;
export const INSTANCE_DISCOVERY_MAX_CHUNKS = 1024;
export const INSTANCE_DISCOVERY_TIMEOUT_MS = 15_000;
export const ACCOUNT_DELETION_GRACE_PERIOD_HOURS_MIN = 1;
export const ACCOUNT_DELETION_GRACE_PERIOD_HOURS_MAX = 8760;
export const MAX_BACKGROUND_GATEWAY_CONNECTIONS_MIN = 0;
export const MAX_BACKGROUND_GATEWAY_CONNECTIONS_MAX = 64;
export const LIMIT_RULES_MAX = 100;
export const AGE_POLICY_GEOS_MAX = 512;
export const DOMAIN_MIGRATION_ROLLOUT_BASIS_POINTS_MAX = EXPERIMENT_BUCKET_RESOLUTION;
export const DOMAIN_MIGRATION_ROLLOUT_SALT_MAX_LENGTH = 64;
const DOMAIN_MIGRATION_ROLLOUT_SALT_PATTERN = /^[\x20-\x7e]+$/u;
export const InstanceGeneration = Object.freeze({
TUNGSTEN: CURRENT_INSTANCE_CODENAME,
LEGACY: 'legacy',
} as const);
export type InstanceGeneration = (typeof InstanceGeneration)[keyof typeof InstanceGeneration];
const FEATURE_FLAG_KEYS = Object.freeze([
'voice_enabled',
'stripe_enabled',
'self_hosted',
'presigned_attachment_uploads',
'emails_enabled',
'premium_enabled',
'stripe_serviceable',
'phone_verification_enabled',
'desktop_modules_enabled',
] as const) satisfies ReadonlyArray<keyof InstanceFeatures>;
const BRANDING_URL_KEYS = Object.freeze([
'icon_url',
'symbol_url',
'logo_url',
'wordmark_url',
'favicon_url',
'status_page_url',
'status_page_incident_history_url',
'premium_info_url',
] as const) satisfies ReadonlyArray<keyof InstanceBranding>;
interface InstanceDiscoveryEndpoints {
readonly api: string;
readonly gateway: string;
readonly api_client: string | null;
readonly api_public: string | null;
readonly media: string | null;
readonly upload_relay: string | null;
readonly static_cdn: string | null;
readonly marketing: string | null;
readonly docs: string | null;
readonly admin: string | null;
readonly invite: string | null;
readonly gift: string | null;
readonly webapp: string | null;
}
interface InstanceDiscoveryAppPublic {
readonly branding: Readonly<Partial<InstanceBranding>>;
readonly setup: Readonly<Partial<InstanceSetup>>;
readonly legal: Readonly<Partial<InstanceLegal>>;
readonly registration: Readonly<Partial<InstanceAppRegistration>>;
}
export interface InstanceDiscoveryDocument {
readonly generation: InstanceGeneration;
readonly apiCodeVersion: number | null;
readonly endpoints: InstanceDiscoveryEndpoints;
readonly features: Readonly<Partial<InstanceFeatures>>;
readonly gif: InstanceGif | null;
readonly sso: InstanceSso | null;
readonly registration: InstanceRegistration | null;
readonly community: InstanceCommunity | null;
readonly services: InstanceServices | null;
readonly limits: LimitConfigSnapshot | LimitConfigWireFormat | null;
readonly push: Readonly<Partial<InstancePush>>;
readonly appPublic: InstanceDiscoveryAppPublic | null;
readonly agePolicy: InstanceAgePolicy | null;
readonly domainMigration: InstanceDomainMigration | null;
}
interface InstanceDiscoveryAttempt {
readonly url: string;
readonly reason: string;
}
export class InvalidInstanceDiscoveryDocumentError extends Error {
public readonly reason: string;
public constructor(reason: string) {
super(`Instance discovery document is unusable: ${reason}`);
this.name = 'InvalidInstanceDiscoveryDocumentError';
this.reason = reason;
}
}
export class InstanceRequiresNewerClientError extends Error {
public readonly reportedGeneration: string;
public constructor(reportedGeneration: string) {
super(`Instance speaks generation "${reportedGeneration}", which this client does not understand`);
this.name = 'InstanceRequiresNewerClientError';
this.reportedGeneration = reportedGeneration;
}
}
export class InstanceDiscoveryOriginMismatchError extends Error {
public readonly servedBy: string;
public readonly declared: string;
public constructor(servedBy: string, declared: string) {
super(`Instance discovery served by ${servedBy} declared an API at ${declared}`);
this.name = 'InstanceDiscoveryOriginMismatchError';
this.servedBy = servedBy;
this.declared = declared;
}
}
export class InstanceDiscoveryUnreachableError extends Error {
public readonly attempts: ReadonlyArray<InstanceDiscoveryAttempt>;
public constructor(attempts: ReadonlyArray<InstanceDiscoveryAttempt>) {
const described = attempts.map((attempt) => `${attempt.url} (${attempt.reason})`).join(', ');
super(`No discovery document was served by any candidate: ${described}`);
this.name = 'InstanceDiscoveryUnreachableError';
this.attempts = attempts;
}
}
export interface InstanceDiscoveryRequestInit {
readonly method: 'GET';
readonly headers: Record<string, string>;
readonly redirect: 'error';
readonly credentials: 'omit';
readonly cache: 'no-store';
readonly referrerPolicy: 'no-referrer';
readonly signal: AbortSignal;
}
export interface InstanceDiscoveryBodyReader {
read(): Promise<{done: boolean; value?: Uint8Array}>;
cancel(): Promise<void>;
}
interface InstanceDiscoveryResponseLike {
readonly status: number;
readonly headers: {get(name: string): string | null};
readonly body: {getReader(): InstanceDiscoveryBodyReader} | null;
text(): Promise<string>;
}
export type InstanceDiscoveryFetch = (
url: string,
init: InstanceDiscoveryRequestInit,
) => Promise<InstanceDiscoveryResponseLike>;
interface InstanceDiscoveryCandidateRequest {
readonly input: string;
readonly knownEndpointPath?: string | null;
}
interface FetchInstanceDiscoveryRequest extends InstanceDiscoveryCandidateRequest {
readonly fetch?: InstanceDiscoveryFetch;
readonly signal?: AbortSignal;
readonly conditionalHeaders?: Readonly<Record<string, string>>;
readonly adoptServingOrigin?: boolean;
}
type InstanceDiscoveryResult =
| {
readonly kind: 'ok';
readonly url: string;
readonly document: InstanceDiscoveryDocument;
readonly etag: string | null;
readonly lastModified: string | null;
}
| {readonly kind: 'not-modified'; readonly url: string};
export function buildInstanceDiscoveryCandidates({
input,
knownEndpointPath,
}: InstanceDiscoveryCandidateRequest): ReadonlyArray<string> {
const base = normalizeInstanceEndpoint(input, InstanceEndpointKind.SERVICE);
if (base == null) {
return [];
}
const url = new URL(base);
const origin = `${url.protocol}//${url.host}`;
const carriedPath = url.pathname === '/' ? null : url.pathname;
const endpointPath = normalizeDiscoveryPath(knownEndpointPath) ?? carriedPath;
const candidates: Array<string> = [];
appendCandidate(candidates, `${origin}${INSTANCE_DISCOVERY_PATH}`);
if (endpointPath != null) {
appendCandidate(candidates, `${origin}${endpointPath}${INSTANCE_DISCOVERY_PATH}`);
}
appendCandidate(candidates, `${origin}${INSTANCE_DISCOVERY_CONVENTIONAL_ENDPOINT_PATH}${INSTANCE_DISCOVERY_PATH}`);
return candidates;
}
export async function fetchInstanceDiscovery(request: FetchInstanceDiscoveryRequest): Promise<InstanceDiscoveryResult> {
const candidates = buildInstanceDiscoveryCandidates(request);
if (candidates.length === 0) {
throw new InstanceDiscoveryUnreachableError([{url: request.input, reason: 'not an absolute http(s) endpoint'}]);
}
const attempts: Array<InstanceDiscoveryAttempt> = [];
for (const url of candidates) {
try {
return await requestInstanceDiscovery(url, request);
} catch (error) {
request.signal?.throwIfAborted();
if (error instanceof InstanceRequiresNewerClientError) {
throw error;
}
attempts.push({url, reason: error instanceof Error ? error.message : String(error)});
}
}
throw new InstanceDiscoveryUnreachableError(attempts);
}
export function parseInstanceDiscoveryDocument(payload: unknown): InstanceDiscoveryDocument {
const source = asRecord(payload);
if (source == null) {
throw new InvalidInstanceDiscoveryDocumentError('the response body is not a JSON object');
}
const apiCodeVersion = readInteger(source, 'api_code_version', 1, Number.MAX_SAFE_INTEGER);
return {
generation: resolveGeneration(source, apiCodeVersion),
apiCodeVersion,
endpoints: parseEndpoints(source.endpoints),
features: parseFeatures(source.features),
gif: parseGif(source.gif),
sso: parseSso(source.sso),
registration: parseRegistration(source.registration),
community: parseCommunity(source.community),
services: parseServices(source.services),
limits: parseLimits(source.limits),
push: parsePush(source.push),
appPublic: parseAppPublic(source.app_public),
agePolicy: parseAgePolicy(source.age_policy),
domainMigration: parseInstanceDomainMigration(source.domain_migration),
};
}
async function requestInstanceDiscovery(
url: string,
request: FetchInstanceDiscoveryRequest,
): Promise<InstanceDiscoveryResult> {
const deadline = AbortSignal.timeout(INSTANCE_DISCOVERY_TIMEOUT_MS);
const fetchInstance = request.fetch ?? globalThis.fetch;
const response = await fetchInstance(url, {
method: 'GET',
headers: {[HttpHeader.ACCEPT]: MimeType.JSON, ...request.conditionalHeaders},
redirect: 'error',
credentials: 'omit',
cache: 'no-store',
referrerPolicy: 'no-referrer',
signal: request.signal == null ? deadline : AbortSignal.any([deadline, request.signal]),
});
if (response.status === HttpStatus.NOT_MODIFIED) {
return {kind: 'not-modified', url};
}
if (response.status !== HttpStatus.OK) {
throw new Error(`responded HTTP ${response.status.toString()}`);
}
if (!isJSONContentType(response.headers.get(HttpHeader.CONTENT_TYPE))) {
throw new Error('responded without a JSON content type');
}
const parsed = parseInstanceDiscoveryDocument(JSON.parse(await readBoundedBody(response)));
const document = request.adoptServingOrigin === true ? withEndpointsOnServingOrigin(url, parsed) : parsed;
requireDiscoveryDeclaresItsOwnOrigin(url, document);
return {
kind: 'ok',
url,
document,
etag: response.headers.get(HttpHeader.ETAG),
lastModified: response.headers.get(HttpHeader.LAST_MODIFIED),
};
}
function withEndpointsOnServingOrigin(url: string, document: InstanceDiscoveryDocument): InstanceDiscoveryDocument {
const servedBy = originOfEndpoint(url);
if (servedBy == null) {
return document;
}
const api = `${servedBy}${INSTANCE_DISCOVERY_CONVENTIONAL_ENDPOINT_PATH}`;
return {
...document,
endpoints: {
...document.endpoints,
api,
api_client: document.endpoints.api_client == null ? null : api,
webapp: document.endpoints.webapp == null ? null : servedBy,
},
};
}
export function requireDiscoveryDeclaresItsOwnOrigin(url: string, document: InstanceDiscoveryDocument): void {
const servedBy = originOfEndpoint(url);
if (servedBy == null) {
throw new InstanceDiscoveryOriginMismatchError(url, document.endpoints.api);
}
for (const declared of [document.endpoints.api, document.endpoints.api_client]) {
if (declared == null) {
continue;
}
if (originOfEndpoint(declared) !== servedBy) {
throw new InstanceDiscoveryOriginMismatchError(servedBy, declared);
}
}
}
function originOfEndpoint(value: string): string | null {
try {
return new URL(value).origin;
} catch {
return null;
}
}
async function readBoundedBody(response: InstanceDiscoveryResponseLike): Promise<string> {
const body = response.body;
if (body == null) {
const text = await response.text();
if (new TextEncoder().encode(text).length > INSTANCE_DISCOVERY_MAX_BYTES) {
throw new Error('exceeded the discovery document byte cap');
}
return text;
}
const reader = body.getReader();
const decoder = new TextDecoder();
let text = '';
let bytes = 0;
let chunks = 0;
try {
for (;;) {
const {done, value} = await reader.read();
if (done) {
break;
}
if (value == null) {
continue;
}
chunks += 1;
bytes += value.byteLength;
if (chunks > INSTANCE_DISCOVERY_MAX_CHUNKS) {
throw new Error('exceeded the discovery document chunk cap');
}
if (bytes > INSTANCE_DISCOVERY_MAX_BYTES) {
throw new Error('exceeded the discovery document byte cap');
}
text += decoder.decode(value, {stream: true});
}
} finally {
await reader.cancel();
}
return `${text}${decoder.decode()}`;
}
function isJSONContentType(value: string | null): boolean {
if (value == null) {
return false;
}
const essence = value.split(';')[0]?.trim().toLowerCase() ?? '';
return essence === MimeType.JSON || essence.endsWith('+json');
}
function appendCandidate(candidates: Array<string>, value: string): void {
if (!candidates.includes(value)) {
candidates.push(value);
}
}
function normalizeDiscoveryPath(value: string | null | undefined): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (!trimmed.startsWith('/') || trimmed.includes('?') || trimmed.includes('#')) {
return null;
}
const stripped = trimmed.replace(/\/+$/u, '');
return stripped.length === 0 ? null : stripped;
}
function resolveGeneration(source: Record<string, unknown>, apiCodeVersion: number | null): InstanceGeneration {
const reported = source.codename;
if (typeof reported === 'string' && reported.length > 0) {
if (reported === InstanceGeneration.TUNGSTEN) {
return InstanceGeneration.TUNGSTEN;
}
throw new InstanceRequiresNewerClientError(reported);
}
if (source.api_code_version != null && apiCodeVersion == null) {
throw new InvalidInstanceDiscoveryDocumentError('api_code_version is not a positive integer');
}
return InstanceGeneration.LEGACY;
}
function parseEndpoints(value: unknown): InstanceDiscoveryEndpoints {
const source = asRecord(value);
if (source == null) {
throw new InvalidInstanceDiscoveryDocumentError('endpoints is missing or not an object');
}
const api = normalizeInstanceEndpoint(source.api, InstanceEndpointKind.SERVICE);
if (api == null) {
throw new InvalidInstanceDiscoveryDocumentError('endpoints.api is missing or not an absolute http(s) endpoint');
}
const gateway = normalizeInstanceEndpoint(source.gateway, InstanceEndpointKind.GATEWAY);
if (gateway == null) {
throw new InvalidInstanceDiscoveryDocumentError('endpoints.gateway is missing or not a ws(s) endpoint');
}
return {
api,
gateway,
api_client: normalizeInstanceEndpoint(source.api_client, InstanceEndpointKind.SERVICE),
api_public: normalizeInstanceEndpoint(source.api_public, InstanceEndpointKind.SERVICE),
media: normalizeInstanceEndpoint(source.media, InstanceEndpointKind.SERVICE),
upload_relay: normalizeInstanceEndpoint(source.upload_relay, InstanceEndpointKind.SERVICE),
static_cdn: normalizeInstanceEndpoint(source.static_cdn, InstanceEndpointKind.SERVICE),
marketing: normalizeInstanceEndpoint(source.marketing, InstanceEndpointKind.SERVICE),
docs: normalizeInstanceEndpoint(source.docs, InstanceEndpointKind.SERVICE),
admin: normalizeInstanceEndpoint(source.admin, InstanceEndpointKind.SERVICE),
invite: normalizeInstanceEndpoint(source.invite, InstanceEndpointKind.SERVICE),
gift: normalizeInstanceEndpoint(source.gift, InstanceEndpointKind.SERVICE),
webapp: normalizeInstanceEndpoint(source.webapp, InstanceEndpointKind.WEBAPP),
};
}
function parseFeatures(value: unknown): Readonly<Partial<InstanceFeatures>> {
const source = asRecord(value);
if (source == null) {
return {};
}
const features: Partial<InstanceFeatures> = {};
for (const key of FEATURE_FLAG_KEYS) {
const flag = source[key];
if (typeof flag === 'boolean') {
features[key] = flag;
}
}
const accountIdentity = Object.values(AccountIdentityModes).find((mode) => mode === source.account_identity);
if (accountIdentity !== undefined) {
features.account_identity = accountIdentity;
}
const tagStyle = Object.values(TagStyles).find((style) => style === source.tag_style);
if (tagStyle !== undefined) {
features.tag_style = tagStyle;
}
const gracePeriodHours = readInteger(
source,
'account_deletion_grace_period_hours',
ACCOUNT_DELETION_GRACE_PERIOD_HOURS_MIN,
ACCOUNT_DELETION_GRACE_PERIOD_HOURS_MAX,
);
if (gracePeriodHours != null) {
features.account_deletion_grace_period_hours = gracePeriodHours;
}
const maxBackgroundGatewayConnections = readInteger(
source,
'max_background_gateway_connections',
MAX_BACKGROUND_GATEWAY_CONNECTIONS_MIN,
MAX_BACKGROUND_GATEWAY_CONNECTIONS_MAX,
);
if (maxBackgroundGatewayConnections != null) {
features.max_background_gateway_connections = maxBackgroundGatewayConnections;
}
return features;
}
function parseGif(value: unknown): InstanceGif | null {
const source = asRecord(value);
if (source == null) {
return null;
}
const provider = readNonEmptyString(source, 'provider');
const displayName = readNonEmptyString(source, 'display_name');
const attributionRequired = readBoolean(source, 'attribution_required');
if (provider == null || displayName == null || attributionRequired == null) {
return null;
}
return {provider, display_name: displayName, attribution_required: attributionRequired};
}
function parseSso(value: unknown): InstanceSso | null {
const source = asRecord(value);
if (source == null) {
return null;
}
const enabled = readBoolean(source, 'enabled');
const enforced = readBoolean(source, 'enforced');
const redirectUri = readAbsoluteHTTPURL(source, 'redirect_uri');
if (enabled == null || enforced == null || redirectUri == null) {
return null;
}
const sso: InstanceSso = {
enabled,
enforced,
display_name: readNonEmptyString(source, 'display_name'),
redirect_uri: redirectUri,
};
const available = readBoolean(source, 'available');
if (available != null) {
sso.available = available;
}
return sso;
}
function parseRegistration(value: unknown): InstanceRegistration | null {
const source = asRecord(value);
if (source == null) {
return null;
}
const mode = readLiteral(source, 'mode', REGISTRATION_MODES);
const adminRegistrationUrlsEnabled = readBoolean(source, 'admin_registration_urls_enabled');
if (mode == null || adminRegistrationUrlsEnabled == null) {
return null;
}
return {mode, admin_registration_urls_enabled: adminRegistrationUrlsEnabled};
}
function parseCommunity(value: unknown): InstanceCommunity | null {
const source = asRecord(value);
if (source == null) {
return null;
}
const singleCommunity = readBoolean(source, 'single_community');
const directMessagesDisabled = readBoolean(source, 'direct_messages_disabled');
if (singleCommunity == null || directMessagesDisabled == null) {
return null;
}
return {
single_community: singleCommunity,
single_community_guild_id: readNonEmptyString(source, 'single_community_guild_id'),
direct_messages_disabled: directMessagesDisabled,
guild_create_access: readBoolean(source, 'guild_create_access') ?? true,
};
}
function parseServices(value: unknown): InstanceServices | null {
const source = asRecord(value);
if (source == null) {
return null;
}
const gifEnabled = readBoolean(source, 'gif_enabled');
const youtubeEnabled = readBoolean(source, 'youtube_enabled');
const blueskyEnabled = readBoolean(source, 'bluesky_enabled');
if (gifEnabled == null || youtubeEnabled == null || blueskyEnabled == null) {
return null;
}
return {gif_enabled: gifEnabled, youtube_enabled: youtubeEnabled, bluesky_enabled: blueskyEnabled};
}
function parseLimits(value: unknown): LimitConfigSnapshot | LimitConfigWireFormat | null {
const source = asRecord(value);
if (source == null) {
return null;
}
const traitDefinitions = readStringArray(source, 'traitDefinitions');
const rules = source.rules;
if (traitDefinitions == null || !Array.isArray(rules) || rules.length > LIMIT_RULES_MAX) {
return null;
}
const wireRules: LimitConfigWireFormat['rules'] = [];
const snapshotRules: Array<LimitRule> = [];
for (const entry of rules) {
const rule = asRecord(entry);
const id = rule == null ? null : readNonEmptyString(rule, 'id');
if (rule == null || id == null) {
return null;
}
const filters = parseLimitFilter(rule.filters);
if (Object.hasOwn(rule, 'filters') && filters == null) {
return null;
}
const overrides = readLimitValues(rule.overrides);
const limits = readLimitValues(rule.limits);
if (overrides != null) {
wireRules.push(filters == null ? {id, overrides} : {id, filters, overrides});
continue;
}
if (limits == null) {
return null;
}
snapshotRules.push(filters == null ? {id, limits} : {id, filters, limits});
}
if (wireRules.length > 0 && snapshotRules.length > 0) {
return null;
}
const version = readInteger(source, 'version', 1, Number.MAX_SAFE_INTEGER);
if (wireRules.length > 0 || (snapshotRules.length === 0 && version === 2)) {
const defaultsHash = readNonEmptyString(source, 'defaultsHash');
if (defaultsHash == null) {
return null;
}
return {
version: 2,
traitDefinitions,
rules: wireRules,
defaultsHash,
};
}
const snapshot: LimitConfigSnapshot = {traitDefinitions, rules: snapshotRules};
if (version != null) {
snapshot.version = version;
}
return snapshot;
}
function parseLimitFilter(value: unknown): LimitFilter | null {
const source = asRecord(value);
if (source == null) {
return null;
}
const filter: LimitFilter = {};
const traits = readStringArray(source, 'traits');
if (traits != null) {
filter.traits = traits;
}
const guildFeatures = readStringArray(source, 'guildFeatures');
if (guildFeatures != null) {
filter.guildFeatures = guildFeatures;
}
return filter;
}
function readLimitValues(value: unknown): Partial<Record<LimitKey, number>> | null {
const source = asRecord(value);
if (source == null) {
return null;
}
const values: Record<string, number> = {};
for (const [key, entry] of Object.entries(source)) {
if (typeof entry !== 'number' || !Number.isFinite(entry)) {
return null;
}
values[key] = entry;
}
return values as Partial<Record<LimitKey, number>>;
}
function parsePush(value: unknown): Readonly<Partial<InstancePush>> {
const source = asRecord(value);
if (source == null) {
return {};
}
const push: Partial<InstancePush> = {};
if (Object.hasOwn(source, 'public_vapid_key')) {
push.public_vapid_key = readNonEmptyString(source, 'public_vapid_key');
}
const deliveryMode = readLiteral(source, 'delivery_mode', PUSH_DELIVERY_MODES);
if (deliveryMode != null) {
push.delivery_mode = deliveryMode;
}
const eventResolutionPath = readNonEmptyString(source, 'event_resolution_path');
if (eventResolutionPath != null) {
push.event_resolution_path = eventResolutionPath;
}
if (Object.hasOwn(source, 'external_relay_url')) {
push.external_relay_url = readAbsoluteHTTPURL(source, 'external_relay_url');
}
if (Object.hasOwn(source, 'relay_signing_key_hashes')) {
push.relay_signing_key_hashes = readStringArray(source, 'relay_signing_key_hashes');
}
return push;
}
function parseAppPublic(value: unknown): InstanceDiscoveryAppPublic | null {
const source = asRecord(value);
if (source == null) {
return null;
}
return {
branding: parseBranding(source.branding),
setup: parseSetup(source.setup),
legal: parseLegal(source.legal),
registration: parseAppRegistration(source.registration),
};
}
function parseBranding(value: unknown): Readonly<Partial<InstanceBranding>> {
const source = asRecord(value);
if (source == null) {
return {};
}
const branding: Partial<InstanceBranding> = {};
const productName = readNonEmptyString(source, 'product_name');
if (productName != null) {
branding.product_name = productName;
}
const premiumProductName = readNonEmptyString(source, 'premium_product_name');
if (premiumProductName != null) {
branding.premium_product_name = premiumProductName;
}
for (const key of BRANDING_URL_KEYS) {
if (Object.hasOwn(source, key)) {
branding[key] = readAbsoluteHTTPURL(source, key);
}
}
if (Object.hasOwn(source, 'theme_color')) {
branding.theme_color = readNonEmptyString(source, 'theme_color');
}
return branding;
}
function parseSetup(value: unknown): Readonly<Partial<InstanceSetup>> {
const source = asRecord(value);
if (source == null) {
return {};
}
const setup: Partial<InstanceSetup> = {};
const configured = readBoolean(source, 'configured');
if (configured != null) {
setup.configured = configured;
}
if (Object.hasOwn(source, 'admin_url')) {
setup.admin_url = readAbsoluteHTTPURL(source, 'admin_url');
}
return setup;
}
function parseLegal(value: unknown): Readonly<Partial<InstanceLegal>> {
const source = asRecord(value);
if (source == null) {
return {};
}
const legal: Partial<InstanceLegal> = {};
if (Object.hasOwn(source, 'terms_url')) {
legal.terms_url = readAbsoluteHTTPURL(source, 'terms_url');
}
if (Object.hasOwn(source, 'privacy_url')) {
legal.privacy_url = readAbsoluteHTTPURL(source, 'privacy_url');
}
return legal;
}
function parseAppRegistration(value: unknown): Readonly<Partial<InstanceAppRegistration>> {
const source = asRecord(value);
if (source == null) {
return {};
}
const collectDateOfBirth = readBoolean(source, 'collect_date_of_birth');
return collectDateOfBirth == null ? {} : {collect_date_of_birth: collectDateOfBirth};
}
function parseAgePolicy(value: unknown): InstanceAgePolicy | null {
const source = asRecord(value);
if (source == null) {
return null;
}
const entries = source.geos;
if (!Array.isArray(entries) || entries.length > AGE_POLICY_GEOS_MAX) {
return null;
}
const geos: Array<InstanceAgePolicyGeo> = [];
for (const entry of entries) {
const geo = asRecord(entry);
const countryCode = geo == null ? null : readNonEmptyString(geo, 'country_code');
if (geo == null || countryCode == null) {
return null;
}
const action = readLiteral(geo, 'action', AGE_POLICY_ACTIONS);
const cardVerificationAvailable = readBoolean(geo, 'card_verification_available');
if (action == null || cardVerificationAvailable == null) {
return null;
}
geos.push({
country_code: countryCode,
region_code: readNonEmptyString(geo, 'region_code'),
action,
card_verification_available: cardVerificationAvailable,
});
}
return {geos};
}
export function parseInstanceDomainMigration(value: unknown): InstanceDomainMigration | null {
const source = asRecord(value);
if (source == null) {
return null;
}
const enabled = readBoolean(source, 'enabled');
const anonymousRolloutBasisPoints = readInteger(
source,
'anonymous_rollout_basis_points',
0,
DOMAIN_MIGRATION_ROLLOUT_BASIS_POINTS_MAX,
);
const rolloutSalt = readDomainMigrationRolloutSalt(source);
const standaloneForwarding = readBoolean(source, 'standalone_forwarding');
if (enabled == null || anonymousRolloutBasisPoints == null || rolloutSalt == null || standaloneForwarding == null) {
return null;
}
return {
enabled,
anonymous_rollout_basis_points: anonymousRolloutBasisPoints,
rollout_salt: rolloutSalt,
standalone_forwarding: standaloneForwarding,
};
}
function readDomainMigrationRolloutSalt(source: Record<string, unknown>): string | null {
const raw = source.rollout_salt;
if (typeof raw !== 'string') {
return null;
}
const value = raw.trim();
if (
value.length === 0 ||
value.length > DOMAIN_MIGRATION_ROLLOUT_SALT_MAX_LENGTH ||
!DOMAIN_MIGRATION_ROLLOUT_SALT_PATTERN.test(value)
) {
return null;
}
return value;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== 'object' || value == null || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function readBoolean(source: Record<string, unknown>, key: string): boolean | null {
const value = source[key];
return typeof value === 'boolean' ? value : null;
}
function readNonEmptyString(source: Record<string, unknown>, key: string): string | null {
const value = source[key];
if (typeof value !== 'string') {
return null;
}
return value.length > 0 ? value : null;
}
function readInteger(source: Record<string, unknown>, key: string, minimum: number, maximum: number): number | null {
const value = source[key];
if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
return null;
}
return value >= minimum && value <= maximum ? value : null;
}
function readStringArray(source: Record<string, unknown>, key: string): Array<string> | null {
const value = source[key];
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
return null;
}
return [...(value as Array<string>)];
}
function readLiteral<TValue extends string>(
source: Record<string, unknown>,
key: string,
allowed: ReadonlyArray<TValue>,
): TValue | null {
const value = source[key];
return allowed.find((entry) => entry === value) ?? null;
}
function readAbsoluteHTTPURL(source: Record<string, unknown>, key: string): string | null {
const raw = readNonEmptyString(source, key);
if (raw == null) {
return null;
}
const value = raw.trim();
let url: URL;
try {
url = new URL(value);
} catch {
return null;
}
if (url.protocol !== CanonicalNetworkProtocol.HTTP && url.protocol !== CanonicalNetworkProtocol.HTTPS) {
return null;
}
return value;
}
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type CanonicalNetworkEndpointRule,
HTTP_NETWORK_PROTOCOLS,
normalizeCanonicalNetworkEndpoint,
WEBSOCKET_NETWORK_PROTOCOLS,
} from '@fluxer/instance_bootstrap/src/NetworkOrigin';
export const InstanceEndpointKind = Object.freeze({
API: 'api',
WEBAPP: 'webapp',
GATEWAY: 'gateway',
SERVICE: 'service',
} as const);
export type InstanceEndpointKind = (typeof InstanceEndpointKind)[keyof typeof InstanceEndpointKind];
const INSTANCE_ENDPOINT_RULES: Readonly<Record<InstanceEndpointKind, CanonicalNetworkEndpointRule>> = Object.freeze({
[InstanceEndpointKind.API]: Object.freeze({
protocols: HTTP_NETWORK_PROTOCOLS,
allowPath: true,
allowRelative: true,
}),
[InstanceEndpointKind.WEBAPP]: Object.freeze({
protocols: HTTP_NETWORK_PROTOCOLS,
allowPath: false,
allowRelative: false,
}),
[InstanceEndpointKind.GATEWAY]: Object.freeze({
protocols: WEBSOCKET_NETWORK_PROTOCOLS,
allowPath: true,
allowRelative: false,
}),
[InstanceEndpointKind.SERVICE]: Object.freeze({
protocols: HTTP_NETWORK_PROTOCOLS,
allowPath: true,
allowRelative: false,
}),
});
export function normalizeInstanceEndpoint(value: unknown, kind: InstanceEndpointKind): string | null {
return normalizeCanonicalNetworkEndpoint(value, INSTANCE_ENDPOINT_RULES[kind]);
}
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import {describe, test} from 'node:test';
const {
HTTP_NETWORK_PROTOCOLS,
WEBSOCKET_NETWORK_PROTOCOLS,
normalizeCanonicalNetworkEndpoint,
normalizeHTTPNetworkOrigin,
} = await import('./NetworkOrigin.ts');
const GATEWAY_RULE = {protocols: WEBSOCKET_NETWORK_PROTOCOLS, allowPath: true, allowRelative: false};
const API_RULE = {protocols: HTTP_NETWORK_PROTOCOLS, allowPath: true, allowRelative: true};
describe('canonicalising a network origin', () => {
test('a written default port collapses to the same origin as the bare host', () => {
assert.equal(normalizeHTTPNetworkOrigin('fluxer.app:443'), 'https://fluxer.app');
assert.equal(normalizeHTTPNetworkOrigin('https://fluxer.app:443'), 'https://fluxer.app');
assert.equal(normalizeHTTPNetworkOrigin('http://fluxer.app:80'), 'http://fluxer.app');
assert.equal(normalizeHTTPNetworkOrigin('https://[::1]:443'), 'https://[::1]');
});
test('one origin has one canonical form, the one URL.origin agrees with', () => {
for (const value of ['fluxer.app', 'fluxer.app:443', 'https://fluxer.app:443', 'https://fluxer.app/']) {
assert.equal(
normalizeHTTPNetworkOrigin(value),
'https://fluxer.app',
`${value} must not gain a second canonical form`,
);
}
for (const value of ['fluxer.app:443', 'https://fluxer.app:8443', 'http://fluxer.app:80', 'http://[::1]:8080']) {
const normalized = normalizeHTTPNetworkOrigin(value);
assert.equal(new URL(normalized).origin, normalized, `${value} must canonicalise the way URL.origin does`);
}
});
test('a host slices off the canonical origin without a port riding along', () => {
const origin = normalizeHTTPNetworkOrigin('web.fluxer.app:443');
assert.equal(origin.slice('https://'.length), 'web.fluxer.app');
});
test('a non default port is kept', () => {
assert.equal(normalizeHTTPNetworkOrigin('fluxer.app:8443'), 'https://fluxer.app:8443');
assert.equal(normalizeHTTPNetworkOrigin('http://localhost:3000'), 'http://localhost:3000');
assert.equal(normalizeHTTPNetworkOrigin('https://[::1]:8443'), 'https://[::1]:8443');
});
test('a leading zero port is normalised the way URL normalises it', () => {
assert.equal(normalizeHTTPNetworkOrigin('https://fluxer.app:0443'), 'https://fluxer.app');
assert.equal(normalizeHTTPNetworkOrigin('https://fluxer.app:08443'), 'https://fluxer.app:8443');
});
test('port zero, credentials, a path, a query and a fragment are all refused', () => {
assert.equal(normalizeHTTPNetworkOrigin('https://fluxer.app:0'), null);
assert.equal(normalizeHTTPNetworkOrigin('https://user:[email protected]'), null);
assert.equal(normalizeHTTPNetworkOrigin('https://fluxer.app/api'), null);
assert.equal(normalizeHTTPNetworkOrigin('https://fluxer.app?a=1'), null);
assert.equal(normalizeHTTPNetworkOrigin('https://fluxer.app#a'), null);
assert.equal(normalizeHTTPNetworkOrigin('//fluxer.app'), null);
assert.equal(normalizeHTTPNetworkOrigin('wss://fluxer.app'), null);
assert.equal(normalizeHTTPNetworkOrigin(42), null);
});
});
describe('canonicalising an endpoint that carries a path', () => {
test('a websocket endpoint drops its written default port too', () => {
assert.equal(
normalizeCanonicalNetworkEndpoint('wss://gateway.fluxer.app:443', GATEWAY_RULE),
'wss://gateway.fluxer.app',
);
assert.equal(normalizeCanonicalNetworkEndpoint('ws://localhost:80/ws', GATEWAY_RULE), 'ws://localhost/ws');
assert.equal(
normalizeCanonicalNetworkEndpoint('wss://gateway.fluxer.app:8443', GATEWAY_RULE),
'wss://gateway.fluxer.app:8443',
);
});
test('an api endpoint keeps its path and still collapses the default port', () => {
assert.equal(
normalizeCanonicalNetworkEndpoint('https://fluxer.app:443/api/v1/', API_RULE),
'https://fluxer.app/api/v1',
);
assert.equal(normalizeCanonicalNetworkEndpoint('/api', API_RULE), '/api');
});
});
@@ -0,0 +1,132 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const CanonicalNetworkProtocol = Object.freeze({
HTTP: 'http:',
HTTPS: 'https:',
WS: 'ws:',
WSS: 'wss:',
} as const);
export type CanonicalNetworkProtocol = (typeof CanonicalNetworkProtocol)[keyof typeof CanonicalNetworkProtocol];
export const HTTP_NETWORK_PROTOCOLS: ReadonlyArray<CanonicalNetworkProtocol> = Object.freeze([
CanonicalNetworkProtocol.HTTP,
CanonicalNetworkProtocol.HTTPS,
]);
export const WEBSOCKET_NETWORK_PROTOCOLS: ReadonlyArray<CanonicalNetworkProtocol> = Object.freeze([
CanonicalNetworkProtocol.WS,
CanonicalNetworkProtocol.WSS,
]);
export const NETWORK_ENDPOINT_INPUT_MAX_BYTES = 8192;
export interface CanonicalNetworkEndpointRule {
readonly protocols: ReadonlyArray<CanonicalNetworkProtocol>;
readonly allowPath: boolean;
readonly allowRelative: boolean;
}
const NETWORK_PROTOCOL_PREFIX = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//u;
const AUTHORITY_TERMINATOR = /[/?#]/u;
const NETWORK_ORIGIN_ONLY_RULE: CanonicalNetworkEndpointRule = Object.freeze({
protocols: HTTP_NETWORK_PROTOCOLS,
allowPath: false,
allowRelative: false,
});
function exceedsInputByteCap(value: string): boolean {
return new TextEncoder().encode(value).length > NETWORK_ENDPOINT_INPUT_MAX_BYTES;
}
function stripTrailingSlashes(pathname: string): string {
let end = pathname.length;
while (end > 0 && pathname[end - 1] === '/') {
end -= 1;
}
return pathname.slice(0, end);
}
function networkAuthority(candidate: string): string {
const schemeSeparator = candidate.indexOf('://');
if (schemeSeparator < 0) {
return '';
}
const authorityStart = schemeSeparator + 3;
const suffixOffset = candidate.slice(authorityStart).search(AUTHORITY_TERMINATOR);
if (suffixOffset < 0) {
return candidate.slice(authorityStart);
}
return candidate.slice(authorityStart, authorityStart + suffixOffset);
}
function normalizeRelativeNetworkEndpoint(trimmed: string): string | null {
if (trimmed.startsWith('//')) {
return null;
}
if (trimmed.includes('?') || trimmed.includes('#')) {
return null;
}
const path = stripTrailingSlashes(trimmed);
if (path.length === 0) {
return null;
}
return path;
}
function networkOriginCandidate(trimmed: string): string {
if (NETWORK_PROTOCOL_PREFIX.test(trimmed)) {
return trimmed;
}
return `${CanonicalNetworkProtocol.HTTPS}//${trimmed}`;
}
export function normalizeCanonicalNetworkEndpoint(value: unknown, rule: CanonicalNetworkEndpointRule): string | null {
if (typeof value !== 'string' || exceedsInputByteCap(value)) {
return null;
}
const trimmed = value.trim();
if (trimmed.length === 0) {
return null;
}
if (trimmed.startsWith('/')) {
if (!rule.allowRelative) {
return null;
}
return normalizeRelativeNetworkEndpoint(trimmed);
}
const candidate = networkOriginCandidate(trimmed);
const authority = networkAuthority(candidate);
if (authority.length === 0) {
return null;
}
let url: URL;
try {
url = new URL(candidate);
} catch {
return null;
}
if (!rule.protocols.some((protocol) => protocol === url.protocol)) {
return null;
}
if (url.hostname.length === 0 || url.username.length > 0 || url.password.length > 0) {
return null;
}
if (url.port === '0' || url.search.length > 0 || url.hash.length > 0) {
return null;
}
const path = stripTrailingSlashes(url.pathname);
if (path.length > 0 && !rule.allowPath) {
return null;
}
let origin = `${url.protocol}//${url.hostname}`;
if (url.port.length > 0) {
origin = `${origin}:${url.port}`;
}
return `${origin}${path}`;
}
export function normalizeHTTPNetworkOrigin(value: unknown): string | null {
return normalizeCanonicalNetworkEndpoint(value, NETWORK_ORIGIN_ONLY_RULE);
}
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {normalizeHTTPNetworkOrigin} from '@fluxer/instance_bootstrap/src/NetworkOrigin';
export const OFFICIAL_INSTANCE_NAME = 'Fluxer';
export const OFFICIAL_INSTANCE_DISPLAY_HOST = 'fluxer.app';
export const OFFICIAL_STABLE_MARKETING_ORIGIN = `https://${OFFICIAL_INSTANCE_DISPLAY_HOST}`;
export const OFFICIAL_CLIENT_API_ENDPOINTS = Object.freeze({
stable: 'https://web.fluxer.app/api',
canary: 'https://web.canary.fluxer.app/api',
} as const);
export type OfficialReleaseChannel = keyof typeof OFFICIAL_CLIENT_API_ENDPOINTS;
export const OFFICIAL_MARKETING_ORIGINS = Object.freeze({
stable: OFFICIAL_STABLE_MARKETING_ORIGIN,
canary: 'https://canary.fluxer.app',
} as const) satisfies Record<OfficialReleaseChannel, string>;
export function officialMarketingOrigin(releaseChannel: string | null | undefined): string {
return releaseChannel === 'stable' ? OFFICIAL_MARKETING_ORIGINS.stable : OFFICIAL_MARKETING_ORIGINS.canary;
}
const OFFICIAL_MIGRATED_WEB_APP_HOSTS = Object.freeze({
stable: 'fluxer.com',
canary: 'canary.fluxer.com',
} as const) satisfies Record<OfficialReleaseChannel, string>;
const OFFICIAL_CLIENT_API_PATH = '/api';
const OFFICIAL_RELEASE_CHANNELS: ReadonlyArray<OfficialReleaseChannel> = Object.freeze(['stable', 'canary']);
const OFFICIAL_CLIENT_API_HOST_CHANNELS: ReadonlyMap<string, OfficialReleaseChannel> = new Map(
OFFICIAL_RELEASE_CHANNELS.flatMap(
(channel): Array<[string, OfficialReleaseChannel]> => [
[new URL(OFFICIAL_CLIENT_API_ENDPOINTS[channel]).host, channel],
[OFFICIAL_MIGRATED_WEB_APP_HOSTS[channel], channel],
],
),
);
export function officialClientApiEndpointForAlias(apiEndpoint: string): string | null {
let url: URL;
try {
url = new URL(apiEndpoint.trim());
} catch {
return null;
}
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) {
return null;
}
if (url.pathname.replace(/\/+$/u, '') !== OFFICIAL_CLIENT_API_PATH) {
return null;
}
const channel = OFFICIAL_CLIENT_API_HOST_CHANNELS.get(url.host);
return channel === undefined ? null : OFFICIAL_CLIENT_API_ENDPOINTS[channel];
}
export const OFFICIAL_INSTANCE_HOSTS: ReadonlyArray<string> = Object.freeze([
OFFICIAL_INSTANCE_DISPLAY_HOST,
'web.fluxer.app',
'api.fluxer.app',
'canary.fluxer.app',
'web.canary.fluxer.app',
'api.canary.fluxer.app',
'fluxer.com',
'canary.fluxer.com',
]);
export function isOfficialInstanceHost(value: string): boolean {
const origin = normalizeHTTPNetworkOrigin(value);
if (origin == null) {
return false;
}
const host = origin.slice(origin.indexOf('//') + 2).toLowerCase();
return OFFICIAL_INSTANCE_HOSTS.some((officialHost) => officialHost === host);
}
+33 -1
View File
@@ -1,15 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {LimitConfigSnapshot, LimitConfigWireFormat} from '@fluxer/limits/src/LimitTypes';
import type {DomainMigrationDiscoveryResponse} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import type {
GeoEntry,
GeolocationResponse as GeolocationWireResponse,
} from '@fluxer/schema/src/domains/geolocation/GeolocationSchemas';
import type {WellKnownFluxerResponse} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
import type {
InstanceAgePolicyAction,
InstanceAppPublic,
InstancePushDeliveryMode,
InstanceRegistration,
WellKnownFluxerResponse,
} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
export type {GeoEntry} from '@fluxer/schema/src/domains/geolocation/GeolocationSchemas';
export type {
InstanceAgePolicy,
InstanceAgePolicyGeo,
InstanceAppPublic,
InstanceBranding,
InstanceCaptcha,
InstanceCommunity,
InstanceEndpoints,
@@ -18,9 +28,31 @@ export type {
InstancePush,
InstanceRegistration,
InstanceServices,
InstanceSetup,
InstanceSso,
} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
export type InstanceLegal = InstanceAppPublic['legal'];
export type InstanceDomainMigration = DomainMigrationDiscoveryResponse;
export type InstanceAppRegistration = InstanceAppPublic['registration'];
export const CURRENT_INSTANCE_CODENAME = 'tungsten';
export const REGISTRATION_MODES = Object.freeze(['open', 'approval', 'closed'] as const) satisfies ReadonlyArray<
InstanceRegistration['mode']
>;
export const PUSH_DELIVERY_MODES = Object.freeze([
'direct',
'external_relay',
'hybrid',
] as const) satisfies ReadonlyArray<InstancePushDeliveryMode>;
export const AGE_POLICY_ACTIONS = Object.freeze([
'restrict',
'block',
] as const) satisfies ReadonlyArray<InstanceAgePolicyAction>;
export interface InstanceDiscoveryResponse extends Omit<WellKnownFluxerResponse, 'limits'> {
limits: LimitConfigSnapshot | LimitConfigWireFormat;
}
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const DEPLOYED_OFFICIAL_DOCUMENT = {
api_code_version: 1,
endpoints: {
api: 'https://api.fluxer.app',
api_client: 'https://api.fluxer.app',
api_public: 'https://api.fluxer.app',
gateway: 'wss://gateway.fluxer.app',
media: 'https://media.fluxer.app',
static_cdn: 'https://cdn.fluxer.app',
marketing: 'https://fluxer.app',
admin: 'https://admin.fluxer.app',
invite: 'https://fluxer.gg',
gift: 'https://fluxer.gift',
webapp: 'https://web.fluxer.app',
},
captcha: {provider: 'altcha'},
features: {
voice_enabled: true,
stripe_enabled: true,
self_hosted: false,
presigned_attachment_uploads: true,
emails_enabled: true,
},
gif: {provider: 'klipy', display_name: 'Klipy', attribution_required: false},
sso: {enabled: false, enforced: false, display_name: null, redirect_uri: 'https://web.fluxer.app/sso/callback'},
registration: {mode: 'open', admin_registration_urls_enabled: true},
community: {single_community: false, single_community_guild_id: null, direct_messages_disabled: false},
services: {gif_enabled: true, youtube_enabled: true, bluesky_enabled: false},
limits: {
version: 2,
traitDefinitions: ['premium'],
rules: [
{id: 'default', overrides: {max_guild_emojis: 50}},
{id: 'premium', filters: {traits: ['premium'], guildFeatures: []}, overrides: {max_guild_emojis: 250}},
],
defaultsHash: 'deployed-defaults-hash',
},
push: {public_vapid_key: 'BPublicVapidKey'},
app_public: {
branding: {
product_name: 'Fluxer',
icon_url: 'https://cdn.fluxer.app/icon.png',
symbol_url: 'https://cdn.fluxer.app/symbol.png',
logo_url: 'https://cdn.fluxer.app/logo.png',
wordmark_url: 'https://cdn.fluxer.app/wordmark.png',
favicon_url: 'https://cdn.fluxer.app/favicon.png',
theme_color: '#5865f2',
},
setup: {configured: true, admin_url: 'https://admin.fluxer.app'},
legal: {terms_url: 'https://fluxer.app/terms', privacy_url: 'https://fluxer.app/privacy'},
registration: {collect_date_of_birth: true},
},
} as const;
export const SELF_HOSTED_API_TOPOLOGY_DOCUMENT = {
api_code_version: 1,
endpoints: {
api: 'https://chat.example.test/api',
api_client: 'https://chat.example.test/api',
api_public: 'https://chat.example.test/api',
gateway: 'wss://chat.example.test/gateway',
media: 'https://chat.example.test/media',
static_cdn: 'https://chat.example.test',
marketing: 'https://chat.example.test/marketing',
admin: 'https://chat.example.test/admin',
invite: 'https://chat.example.test/invite',
gift: 'https://chat.example.test/gift',
webapp: 'https://chat.example.test',
},
captcha: {provider: 'none'},
features: {
voice_enabled: false,
stripe_enabled: false,
self_hosted: true,
presigned_attachment_uploads: false,
emails_enabled: false,
},
gif: {provider: 'klipy', display_name: 'Klipy', attribution_required: false},
sso: {enabled: false, enforced: false, display_name: null, redirect_uri: 'https://chat.example.test/sso/callback'},
registration: {mode: 'closed', admin_registration_urls_enabled: false},
community: {single_community: true, single_community_guild_id: '1', direct_messages_disabled: false},
services: {gif_enabled: false, youtube_enabled: false, bluesky_enabled: false},
limits: {version: 1, traitDefinitions: [], rules: []},
push: {public_vapid_key: null},
app_public: {
branding: {
product_name: 'Example Chat',
icon_url: null,
symbol_url: null,
logo_url: null,
wordmark_url: null,
favicon_url: null,
theme_color: null,
},
setup: {configured: true, admin_url: 'https://chat.example.test/admin'},
legal: {terms_url: null, privacy_url: null},
registration: {collect_date_of_birth: false},
},
} as const;
export const UPGRADED_DOCUMENT = {
codename: 'tungsten',
api_code_version: 1,
endpoints: {
...DEPLOYED_OFFICIAL_DOCUMENT.endpoints,
upload_relay: 'https://media.fluxer.app/upload',
docs: 'https://fluxer.app/docs',
},
captcha: DEPLOYED_OFFICIAL_DOCUMENT.captcha,
features: {...DEPLOYED_OFFICIAL_DOCUMENT.features, account_deletion_grace_period_hours: 720},
gif: DEPLOYED_OFFICIAL_DOCUMENT.gif,
sso: {...DEPLOYED_OFFICIAL_DOCUMENT.sso, available: true},
registration: DEPLOYED_OFFICIAL_DOCUMENT.registration,
community: DEPLOYED_OFFICIAL_DOCUMENT.community,
services: DEPLOYED_OFFICIAL_DOCUMENT.services,
limits: DEPLOYED_OFFICIAL_DOCUMENT.limits,
push: {
public_vapid_key: 'BPublicVapidKey',
delivery_mode: 'direct',
event_resolution_path: '/v1/push/events/{event_id}',
external_relay_url: null,
relay_signing_key_hashes: null,
},
app_public: DEPLOYED_OFFICIAL_DOCUMENT.app_public,
age_policy: {
geos: [
{country_code: 'GB', region_code: null, action: 'restrict', card_verification_available: false},
{country_code: 'US', region_code: 'MS', action: 'block', card_verification_available: false},
],
},
} as const;
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfigs/package.json",
"compilerOptions": {},
"include": ["src/**/*"]
}
@@ -11,14 +11,14 @@ function normalizeDesktopHandoffCode(value: string): string {
return value.replace(DESKTOP_HANDOFF_CODE_SEPARATOR_PATTERN, '').toUpperCase();
}
function isDesktopHandoffCode(value: string): boolean {
export function isDesktopHandoffCode(value: string): boolean {
if (value.length !== DESKTOP_HANDOFF_CODE_LENGTH) {
return false;
}
return Array.from(value).every((character) => DESKTOP_HANDOFF_CODE_ALPHABET.includes(character));
}
function parseDesktopHandoffCodeInput(value: string): string {
export function parseDesktopHandoffCodeInput(value: string): string {
return normalizeDesktopHandoffCode(value).slice(0, DESKTOP_HANDOFF_CODE_LENGTH);
}
@@ -90,8 +90,10 @@ export const InstanceEndpointsSchema = z
api_public: z.string().describe('Base URL for public API requests'),
gateway: z.string().describe('WebSocket URL for the gateway'),
media: z.string().describe('Base URL for the media proxy'),
upload_relay: z.string().optional().describe('Base URL for proxied attachment and preview uploads'),
static_cdn: z.string().describe('Base URL for static assets (avatars, emojis, etc.)'),
marketing: z.string().describe('Base URL for the marketing website'),
docs: z.string().optional().describe('Base URL for the documentation website'),
admin: z.string().describe('Base URL for the admin panel'),
invite: z.string().describe('Base URL for invite links'),
gift: z.string().describe('Base URL for gift links'),
@@ -137,6 +139,21 @@ export const InstanceFeaturesSchema = z
presigned_attachment_uploads: z.boolean().describe('Whether clients can request presigned attachment upload URLs'),
emails_enabled: z.boolean().describe('Whether the instance sends emails (verification, password reset, etc.)'),
phone_verification_enabled: z.boolean().describe('Deprecated. Always false.'),
desktop_modules_enabled: z.boolean().optional().describe('Whether desktop clients may load downloadable modules'),
account_deletion_grace_period_hours: z
.number()
.int()
.min(1)
.max(8760)
.optional()
.describe('Hours between a deletion request and the permanent deletion of the account'),
max_background_gateway_connections: z
.number()
.int()
.min(0)
.max(64)
.optional()
.describe('Maximum gateway connections a client may keep open for background accounts'),
account_identity: AccountIdentityModeSchema.optional().describe(
'How people sign in on this instance. Clients treat a missing value as email',
),
@@ -156,7 +173,9 @@ export const InstanceGifSchema = z
.describe('GIF provider configuration for clients');
export type InstanceGif = z.infer<typeof InstanceGifSchema>;
export const InstanceSsoSchema = SsoStatusResponse.describe('Single sign-on configuration');
export const InstanceSsoSchema = SsoStatusResponse.extend({
available: z.boolean().optional().describe('Whether an SSO provider is configured for this instance'),
}).describe('Single sign-on configuration');
export type InstanceSso = z.infer<typeof InstanceSsoSchema>;
export const InstanceRegistrationModeSchema = createNamedStringLiteralUnion(
@@ -206,14 +225,49 @@ export const InstanceServicesSchema = z
.describe('Optional third-party service integrations enabled for this instance');
export type InstanceServices = z.infer<typeof InstanceServicesSchema>;
export const InstancePushDeliveryModeSchema = z.enum(['direct', 'external_relay', 'hybrid']);
export type InstancePushDeliveryMode = z.infer<typeof InstancePushDeliveryModeSchema>;
export const InstancePushSchema = z
.object({
public_vapid_key: z.string().nullable().describe('VAPID public key for web push notifications'),
delivery_mode: InstancePushDeliveryModeSchema.optional().describe('How push notifications reach devices'),
event_resolution_path: z
.string()
.optional()
.describe('API path clients call to resolve the payload of a push event'),
external_relay_url: z.string().nullable().optional().describe('URL of the external push relay, if any'),
relay_signing_key_hashes: z
.array(z.string())
.nullable()
.optional()
.describe('Hashes of the keys the external push relay signs with'),
})
.describe('Push notification configuration');
export type InstancePush = z.infer<typeof InstancePushSchema>;
export const InstanceAgePolicyActionSchema = z.enum(['restrict', 'block']);
export type InstanceAgePolicyAction = z.infer<typeof InstanceAgePolicyActionSchema>;
export const InstanceAgePolicyGeoSchema = z
.object({
country_code: z.string().describe('ISO 3166-1 alpha-2 country code'),
region_code: z.string().nullable().describe('ISO 3166-2 subdivision code, or null for the whole country'),
action: InstanceAgePolicyActionSchema.describe('Whether the region restricts or blocks access'),
card_verification_available: z.boolean().describe('Whether card age verification is available in the region'),
})
.describe('Age policy for one region');
export type InstanceAgePolicyGeo = z.infer<typeof InstanceAgePolicyGeoSchema>;
export const InstanceAgePolicySchema = z
.object({
geos: z.array(InstanceAgePolicyGeoSchema).describe('Regions with an age policy'),
})
.describe('Regional age policy for this instance');
export type InstanceAgePolicy = z.infer<typeof InstanceAgePolicySchema>;
export const WellKnownFluxerResponse = z.object({
codename: z.string().optional().describe('Protocol generation spoken by this instance'),
api_code_version: z.number().int().describe('Version of the API server code'),
endpoints: InstanceEndpointsSchema,
captcha: InstanceCaptchaSchema,
@@ -226,6 +280,7 @@ export const WellKnownFluxerResponse = z.object({
limits: LimitConfigResponse.describe('Limit configuration with rules and trait definitions'),
push: InstancePushSchema,
app_public: InstanceAppPublicSchema.describe('Public application configuration for client-side features'),
age_policy: InstanceAgePolicySchema.optional().describe('Regional age policy for this instance'),
domain_migration: DomainMigrationDiscoveryResponse.optional().describe(
'Web domain migration switch and anonymous rollout, only acted on by official instance clients',
),