mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-31 08:39:24 +00:00
cleanup
This commit is contained in:
parent
4a883a1add
commit
4936f3268a
4 changed files with 1 additions and 629 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -28,3 +28,4 @@ asset
|
|||
scripts/scrape_android_compose_animation_docs.py
|
||||
tools
|
||||
AGENTS.md
|
||||
server
|
||||
2
server/sync-switch/.gitignore
vendored
2
server/sync-switch/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
data/*.json
|
||||
*.log
|
||||
|
|
@ -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 `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Nuvio Sync Switch</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #f5f7fb;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(720px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
line-height: 1.1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 28px 0;
|
||||
}
|
||||
|
||||
.metric,
|
||||
.actions {
|
||||
border: 1px solid #d8deea;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.metric {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin: 0 0 4px;
|
||||
color: #5d6b82;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.value {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #1f6feb;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #8c98aa;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #5d6b82;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
main {
|
||||
padding: 28px 0;
|
||||
}
|
||||
|
||||
.status,
|
||||
.actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background: #101622;
|
||||
color: #edf2fb;
|
||||
}
|
||||
|
||||
.metric,
|
||||
.actions {
|
||||
background: #172033;
|
||||
border-color: #303b4d;
|
||||
}
|
||||
|
||||
.label,
|
||||
.hint {
|
||||
color: #a9b6ca;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Nuvio Sync Switch</h1>
|
||||
<p class="hint">Select the Supabase backend exposed by <code>/config.json</code>.</p>
|
||||
<section class="status" aria-label="Current status">
|
||||
<div class="metric">
|
||||
<p class="label">Active backend</p>
|
||||
<p class="value">${escapeHtml(state.activeBackend)}</p>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<p class="label">Revision</p>
|
||||
<p class="value">${escapeHtml(String(state.revision))}</p>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<p class="label">Force logout</p>
|
||||
<p class="value">${serverConfig.forceLogoutOnChange ? 'true' : 'false'}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="actions" aria-label="Backend switch actions">
|
||||
<form method="post" action="/admin/backend">
|
||||
<input type="hidden" name="activeBackend" value="hosted">
|
||||
<button type="submit" ${hostedActive ? 'disabled' : ''} data-target="hosted">Switch to Hosted</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/backend">
|
||||
<input type="hidden" name="activeBackend" value="nuvio">
|
||||
<button type="submit" ${nuvioActive ? 'disabled' : ''} data-target="nuvio">Switch to Nuvio</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
for (const form of document.querySelectorAll('form')) {
|
||||
form.addEventListener('submit', (event) => {
|
||||
const target = form.querySelector('input[name="activeBackend"]').value;
|
||||
if (!window.confirm('Switch Nuvio sync backend to ' + target + '? Active app sessions will be signed out once.')) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return value.replace(/[&<>"']/g, (char) => {
|
||||
switch (char) {
|
||||
case '&':
|
||||
return '&';
|
||||
case '<':
|
||||
return '<';
|
||||
case '>':
|
||||
return '>';
|
||||
case '"':
|
||||
return '"';
|
||||
case "'":
|
||||
return ''';
|
||||
default:
|
||||
return char;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue