Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 7 additions & 15 deletions website/snap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@

- a public, App Store-style catalog for CapOS users;
- Snap Store compatible `/v2/*` federation endpoints and proxied upstream downloads;
- a PIN-protected `/admin` console for repository versions, upstream order, local packages and CapOS metadata.
- a Cloudflare Access-protected `/admin` console for repository versions, upstream order, local packages and CapOS metadata.

## Architecture

- **Cloudflare Worker**: API, authentication, upstream federation, streaming proxy and authenticated package upload controller.
- **D1**: repository versions, ordered upstreams, local Snap metadata, sessions and audit log.
- **Cloudflare Worker**: API, Access JWT verification, upstream federation, streaming proxy and authenticated package upload controller.
- **D1**: repository versions, ordered upstreams, local Snap metadata and audit log.
- **Cloudflare R2 / `repo.capos.top`**: the existing `capos` bucket stores local artifacts at `/<version>/snaps/*.snap`.
- **Canonical/other upstreams**: queried in priority order. Their Snap downloads are proxied in real time and are never mirrored into R2.

Expand All @@ -24,7 +24,7 @@ npm install
npm run dev
```

Vite development mode uses representative catalog/admin data so the UI can be developed without production credentials. Any non-empty PIN unlocks the local admin preview.
Vite development mode uses representative catalog/admin data so the UI can be developed without production credentials.

## Production setup

Expand All @@ -34,19 +34,11 @@ The production bootstrap is intentionally one command:
npm run setup:production
```

It authenticates Wrangler if necessary, reuses or creates the `capos-snap-store` D1 database in APAC, writes its non-secret database ID into `wrangler.toml`, applies migrations, prompts for the administrator PIN, generates `SESSION_SECRET` and `ADMIN_PIN_HASH`, builds and deploys the Worker, binds the existing `capos` R2 bucket, creates the `snap.capos.top` custom domain, and verifies the public endpoints.
It authenticates Wrangler if necessary, reuses or creates the `capos-snap-store` D1 database in APAC, writes its non-secret database ID into `wrangler.toml`, applies migrations, builds and deploys the Worker, binds the existing `capos` R2 bucket, creates the `snap.capos.top` custom domain, and verifies the public endpoints.

The only interactive steps are Cloudflare authorization in the browser and choosing the administrator PIN. The generated session secret and PIN hash are sent to Cloudflare through Wrangler and are never written to the repository.
Production admin access is enforced by the `CapOS Snap Admin` Cloudflare Access application. It protects `/admin`, `/admin/*`, `/api/admin` and `/api/admin/*`; the Worker also validates the Access JWT audience before serving admin APIs. The public storefront and `/embed` stay outside Access.

The resulting D1 database ID is not secret and should be committed after the first production setup.

Generate the administrator hash without putting the PIN into the repository:

```sh
node -e "const c=require('crypto'); const pin=process.env.PIN; const secret=process.env.SESSION_SECRET; console.log(c.createHash('sha256').update(pin+':'+secret).digest('hex'))"
```

The admin session is an HttpOnly/Secure/SameSite=Strict cookie. Five failed PIN attempts from one IP lock authentication for 15 minutes.
The resulting D1 database ID and Access audience ID are not secrets and should be committed after the first production setup.

## R2 uploads

Expand Down
2 changes: 2 additions & 0 deletions website/snap/migrations/0002_drop_legacy_admin_auth.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS admin_sessions;
DROP TABLE IF EXISTS login_failures;
28 changes: 2 additions & 26 deletions website/snap/scripts/setup-production.sh
Original file line number Diff line number Diff line change
Expand Up @@ -62,36 +62,12 @@ echo "D1 database: $DB_ID"
say "Applying D1 migrations"
npx wrangler d1 migrations apply capos-snap-store --remote

