mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
feat(desktop): bundled renderer, modules and instance accounts (#3254)
This commit is contained in:
1 parent
c748c8af4e
commit
7c5fa2180a
1275 files changed
+215074
-81246
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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()}`;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfigs/package.json",
|
||||
"compilerOptions": {},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user