Skip to content

Commit a0ce76c

Browse files
committed
feat(dashboard): manage feature flags from the admin dashboard
Add a KV-backed feature-flag store to the cumulusvpn-dashboard Worker, edited from a new password-gated /admin dashboard: - GET /api/flags (public) serves the flags the mobile app reads at launch, same JSON shape as flags.json; falls back to all-OFF if KV is empty. - POST /api/flags + /api/admin/verify are gated by the ADMIN_TOKEN secret (constant-time SHA-256 compare). - /admin: sign in, toggle inAppUpgrade per platform, save; carries a store-compliance warning against enabling it for store builds. Repoint the mobile app's FLAGS_URL to dashboard.cumulusvpn.com/api/flags (JSON shape unchanged, so resolveFlags + tests are untouched); flags.json becomes the documented default / KV seed. wrangler.jsonc binds FLAGS_KV and provisions the dashboard.cumulusvpn.com custom domain.
1 parent 25ad0a1 commit a0ce76c

6 files changed

Lines changed: 448 additions & 8 deletions

File tree

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<meta name="robots" content="noindex, nofollow" />
7+
<title>Admin · CumulusVPN</title>
8+
<meta name="theme-color" content="#070B11" />
9+
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
10+
<style>
11+
@font-face { font-family:"Inter"; font-weight:400; font-display:swap; src:url("/fonts/inter-400.woff2") format("woff2"); }
12+
@font-face { font-family:"Inter"; font-weight:500; font-display:swap; src:url("/fonts/inter-500.woff2") format("woff2"); }
13+
@font-face { font-family:"Inter"; font-weight:600; font-display:swap; src:url("/fonts/inter-600.woff2") format("woff2"); }
14+
@font-face { font-family:"Inter"; font-weight:700; font-display:swap; src:url("/fonts/inter-700.woff2") format("woff2"); }
15+
:root {
16+
--bg:#070B11; --surface:#10171F; --surface-2:#161F2A; --ink:#EAF1F8; --ink-2:#A6B6C6;
17+
--ink-3:#6C7E90; --line:#1E2A37; --cyan:#34E4DA; --amber:#F5B23D; --red:#F26D6D; --green:#34D399;
18+
--mono: ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;
19+
--sans:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
20+
}
21+
@media (prefers-color-scheme: light) {
22+
:root { --bg:#F5F8FB; --surface:#FFFFFF; --surface-2:#F0F4F8; --ink:#0C1420; --ink-2:#46586B;
23+
--ink-3:#7B8D9E; --line:#DCE5EE; --cyan:#0FB9AE; --amber:#C77F17; --red:#C4443A; --green:#1F9D6B; }
24+
}
25+
* { box-sizing:border-box; }
26+
body { margin:0; background:var(--bg); color:var(--ink); font-family:var(--sans);
27+
-webkit-font-smoothing:antialiased; line-height:1.5; min-height:100vh; }
28+
.wrap { max-width:560px; margin:0 auto; padding:48px 24px 80px; }
29+
.brand { display:flex; align-items:center; gap:11px; font-weight:700; letter-spacing:-.02em; font-size:18px; margin-bottom:6px; }
30+
.mark { width:26px; height:26px; }
31+
.sub { color:var(--ink-3); font-family:var(--mono); font-size:12px; letter-spacing:.06em; text-transform:uppercase; margin-bottom:28px; }
32+
.card { background:var(--surface); border:1px solid var(--line); border-radius:16px; padding:24px; margin-bottom:18px;
33+
box-shadow:0 30px 64px -40px rgba(3,8,16,.55); }
34+
h2 { font-size:16px; letter-spacing:-.01em; margin:0 0 4px; }
35+
.hint { color:var(--ink-3); font-size:13px; margin:0 0 16px; }
36+
label.fld { display:block; font-size:12px; color:var(--ink-3); text-transform:uppercase; letter-spacing:.06em; margin-bottom:7px; }
37+
input[type=password] { width:100%; font:inherit; font-size:15px; background:var(--surface-2); color:var(--ink);
38+
border:1px solid var(--line); border-radius:10px; padding:12px 14px; }
39+
input[type=password]:focus { outline:none; border-color:var(--cyan); box-shadow:0 0 0 3px color-mix(in srgb,var(--cyan) 30%,transparent); }
40+
.btn { font:inherit; font-weight:600; font-size:15px; border:1px solid transparent; border-radius:11px; padding:12px 18px;
41+
cursor:pointer; display:inline-flex; align-items:center; gap:8px; }
42+
.btn.primary { background:var(--cyan); color:#04201d; }
43+
.btn.primary:hover { filter:brightness(1.06); }
44+
.btn.ghost { background:transparent; border-color:var(--line); color:var(--ink-2); }
45+
.btn.ghost:hover { border-color:var(--ink-3); color:var(--ink); }
46+
.btn.sm { padding:8px 14px; font-size:13px; }
47+
/* dashboard section scaffolding */
48+
.dash-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; }
49+
.sec-title { font-size:13px; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--ink-2); }
50+
.row { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:16px 0; border-top:1px solid var(--line); }
51+
.row:first-of-type { border-top:0; }
52+
.row .k { font-weight:600; font-size:15px; }
53+
.row .k small { display:block; color:var(--ink-3); font-weight:400; font-size:12.5px; margin-top:2px; }
54+
/* toggle */
55+
.sw { position:relative; width:46px; height:27px; flex-shrink:0; }
56+
.sw input { position:absolute; opacity:0; width:100%; height:100%; margin:0; cursor:pointer; }
57+
.sw .track { position:absolute; inset:0; background:var(--surface-2); border:1px solid var(--line); border-radius:99px; transition:.18s; }
58+
.sw .knob { position:absolute; top:3px; left:3px; width:20px; height:20px; border-radius:50%; background:var(--ink-3); transition:.18s; }
59+
.sw input:checked ~ .track { background:color-mix(in srgb,var(--amber) 26%,transparent); border-color:var(--amber); }
60+
.sw input:checked ~ .knob { left:22px; background:var(--amber); }
61+
.sw input:focus-visible ~ .track { box-shadow:0 0 0 3px color-mix(in srgb,var(--cyan) 30%,transparent); }
62+
.warn { border:1px solid color-mix(in srgb,var(--amber) 45%,var(--line)); background:color-mix(in srgb,var(--amber) 12%,transparent);
63+
border-radius:12px; padding:14px 16px; font-size:13px; line-height:1.55; color:var(--ink); margin-bottom:20px; }
64+
.warn b { color:var(--amber); }
65+
.foot { display:flex; align-items:center; justify-content:space-between; gap:14px; margin-top:22px; }
66+
.updated { color:var(--ink-3); font-family:var(--mono); font-size:11.5px; }
67+
.err { color:var(--red); font-size:13px; margin-top:12px; min-height:1em; }
68+
#toast { position:fixed; left:50%; bottom:28px; transform:translateX(-50%) translateY(20px); opacity:0; pointer-events:none;
69+
background:var(--surface); border:1px solid var(--line); border-radius:10px; padding:11px 18px; font-size:14px; font-weight:600;
70+
box-shadow:0 20px 50px -20px rgba(0,0,0,.5); transition:.2s; }
71+
#toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
72+
#toast.ok { border-color:color-mix(in srgb,var(--green) 50%,var(--line)); }
73+
#toast.bad { border-color:color-mix(in srgb,var(--red) 50%,var(--line)); }
74+
[hidden] { display:none !important; }
75+
</style>
76+
</head>
77+
<body>
78+
<div class="wrap">
79+
<div class="brand">
80+
<svg class="mark" viewBox="0 0 32 32" fill="none" aria-hidden="true">
81+
<path d="M9 21h13a5 5 0 0 0 .6-9.96A7 7 0 0 0 8.5 12 4.5 4.5 0 0 0 9 21Z" fill="currentColor" opacity=".18"/>
82+
<path d="M9 21h13a5 5 0 0 0 .6-9.96A7 7 0 0 0 8.5 12 4.5 4.5 0 0 0 9 21Z" stroke="var(--cyan)" stroke-width="1.7"/>
83+
<path d="M14 15l-2.5 4h3l-1 4 4.5-6h-3l1.5-2.6z" fill="var(--cyan)"/>
84+
</svg>
85+
CumulusVPN
86+
</div>
87+
<div class="sub">Admin · internal</div>
88+
89+
<!-- LOGIN -->
90+
<div class="card" id="login" hidden>
91+
<h2>Sign in</h2>
92+
<p class="hint">Enter the admin password to continue.</p>
93+
<label class="fld" for="pw">Admin password</label>
94+
<input type="password" id="pw" autocomplete="current-password" placeholder="••••••••••" />
95+
<div class="foot">
96+
<span></span>
97+
<button class="btn primary" id="signin">Sign in</button>
98+
</div>
99+
<div class="err" id="login-err"></div>
100+
</div>
101+
102+
<!-- DASHBOARD (shown after auth) -->
103+
<div id="editor" hidden>
104+
<div class="dash-head">
105+
<div class="sec-title">Feature flags</div>
106+
<button class="btn ghost sm" id="signout">Sign out</button>
107+
</div>
108+
<div class="card">
109+
<div class="warn">
110+
<b>⚠ Store-compliance:</b> turning <code>inAppUpgrade</code> <b>on</b> shows the in-app FLUX
111+
purchase UI (QR + wallet). <b>Do not enable it for a build that is under, or has passed,
112+
App&nbsp;Store / Google&nbsp;Play review</b> — a post-review behaviour change is a policy
113+
violation. Direct-APK / web / testing builds only.
114+
</div>
115+
116+
<h2>inAppUpgrade</h2>
117+
<p class="hint">In-app FLUX upgrade flow, per platform. Off = store-compliant "manage on web".</p>
118+
119+
<div class="row">
120+
<div class="k">Android <small>com.cumulusvpn.app</small></div>
121+
<label class="sw"><input type="checkbox" id="t-android" /><span class="track"></span><span class="knob"></span></label>
122+
</div>
123+
<div class="row">
124+
<div class="k">iOS <small>com.cumulusvpn.app (App Store)</small></div>
125+
<label class="sw"><input type="checkbox" id="t-ios" /><span class="track"></span><span class="knob"></span></label>
126+
</div>
127+
128+
<div class="foot">
129+
<span class="updated" id="updated"></span>
130+
<button class="btn primary" id="save">Save</button>
131+
</div>
132+
<div class="err" id="save-err"></div>
133+
</div>
134+
</div>
135+
</div>
136+
137+
<div id="toast"></div>
138+
139+
<script>
140+
var $ = function (id) { return document.getElementById(id); };
141+
var TOKEN_KEY = 'cvpn-admin-token';
142+
var token = '';
143+
try { token = sessionStorage.getItem(TOKEN_KEY) || ''; } catch (e) {}
144+
145+
function toast(msg, ok) {
146+
var t = $('toast');
147+
t.textContent = msg;
148+
t.className = 'show ' + (ok ? 'ok' : 'bad');
149+
clearTimeout(toast._t);
150+
toast._t = setTimeout(function () { t.className = ''; }, 2600);
151+
}
152+
153+
function verify(tok) {
154+
return fetch('/api/admin/verify', {
155+
method: 'POST', headers: { authorization: 'Bearer ' + tok },
156+
}).then(function (r) { return r.ok; }).catch(function () { return false; });
157+
}
158+
159+
function loadFlags() {
160+
return fetch('/api/flags').then(function (r) { return r.json(); }).then(function (f) {
161+
var u = (f && f.inAppUpgrade) || {};
162+
$('t-android').checked = u.android === true;
163+
$('t-ios').checked = u.ios === true;
164+
$('updated').textContent = f && f.updatedAt
165+
? 'Last updated ' + new Date(f.updatedAt).toLocaleString()
166+
: 'Never updated (serving defaults)';
167+
});
168+
}
169+
170+
function showEditor() { $('login').hidden = true; $('editor').hidden = false; loadFlags(); }
171+
function showLogin() { $('editor').hidden = true; $('login').hidden = false; $('pw').focus(); }
172+
173+
function signOut() {
174+
token = '';
175+
try { sessionStorage.removeItem(TOKEN_KEY); } catch (e) {}
176+
showLogin();
177+
}
178+
179+
function doLogin() {
180+
var pw = $('pw').value;
181+
$('login-err').textContent = '';
182+
if (!pw) return;
183+
verify(pw).then(function (ok) {
184+
if (!ok) { $('login-err').textContent = 'Wrong password.'; return; }
185+
token = pw;
186+
try { sessionStorage.setItem(TOKEN_KEY, pw); } catch (e) {}
187+
$('pw').value = '';
188+
showEditor();
189+
});
190+
}
191+
192+
function save() {
193+
$('save-err').textContent = '';
194+
var body = { inAppUpgrade: { android: $('t-android').checked, ios: $('t-ios').checked } };
195+
fetch('/api/flags', {
196+
method: 'POST',
197+
headers: { authorization: 'Bearer ' + token, 'content-type': 'application/json' },
198+
body: JSON.stringify(body),
199+
}).then(function (r) {
200+
if (r.status === 401) { signOut(); toast('Session expired — sign in again', false); return null; }
201+
if (!r.ok) { toast('Save failed (' + r.status + ')', false); return null; }
202+
return r.json();
203+
}).then(function (f) {
204+
if (!f) return;
205+
$('updated').textContent = 'Last updated ' + new Date(f.updatedAt).toLocaleString();
206+
toast('Saved', true);
207+
}).catch(function () { toast('Network error', false); });
208+
}
209+
210+
$('signin').addEventListener('click', doLogin);
211+
$('pw').addEventListener('keydown', function (e) { if (e.key === 'Enter') doLogin(); });
212+
$('save').addEventListener('click', save);
213+
$('signout').addEventListener('click', signOut);
214+
215+
// init: resume a stored session if the token still verifies
216+
(function () {
217+
if (!token) { showLogin(); return; }
218+
verify(token).then(function (ok) {
219+
if (ok) showEditor(); else signOut();
220+
});
221+
})();
222+
</script>
223+
</body>
224+
</html>