say "Preparing administrator credentials"
read -rsp "Choose the administrator PIN: " ADMIN_PIN
echo
read -rsp "Confirm the administrator PIN: " ADMIN_PIN_CONFIRM
echo
if [[ -z "$ADMIN_PIN" || "$ADMIN_PIN" != "$ADMIN_PIN_CONFIRM" ]]; then
echo "PINs did not match or were empty." >&2
exit 1
fi

SESSION_SECRET="$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')"
ADMIN_PIN_HASH="$(PIN="$ADMIN_PIN" SECRET="$SESSION_SECRET" node -e 'console.log(require("crypto").createHash("sha256").update(process.env.PIN+":"+process.env.SECRET).digest("hex"))')"
unset ADMIN_PIN ADMIN_PIN_CONFIRM

SECRETS_FILE="$(mktemp)"
trap 'rm -f "$SECRETS_FILE"' EXIT
chmod 600 "$SECRETS_FILE"
SESSION_SECRET="$SESSION_SECRET" ADMIN_PIN_HASH="$ADMIN_PIN_HASH" node >"$SECRETS_FILE" <<'NODE'
process.stdout.write(JSON.stringify({
SESSION_SECRET: process.env.SESSION_SECRET,
ADMIN_PIN_HASH: process.env.ADMIN_PIN_HASH,
}));
NODE

say "Building the Snap Store"
npm ci --no-audit --no-fund
npm run build

say "Deploying capos-snap"
npx wrangler deploy --secrets-file "$SECRETS_FILE"
npx wrangler deploy

if [[ "${SKIP_VERIFY:-0}" != "1" ]]; then
say "Verifying public endpoints"
Expand Down Expand Up @@ -134,6 +110,6 @@ Production setup is complete.
Admin: https://snap.capos.top/admin
D1 ID: $DB_ID

The generated SESSION_SECRET and PIN hash were uploaded directly to Cloudflare and were not saved in the repository.
The admin UI and API are protected by Cloudflare Access; no application-level PIN secret is required.
The D1 database ID was written to website/snap/wrangler.toml; it is not a secret and should be committed.
EOF
21 changes: 8 additions & 13 deletions website/snap/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
LayoutGrid, LockKeyhole, LogOut, Menu, Package, Plus, Search, Server, Settings, ShieldCheck,
Sparkles, Star, Store, UploadCloud, Users, X
} from 'lucide-react';
import { abortPackageUpload, createUpstream, createVersion, finalizePackage, getAdminState, getAppDetails, getCatalog, getStorefront, login, logout, requestPackageUpload, saveUpstreams, searchApps, uploadPackagePart } from './api';
import { abortPackageUpload, createUpstream, createVersion, finalizePackage, getAdminState, getAppDetails, getCatalog, getStorefront, requestPackageUpload, saveUpstreams, searchApps, uploadPackagePart } from './api';
import type { AdminState, StoreApp, StorefrontData, Upstream } from './types';
import { useWebDesktopBridge } from './webdesktop';

Expand Down Expand Up @@ -276,20 +276,15 @@ function Storefront() {
</main><footer className="store-footer"><div className="store-container"><div><b>CapOS App Store</b><span>Packages from CapOS and configured upstreams.</span></div><div><a href="https://capos.top">capos.top</a><a href="https://repo.capos.top">Repository</a><a href="/admin">Admin</a></div></div></footer>{webdesktop.error && <div className="bridge-error" role="alert">{webdesktop.error}</div>}{selected && <AppDetail app={selected} loading={detailLoadingName === selected.name} onClose={closeDetail} action={actionFor(selected, true)} onAction={() => void performAction(selected, true)}/>}</Shell>;
}

