diff --git a/.gitignore b/.gitignore
index c7156d5f..80df2402 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,4 @@ asset
scripts/scrape_android_compose_animation_docs.py
tools
AGENTS.md
+server
\ No newline at end of file
diff --git a/server/sync-switch/.gitignore b/server/sync-switch/.gitignore
deleted file mode 100644
index 6b71ee4d..00000000
--- a/server/sync-switch/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-data/*.json
-*.log
diff --git a/server/sync-switch/index.js b/server/sync-switch/index.js
deleted file mode 100644
index 88a7fdb2..00000000
--- a/server/sync-switch/index.js
+++ /dev/null
@@ -1,613 +0,0 @@
-'use strict';
-
-const crypto = require('crypto');
-const fs = require('fs/promises');
-const http = require('http');
-const path = require('path');
-
-const BACKEND_HOSTED = 'hosted';
-const BACKEND_NUVIO = 'nuvio';
-const BACKENDS = new Set([BACKEND_HOSTED, BACKEND_NUVIO]);
-const DEFAULT_STATE_FILE = path.join(__dirname, 'data', 'state.json');
-
-const env = process.env;
-
-const serverConfig = {
- host: env.NUVIO_SWITCH_HOST || env.HOST || '0.0.0.0',
- port: parsePort(env.NUVIO_SWITCH_PORT || env.PORT || '3000'),
- stateFile: resolveStateFile(env.NUVIO_SWITCH_STATE_FILE || env.STATE_FILE),
- manifestVersion: parsePositiveInteger(env.NUVIO_SWITCH_VERSION || '1', 'NUVIO_SWITCH_VERSION'),
- defaultBackend: parseBackend(env.NUVIO_SWITCH_DEFAULT_BACKEND || 'hosted'),
- forceLogoutOnChange: parseBoolean(env.NUVIO_SWITCH_FORCE_LOGOUT_ON_CHANGE, true),
- adminUsername: env.NUVIO_SWITCH_ADMIN_USERNAME || env.ADMIN_USERNAME || 'admin',
- adminPassword: env.NUVIO_SWITCH_ADMIN_PASSWORD || env.ADMIN_PASSWORD || '',
- hosted: {
- displayName: env.NUVIO_SWITCH_HOSTED_DISPLAY_NAME || 'Hosted',
- supabaseUrl:
- env.NUVIO_SWITCH_HOSTED_SUPABASE_URL ||
- env.NUVIO_SWITCH_HOSTED_URL ||
- 'https://dpyhjjcoabcglfmgecug.supabase.co',
- anonKey:
- env.NUVIO_SWITCH_HOSTED_SUPABASE_ANON_KEY ||
- env.NUVIO_SWITCH_HOSTED_ANON_KEY ||
- '',
- avatarPublicBaseUrl:
- env.NUVIO_SWITCH_HOSTED_AVATAR_PUBLIC_BASE_URL ||
- 'https://dpyhjjcoabcglfmgecug.supabase.co/storage/v1/object/public/avatars',
- schemaVersion: parsePositiveInteger(
- env.NUVIO_SWITCH_HOSTED_SCHEMA_VERSION || '1',
- 'NUVIO_SWITCH_HOSTED_SCHEMA_VERSION'
- ),
- },
- nuvio: {
- displayName: env.NUVIO_SWITCH_NUVIO_DISPLAY_NAME || 'Nuvio',
- supabaseUrl:
- env.NUVIO_SWITCH_NUVIO_SUPABASE_URL ||
- env.NUVIO_SWITCH_NUVIO_URL ||
- 'https://api.nuvio.tv',
- anonKey:
- env.NUVIO_SWITCH_NUVIO_SUPABASE_ANON_KEY ||
- env.NUVIO_SWITCH_NUVIO_ANON_KEY ||
- 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzgxNTIxMzQ2LCJleHAiOjE5MzkyMDEzNDZ9.tmQaj682pwzehpqlgCDMnySOqiUvpgRbrE43T4VJpDI',
- avatarPublicBaseUrl:
- env.NUVIO_SWITCH_NUVIO_AVATAR_PUBLIC_BASE_URL ||
- 'https://api.nuvio.tv/storage/v1/object/public/avatars',
- schemaVersion: parsePositiveInteger(
- env.NUVIO_SWITCH_NUVIO_SCHEMA_VERSION || '1',
- 'NUVIO_SWITCH_NUVIO_SCHEMA_VERSION'
- ),
- },
-};
-
-const server = http.createServer((req, res) => {
- handleRequest(req, res).catch((error) => {
- console.error(error);
- if (error.statusCode === 413) {
- sendJson(res, 413, { error: 'request_body_too_large' });
- return;
- }
-
- sendJson(res, 500, { error: 'internal_server_error' });
- });
-});
-
-server.listen(serverConfig.port, serverConfig.host, () => {
- console.log(
- `Nuvio sync switch listening on http://${serverConfig.host}:${serverConfig.port}`
- );
- console.log(`State file: ${serverConfig.stateFile}`);
-});
-
-async function handleRequest(req, res) {
- const requestUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
- const pathname = normalizePath(requestUrl.pathname);
-
- if (req.method === 'GET' && pathname === '/health') {
- sendJson(res, 200, { ok: true });
- return;
- }
-
- if (req.method === 'GET' && pathname === '/config.json') {
- const state = await readState();
- sendJson(res, 200, buildManifest(state));
- return;
- }
-
- if (req.method === 'GET' && pathname === '/admin') {
- if (!requireAdmin(req, res)) return;
-
- const state = await readState();
- sendHtml(res, 200, renderAdminPage(state));
- return;
- }
-
- if (req.method === 'POST' && pathname === '/admin/backend') {
- if (!requireAdmin(req, res)) return;
-
- const body = await readRequestBody(req);
- const backend = parseBackendFromRequest(req, body);
- if (!backend) {
- sendJson(res, 400, {
- error: 'invalid_backend',
- message: 'Expected activeBackend or backend to be hosted or nuvio.',
- });
- return;
- }
-
- const state = await writeBackend(backend);
- if (prefersHtml(req)) {
- sendRedirect(res, '/admin');
- return;
- }
-
- sendJson(res, 200, {
- ok: true,
- activeBackend: state.activeBackend,
- revision: state.revision,
- });
- return;
- }
-
- if (pathname === '/admin' || pathname === '/admin/backend') {
- sendJson(res, 405, { error: 'method_not_allowed' }, { Allow: 'GET, POST' });
- return;
- }
-
- sendJson(res, 404, { error: 'not_found' });
-}
-
-function normalizePath(pathname) {
- if (pathname.length > 1 && pathname.endsWith('/')) {
- return pathname.slice(0, -1);
- }
-
- return pathname;
-}
-
-function buildManifest(state) {
- return {
- version: serverConfig.manifestVersion,
- activeBackend: state.activeBackend,
- revision: String(state.revision),
- forceLogoutOnChange: serverConfig.forceLogoutOnChange,
- backends: {
- hosted: publicBackendConfig(serverConfig.hosted),
- nuvio: publicBackendConfig(serverConfig.nuvio),
- },
- };
-}
-
-function publicBackendConfig(config) {
- return {
- displayName: config.displayName,
- supabaseUrl: normalizeBaseUrl(config.supabaseUrl),
- anonKey: config.anonKey,
- avatarPublicBaseUrl: normalizeBaseUrl(config.avatarPublicBaseUrl),
- schemaVersion: config.schemaVersion,
- };
-}
-
-async function readState() {
- try {
- const rawState = await fs.readFile(serverConfig.stateFile, 'utf8');
- return normalizeState(JSON.parse(rawState));
- } catch (error) {
- if (error.code === 'ENOENT') {
- const initialState = {
- activeBackend: serverConfig.defaultBackend,
- revision: 1,
- updatedAt: new Date().toISOString(),
- };
- await writeState(initialState);
- return initialState;
- }
-
- if (error instanceof SyntaxError) {
- throw new Error(`State file is not valid JSON: ${serverConfig.stateFile}`);
- }
-
- throw error;
- }
-}
-
-function normalizeState(state) {
- const activeBackend = parseBackend(state.activeBackend);
- const revision = Number.isInteger(state.revision) && state.revision > 0 ? state.revision : 1;
-
- return {
- activeBackend,
- revision,
- updatedAt: typeof state.updatedAt === 'string' ? state.updatedAt : undefined,
- };
-}
-
-async function writeBackend(activeBackend) {
- const currentState = await readState();
- const nextRevision =
- currentState.activeBackend === activeBackend
- ? currentState.revision
- : currentState.revision + 1;
-
- const nextState = {
- activeBackend,
- revision: nextRevision,
- updatedAt: new Date().toISOString(),
- };
-
- await writeState(nextState);
- return nextState;
-}
-
-async function writeState(state) {
- const stateDir = path.dirname(serverConfig.stateFile);
- const tempFile = path.join(
- stateDir,
- `.${path.basename(serverConfig.stateFile)}.${process.pid}.${crypto.randomUUID()}.tmp`
- );
-
- await fs.mkdir(stateDir, { recursive: true });
- await fs.writeFile(tempFile, `${JSON.stringify(state, null, 2)}\n`, {
- encoding: 'utf8',
- mode: 0o600,
- });
- await fs.rename(tempFile, serverConfig.stateFile);
-}
-
-function requireAdmin(req, res) {
- if (!serverConfig.adminPassword) {
- sendJson(res, 503, {
- error: 'admin_password_not_configured',
- message: 'Set NUVIO_SWITCH_ADMIN_PASSWORD before using admin endpoints.',
- });
- return false;
- }
-
- const authHeader = req.headers.authorization || '';
- if (!authHeader.startsWith('Basic ')) {
- sendAuthRequired(res);
- return false;
- }
-
- const credentials = Buffer.from(authHeader.slice('Basic '.length), 'base64').toString('utf8');
- const separatorIndex = credentials.indexOf(':');
- const username = separatorIndex >= 0 ? credentials.slice(0, separatorIndex) : '';
- const password = separatorIndex >= 0 ? credentials.slice(separatorIndex + 1) : '';
-
- if (
- timingSafeEqual(username, serverConfig.adminUsername) &&
- timingSafeEqual(password, serverConfig.adminPassword)
- ) {
- return true;
- }
-
- sendAuthRequired(res);
- return false;
-}
-
-function timingSafeEqual(input, expected) {
- const inputBuffer = Buffer.from(input);
- const expectedBuffer = Buffer.from(expected);
-
- if (inputBuffer.length !== expectedBuffer.length) {
- return false;
- }
-
- return crypto.timingSafeEqual(inputBuffer, expectedBuffer);
-}
-
-function sendAuthRequired(res) {
- sendText(res, 401, 'Authentication required.\n', {
- 'WWW-Authenticate': 'Basic realm="Nuvio Sync Switch", charset="UTF-8"',
- });
-}
-
-async function readRequestBody(req) {
- const chunks = [];
- let bytesRead = 0;
- const maxBytes = 16 * 1024;
-
- for await (const chunk of req) {
- bytesRead += chunk.length;
- if (bytesRead > maxBytes) {
- const error = new Error('Request body too large.');
- error.statusCode = 413;
- throw error;
- }
-
- chunks.push(chunk);
- }
-
- return Buffer.concat(chunks).toString('utf8');
-}
-
-function parseBackendFromRequest(req, body) {
- const contentType = String(req.headers['content-type'] || '').split(';')[0].trim();
-
- if (contentType === 'application/json') {
- try {
- const parsed = JSON.parse(body || '{}');
- return safeParseBackend(parsed.activeBackend || parsed.backend);
- } catch {
- return null;
- }
- }
-
- const formData = new URLSearchParams(body);
- return safeParseBackend(formData.get('activeBackend') || formData.get('backend'));
-}
-
-function parseBackend(value) {
- const backend = String(value || '').trim().toLowerCase();
- if (BACKENDS.has(backend)) {
- return backend;
- }
-
- throw new Error(`Invalid backend "${value}". Expected hosted or nuvio.`);
-}
-
-function safeParseBackend(value) {
- const backend = String(value || '').trim().toLowerCase();
- return BACKENDS.has(backend) ? backend : null;
-}
-
-function parseBoolean(value, defaultValue) {
- if (value === undefined || value === '') {
- return defaultValue;
- }
-
- return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase());
-}
-
-function parsePort(value) {
- const port = Number(value);
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
- throw new Error(`Invalid port "${value}".`);
- }
-
- return port;
-}
-
-function parsePositiveInteger(value, name) {
- const parsed = Number(value);
- if (!Number.isInteger(parsed) || parsed < 1) {
- throw new Error(`Invalid ${name} "${value}".`);
- }
-
- return parsed;
-}
-
-function normalizeBaseUrl(value) {
- return String(value || '').trim().replace(/\/+$/, '');
-}
-
-function resolveStateFile(value) {
- if (!value) {
- return DEFAULT_STATE_FILE;
- }
-
- if (path.isAbsolute(value)) {
- return value;
- }
-
- return path.resolve(process.cwd(), value);
-}
-
-function prefersHtml(req) {
- const accept = req.headers.accept || '';
- const contentType = req.headers['content-type'] || '';
- return contentType.includes('application/x-www-form-urlencoded') && accept.includes('text/html');
-}
-
-function sendJson(res, statusCode, payload, headers = {}) {
- send(res, statusCode, JSON.stringify(payload, null, 2), {
- 'Content-Type': 'application/json; charset=utf-8',
- ...headers,
- });
-}
-
-function sendHtml(res, statusCode, body, headers = {}) {
- send(res, statusCode, body, {
- 'Content-Type': 'text/html; charset=utf-8',
- ...headers,
- });
-}
-
-function sendText(res, statusCode, body, headers = {}) {
- send(res, statusCode, body, {
- 'Content-Type': 'text/plain; charset=utf-8',
- ...headers,
- });
-}
-
-function sendRedirect(res, location) {
- res.writeHead(303, {
- Location: location,
- 'Cache-Control': 'no-store',
- 'X-Content-Type-Options': 'nosniff',
- });
- res.end();
-}
-
-function send(res, statusCode, body, headers = {}) {
- res.writeHead(statusCode, {
- 'Cache-Control': 'no-store',
- 'X-Content-Type-Options': 'nosniff',
- ...headers,
- });
- res.end(body);
-}
-
-function renderAdminPage(state) {
- const hostedActive = state.activeBackend === BACKEND_HOSTED;
- const nuvioActive = state.activeBackend === BACKEND_NUVIO;
-
- return `
-
-
-
-
- Nuvio Sync Switch
-
-
-
-
- Nuvio Sync Switch
- Select the Supabase backend exposed by /config.json.
-
-
-
Active backend
-
${escapeHtml(state.activeBackend)}
-
-
-
Revision
-
${escapeHtml(String(state.revision))}
-
-
-
Force logout
-
${serverConfig.forceLogoutOnChange ? 'true' : 'false'}
-
-
-
-
-
-
-`;
-}
-
-function escapeHtml(value) {
- return value.replace(/[&<>"']/g, (char) => {
- switch (char) {
- case '&':
- return '&';
- case '<':
- return '<';
- case '>':
- return '>';
- case '"':
- return '"';
- case "'":
- return ''';
- default:
- return char;
- }
- });
-}
diff --git a/server/sync-switch/package.json b/server/sync-switch/package.json
deleted file mode 100644
index 6bde2bea..00000000
--- a/server/sync-switch/package.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "name": "nuvio-sync-switch",
- "version": "0.1.0",
- "private": true,
- "description": "Minimal VPS-hosted switch API for Nuvio Supabase backend selection.",
- "main": "index.js",
- "scripts": {
- "start": "node index.js",
- "check": "node --check index.js"
- },
- "engines": {
- "node": ">=18"
- }
-}