clients/dashboard/worker.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,105 @@ async function fleet() {
110110
};
111111
}
112112

113+
// ---- Feature flags (KV-backed, edited via /admin) ---------------------------
114+
// The mobile app fetches GET /api/flags at launch (same JSON shape as the repo's
115+
// flags.json). Writes go through POST /api/flags, gated by the ADMIN_TOKEN secret.
116+
const FLAGS_KEY = 'flags';
117+
// Fail-safe fallback when KV is empty/unreadable: everything OFF (store-safe).
118+
const DEFAULT_FLAGS = { inAppUpgrade: { android: false, ios: false } };
119+
120+
function jsonResponse(obj, extraHeaders = {}, status = 200) {
121+
return new Response(JSON.stringify(obj), {
122+
status,
123+
headers: { 'content-type': 'application/json; charset=utf-8', ...extraHeaders },
124+
});
125+
}
126+
127+
/** Bearer token from the Authorization header, or null. */
128+
function bearerToken(request) {
129+
const m = /^Bearer\s+(.+)$/i.exec(request.headers.get('authorization') || '');
130+
return m ? m[1].trim() : null;
131+
}
132+
133+
/** Constant-time compare of provided vs secret via fixed-length SHA-256 digests. */
134+
async function tokenMatches(provided, secret) {
135+
if (!provided || !secret) return false;
136+
const enc = new TextEncoder();
137+
const [a, b] = await Promise.all([
138+
crypto.subtle.digest('SHA-256', enc.encode(provided)),
139+
crypto.subtle.digest('SHA-256', enc.encode(secret)),
140+
]);
141+
const x = new Uint8Array(a);
142+
const y = new Uint8Array(b);
143+
let diff = 0;
144+
for (let i = 0; i < x.length; i++) diff |= x[i] ^ y[i];
145+
return diff === 0;
146+
}
147+
148+
/** Coerce arbitrary input to the strict flags shape, or null if invalid. */
149+
function validateFlags(body) {
150+
if (!body || typeof body !== 'object') return null;
151+
const u = body.inAppUpgrade;
152+
if (!u || typeof u !== 'object') return null;
153+
if (typeof u.android !== 'boolean' || typeof u.ios !== 'boolean') return null;
154+
return { inAppUpgrade: { android: u.android, ios: u.ios } };
155+
}
156+
157+
/** Current flags from KV (validated), or the fail-safe default. */
158+
async function readFlags(env) {
159+
try {
160+
const raw = await env.FLAGS_KV.get(FLAGS_KEY);
161+
if (!raw) return { ...DEFAULT_FLAGS, updatedAt: null };
162+
const parsed = JSON.parse(raw);
163+
const valid = validateFlags(parsed);
164+
if (!valid) return { ...DEFAULT_FLAGS, updatedAt: null };
165+
return { ...valid, updatedAt: parsed.updatedAt ?? null };
166+
} catch {
167+
return { ...DEFAULT_FLAGS, updatedAt: null };
168+
}
169+
}
170+
113171
export default {
114172
async fetch(request, env, ctx) {
115173
const url = new URL(request.url);
116174

175+
// ---- feature flags: public read, token-gated write ----
176+
if (url.pathname === '/api/flags' && request.method === 'GET') {
177+
const flags = await readFlags(env);
178+
return jsonResponse(flags, {
179+
'cache-control': 'public, max-age=60',
180+
'access-control-allow-origin': '*',
181+
});
182+
}
183+
184+
if (url.pathname === '/api/admin/verify' && request.method === 'POST') {
185+
const ok = await tokenMatches(bearerToken(request), env.ADMIN_TOKEN);
186+
return jsonResponse({ ok }, {}, ok ? 200 : 401);
187+
}
188+
189+
if (url.pathname === '/api/flags' && request.method === 'POST') {
190+
if (!(await tokenMatches(bearerToken(request), env.ADMIN_TOKEN))) {
191+
return jsonResponse({ error: 'unauthorized' }, {}, 401);
192+
}
193+
let body;
194+
try {
195+
body = await request.json();
196+
} catch {
197+
return jsonResponse({ error: 'invalid JSON' }, {}, 400);
198+
}
199+
const valid = validateFlags(body);
200+
if (!valid) {
201+
return jsonResponse(
202+
{ error: 'expected { inAppUpgrade: { android: boolean, ios: boolean } }' },
203+
{},
204+
400,
205+
);
206+
}
207+
const record = { ...valid, updatedAt: new Date().toISOString() };
208+
await env.FLAGS_KV.put(FLAGS_KEY, JSON.stringify(record));
209+
return jsonResponse(record, { 'access-control-allow-origin': '*' });
210+
}
211+
117212
if (url.pathname === '/api/fleet') {
118213
const cache = caches.default;
119214
const key = new Request(new URL('/api/fleet', url.origin).toString(), { method: 'GET' });

clients/dashboard/wrangler.jsonc

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,18 @@
1919
// Worker runs first so /api/fleet reaches it before asset routing.
2020
"run_worker_first": true,
2121
"not_found_handling": "single-page-application"
22-
}
22+
},
23+
// Feature-flag store (KV), read/written by worker.js and edited at /admin.
24+
// One-time setup (needs CF/wrangler access):
25+
// wrangler kv namespace create FLAGS_KV # paste the returned id below
26+
// wrangler secret put ADMIN_TOKEN --config clients/dashboard/wrangler.jsonc
27+
// wrangler deploy --config clients/dashboard/wrangler.jsonc
28+
// wrangler kv key put --binding=FLAGS_KV flags "$(cat ../../flags.json)" # seed
29+
"kv_namespaces": [
30+
{ "binding": "FLAGS_KV", "id": "7ac10be1d3724e69b7d58bd3939ba5d6" }
31+
],
32+
// Custom domain — provisioned on deploy (Cloudflare manages the DNS record + cert).
33+
"routes": [
34+
{ "pattern": "dashboard.cumulusvpn.com", "custom_domain": true }
35+
]
2336
}