function AdminLogin({ onSuccess }: { onSuccess: () => void }) {
const [pin, setPin] = useState(''); const [error, setError] = useState(''); const [busy,setBusy] = useState(false);
async function submit(e: React.FormEvent) { e.preventDefault(); setBusy(true); setError(''); try { await login(pin); onSuccess(); } catch (err) { setError(err instanceof Error ? err.message : 'Authentication failed'); } finally { setBusy(false); } }
return <div className="login-page"><div className="login-glow"/><a className="login-wordmark" href="/"><span className="mark"><Store size={18}/></span> CapOS <b>Store Admin</b></a><form className="login-card" onSubmit={submit}><div className="login-lock"><LockKeyhole/></div><h1>Store administration</h1><p>Enter the administrator PIN to manage packages, upstreams and releases.</p><label>Administrator PIN<input autoFocus type="password" inputMode="numeric" value={pin} onChange={e=>setPin(e.target.value)} placeholder="••••••••"/></label>{error && <div className="error-banner">{error}</div>}<button className="login-submit" disabled={busy || !pin}>{busy ? 'Unlocking…' : 'Unlock dashboard'} <ArrowRight size={17}/></button><div className="login-security"><ShieldCheck size={15}/> Protected by rate limiting and secure sessions</div></form></div>;
}

type AdminTab = 'overview'|'packages'|'upstreams'|'versions'|'apps'|'settings';

function AdminDashboard({ onLock }: { onLock: () => void }) {
const [state,setState] = useState<AdminState|null>(null); const [tab,setTab] = useState<AdminTab>('overview'); const [version,setVersion] = useState('rolling'); const [notice,setNotice] = useState('');
useEffect(() => { getAdminState(version).then(setState).catch(() => onLock()); }, [version]);
function AdminDashboard() {
const [state,setState] = useState<AdminState|null>(null); const [tab,setTab] = useState<AdminTab>('overview'); const [version,setVersion] = useState('rolling'); const [notice,setNotice] = useState(''); const [error,setError] = useState('');
useEffect(() => { setState(null); setError(''); getAdminState(version).then(setState).catch(err => setError(err instanceof Error ? err.message : 'Could not load store state.')); }, [version]);
if (error) return <div className="admin-loading"><ShieldCheck/>Cloudflare Access is active, but the store state could not be loaded: {error}</div>;
if (!state) return <div className="admin-loading"><div className="spinner"/>Loading store state…</div>;
const navigation: [AdminTab,string,React.ReactNode][] = [['overview','Overview',<CircleGauge/>],['packages','Local packages',<Package/>],['upstreams','Upstreams',<Cloud/>],['versions','Versions',<Boxes/>],['apps','App metadata',<LayoutGrid/>],['settings','Settings',<Settings/>]];
return <Shell admin><div className="admin-layout"><aside className="admin-sidebar"><a className="admin-brand" href="/"><span className="mark"><Store size={17}/></span><span>CapOS<b>Store</b></span></a><nav>{navigation.map(([id,label,icon])=><button className={tab===id?'active':''} key={id} onClick={()=>setTab(id)}>{icon}<span>{label}</span></button>)}</nav><div className="sidebar-bottom"><div className="system-status"><span className="status-dot"/>Store online<small>Cloudflare Worker</small></div><button onClick={async()=>{await logout();onLock();}}><LogOut/>Lock console</button></div></aside>
return <Shell admin><div className="admin-layout"><aside className="admin-sidebar"><a className="admin-brand" href="/"><span className="mark"><Store size={17}/></span><span>CapOS<b>Store</b></span></a><nav>{navigation.map(([id,label,icon])=><button className={tab===id?'active':''} key={id} onClick={()=>setTab(id)}>{icon}<span>{label}</span></button>)}</nav><div className="sidebar-bottom"><div className="system-status"><span className="status-dot"/>Store online<small>Cloudflare Access</small></div><button onClick={()=>window.location.assign('/cdn-cgi/access/logout')}><LogOut/>Sign out</button></div></aside>
<section className="admin-main"><header className="admin-topbar"><div><span className="admin-breadcrumb">Store /</span> <strong>{navigation.find(n=>n[0]===tab)?.[1]}</strong></div><div className="admin-top-actions"><label className="version-picker"><span>Repository</span><select value={version} onChange={e=>setVersion(e.target.value)}>{state.versions.map(v=><option key={v.name}>{v.name}</option>)}</select><ChevronDown/></label><a href="/" className="preview-link">View store <ExternalLink/></a></div></header><div className="admin-content">{notice && <div className="success-toast"><Check/> {notice}<button onClick={()=>setNotice('')}><X/></button></div>}{tab==='overview'&&<Overview state={state}/>} {tab==='packages'&&<Packages state={state} version={version} onNotice={setNotice}/>} {tab==='upstreams'&&<Upstreams state={state} version={version} setState={setState} onNotice={setNotice}/>} {tab==='versions'&&<Versions state={state} setState={setState} version={version} setVersion={setVersion} onNotice={setNotice}/>} {tab==='apps'&&<Metadata state={state}/>} {tab==='settings'&&<StoreSettings/>}</div></section></div></Shell>;
}

Expand All @@ -310,8 +305,8 @@ function Versions({state,setState,version,setVersion,onNotice}:{state:AdminState
async function add(){if(!name||!label)return;setBusy(true);setError('');try{const created=await createVersion({name,label,copyFrom:version});setState({...state,versions:[created,...state.versions],stats:{...state.stats,versions:state.stats.versions+1}});setVersion(created.name);setShow(false);setName('');setLabel('');onNotice(`${created.label} created and selected`);}catch(err){setError(err instanceof Error?err.message:'Could not create repository version');}finally{setBusy(false)}}
return <><PageTitle eyebrow="RELEASES" title="Repository versions" description="Maintain independent Snap views for rolling and stable CapOS releases." action={<button className="admin-primary" onClick={()=>setShow(true)}><Plus/>New version</button>}/><div className="version-grid">{state.versions.map(v=><div className={`version-card ${v.name===version?'selected':''}`} key={v.name}><div className="version-card-top"><span className="version-icon"><Boxes/></span><span className={v.frozen?'version-state frozen':'version-state'}>{v.frozen?'Frozen':v.name===version?'Selected':'Active'}</span></div><h2>{v.label}</h2><code>{v.name}</code><div className="version-meta"><span>{v.appCount} local apps</span><span>{v.frozen?'Read only':'Accepting changes'}</span></div><button onClick={()=>{setVersion(v.name);onNotice(`Now managing ${v.label}`)}}>{v.name===version?'Managing this version':'Manage version'} <ChevronRight/></button></div>)}</div>{show&&<div className="modal-backdrop"><div className="admin-modal small"><button className="modal-close" onClick={()=>setShow(false)}><X/></button><span className="modal-icon"><Boxes/></span><h2>Create repository version</h2><p>Create an independent Snap repository view. Upstream source order is copied from <b>{version}</b>; local packages start empty.</p><div className="form-stack"><label>Version name<input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder="v1.2"/></label><label>Display label<input value={label} onChange={e=>setLabel(e.target.value)} placeholder="CapOS 1.2"/></label></div>{error&&<div className="error-banner">{error}</div>}<button className="admin-primary modal-submit" disabled={busy||!name.trim()||!label.trim()} onClick={add}>{busy?'Creating…':'Create version'} <ArrowRight/></button></div></div>}</> }
function Metadata({state}:{state:AdminState}) { return <><PageTitle eyebrow="CATALOG" title="App metadata" description="Curate how local and upstream apps appear in CapOS and WebDesktop."/><div className="metadata-toolbar"><SearchBox value="" onChange={()=>{}} compact/><button><Sparkles/>Featured</button><button><AppWindow/>WebDesktop ready</button></div><section className="admin-panel table-panel"><table className="admin-table"><thead><tr><th>App</th><th>Integration</th><th>Publisher</th><th>Source</th><th>Visibility</th><th></th></tr></thead><tbody>{state.localPackages.map(app=><tr key={app.id}><td><div className="table-app"><AppIcon app={app} size="sm"/><span><b>{app.displayName}</b><small>{app.summary}</small></span></div></td><td>{app.webdesktop||'Unknown'}</td><td>{app.publisher}</td><td>{app.sourceName}</td><td><span className="visible-chip">Visible</span></td><td><button className="row-menu">Edit</button></td></tr>)}</tbody></table></section></> }
function StoreSettings(){return <><PageTitle eyebrow="CONFIGURATION" title="Store settings" description="Global behavior for snap.capos.top and package delivery."/><div className="settings-grid"><section className="admin-panel settings-section"><h2>General</h2><label>Store display name<input defaultValue="CapOS App Store"/></label><label>Default repository<select defaultValue="rolling"><option>rolling</option></select></label><label className="setting-toggle"><span><b>Proxy upstream downloads</b><small>Keep Canonical download URLs behind snap.capos.top.</small></span><span className="switch"><input type="checkbox" defaultChecked/><span/></span></label></section><section className="admin-panel settings-section"><h2>Security</h2><label className="setting-toggle"><span><b>Require admin PIN</b><small>Protect every mutation and package operation.</small></span><span className="switch"><input type="checkbox" defaultChecked disabled/><span/></span></label><label className="setting-toggle"><span><b>Audit administrative actions</b><small>Record upstream, package and release changes.</small></span><span className="switch"><input type="checkbox" defaultChecked/><span/></span></label><button className="secondary-danger">Rotate administrator PIN</button></section></div></>}
function StoreSettings(){return <><PageTitle eyebrow="CONFIGURATION" title="Store settings" description="Global behavior for snap.capos.top and package delivery."/><div className="settings-grid"><section className="admin-panel settings-section"><h2>General</h2><label>Store display name<input defaultValue="CapOS App Store"/></label><label>Default repository<select defaultValue="rolling"><option>rolling</option></select></label><label className="setting-toggle"><span><b>Proxy upstream downloads</b><small>Keep Canonical download URLs behind snap.capos.top.</small></span><span className="switch"><input type="checkbox" defaultChecked/><span/></span></label></section><section className="admin-panel settings-section"><h2>Security</h2><label className="setting-toggle"><span><b>Cloudflare Access</b><small>Email one-time-code authentication protects the admin UI and API.</small></span><span className="switch"><input type="checkbox" defaultChecked disabled/><span/></span></label><label className="setting-toggle"><span><b>Audit administrative actions</b><small>Record upstream, package and release changes.</small></span><span className="switch"><input type="checkbox" defaultChecked/><span/></span></label><button className="secondary-danger" onClick={()=>window.location.assign('/cdn-cgi/access/logout')}>Sign out of Access</button></section></div></>}

function AdminApp(){const [authed,setAuthed]=useState(import.meta.env.DEV&&sessionStorage.getItem('capos-admin-dev')==='1'); return authed?<AdminDashboard onLock={()=>setAuthed(false)}/>:<AdminLogin onSuccess={()=>setAuthed(true)}/>}
function AdminApp(){return <AdminDashboard/>}

export function App(){const path=window.location.pathname;return path.startsWith('/admin')?<AdminApp/>:<Storefront/>}
14 changes: 0 additions & 14 deletions website/snap/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,6 @@ export async function searchApps(query: string, version = 'rolling'): Promise<St
return result.apps;
}

export async function login(pin: string): Promise<void> {
if (import.meta.env.DEV) {
if (!pin.trim()) throw new Error('Enter an administrator PIN.');
sessionStorage.setItem('capos-admin-dev', '1');
return;
}
await request('/api/admin/auth', { method: 'POST', body: JSON.stringify({ pin }) });
}

export async function logout(): Promise<void> {
if (import.meta.env.DEV) sessionStorage.removeItem('capos-admin-dev');
else await request('/api/admin/logout', { method: 'POST', body: '{}' });
}

export async function getAdminState(version = 'rolling'): Promise<AdminState> {
if (import.meta.env.DEV) return mockAdmin;
return request<AdminState>(`/api/admin/state?version=${encodeURIComponent(version)}`);
Expand Down
Loading
Loading