clients/mobile/src/lib/flags.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
/**
22
* Remote per-platform feature flags.
33
*
4-
* Fetched at launch from a simple JSON file in the public repo, so a feature can
5-
* be flipped on/off per platform WITHOUT an app update (e.g. keep the in-app
6-
* crypto-pay upgrade off on iOS for App Store review, on for Android). If the
7-
* fetch fails for ANY reason, every flag defaults to OFF — the safe, store-
8-
* compliant state (upgrade falls back to "manage on the web").
4+
* Fetched at launch from the internal dashboard's KV-backed endpoint, so a
5+
* feature can be flipped on/off per platform from the dashboard
6+
* (dashboard.cumulusvpn.com/admin) WITHOUT an app update — e.g. keep the in-app
7+
* crypto-pay upgrade off on iOS for App Store review. If the fetch fails for ANY
8+
* reason, every flag defaults to OFF — the safe, store-compliant state (upgrade
9+
* falls back to "manage on the web"). The endpoint returns the same JSON shape
10+
* as the repo's flags.json (the documented default / KV seed).
911
*/
1012
import { Platform } from 'react-native';
1113

12-
const FLAGS_URL = 'https://raw.githubusercontent.com/RunOnFlux/cumulusvpn/main/flags.json';
14+
const FLAGS_URL = 'https://dashboard.cumulusvpn.com/api/flags';
1315

1416
export interface Flags {
1517
/**

0 commit comments

Comments
 (0)