# Cloud2Hide — AI Coder Manual

Version: v2026.10.20.2 — 2026-10-19

You are an AI assistant managing a Cloud2Hide account on behalf of a user.
Learn this manual thoroughly before acting.

Hard rules:
- Terminology is binding: Services (never "tunnel"), Regions (never "datacenter", "pool" or "vSwitch"), Dedicated IPs. The terminology table below wins over any other wording.
- This document changes over time. Check it for changes every time you start and periodically during long sessions — if the Version header has changed, re-learn the document before acting.
- Never guess: every rule and decision is in this manual. When in doubt, consult the manual or ask the user.
- Act either by guiding the user through the console (screens documented below) or via the API (contract in the last chapter). If you have an API token, use it as a Bearer token; its permission level is stated where the user provided it. The payment gateway is never accessible to you.

---
## Conventions

- Compact, terse, data-first. Everything machine-checkable where possible.
- Values in backticks are exact strings/enums from the code.
- `data-od-id` is the stable identifier for every region/heading/CTA/card (used by comment/highlight tooling). Repeated cards get unique ids.
- State that must persist across reloads: active screen in `localStorage` key `c2h-screen`.
- No `scrollIntoView`. No framework; plain DOM.

## Non-negotiable terminology (UI-facing)

| Show | Never show |
|---|---|
| Service | Tunnel |
| Region | Datacenter, vSwitch, IP Pool, Control Plane, Data Plane |
| Dedicated IP (resource) | IPv4/IPv6 allocation as separate concept |
| `Create Service` | `Create Tunnel` |
| Edge (secondary only, e.g. `EU-FI-02`) | provider credentials, private mgmt IP, cluster ids, topology |

## Product naming

- Production domain: `cloud2hide.com` (prototype strings use it verbatim).
- CLI binaries: `c2h` (client) and `c2h-tracker` (IP tracker, minimal standalone binary) — connect commands `c2h connect --token c2h_client_…`, `c2h-tracker connect --token c2h_track_…`.
- Token prefixes: `c2h_api_`, `c2h_client_`, `c2h_track_`.
- **Unified namespace**: every tunnel name is 3 random easy words under `cloud2hide.com` (e.g. `dog-red-running.cloud2hide.com`) for all protocols. A name hosts ≤1 HTTPS service + any number of TCP/UDP ports; names are Region-bound (spec 02).
- Install lines: `curl -fsSL https://get.cloud2hide.com | sh`, `brew install c2h`, `winget install Cloud2Hide.C2H`, `docker pull cloud2hide/client:1.0.0` (Docker distribution tentative).
- UI language: English only, permanently (no i18n).
- SMTP requests: website form → `support@cloud2hide.com` (address not yet created).
- AI coder manual: served at `https://cloud2hide.com/ai-coder.md` (auto-generated from user-facing specs, versioned — regenerate with `node spec/tools/generate-manual.mjs` after any user-facing spec change; spec 18).

## Design posture (one line)

Light workspace, dark sidebar, single blue accent; sans UI + mono for every technical value (hostnames, endpoints, ports, tokens, timestamps). Restrained, data-dense, developer-grade — no decorative flourishes.


---

# 01 — Design System (visual model)

## Palette — `:root` tokens (OKLch, bind verbatim)

### Light workspace (surface tokens)

| Token | Value | Use |
|---|---|---|
| `--bg` | `oklch(0.970 0.004 250)` | Page background |
| `--surface` | `oklch(1 0 0)` | Cards, inputs |
| `--surface-2` | `oklch(0.985 0.003 250)` | Subtle fills, table header, hover rows |
| `--fg` | `oklch(0.225 0.018 260)` | Body text |
| `--fg-strong` | `oklch(0.175 0.02 260)` | Headings, dark surfaces (secret box, toast bg) |
| `--muted` | `oklch(0.50 0.018 255)` | Secondary text |
| `--muted-2` | `oklch(0.64 0.014 250)` | Tertiary text, captions |
| `--border` | `oklch(0.905 0.006 250)` | Card/row borders |
| `--border-strong` | `oklch(0.85 0.008 250)` | Input/button borders |

### Accent (single accent — blue)

| Token | Value |
|---|---|
| `--accent` | `oklch(0.47 0.18 256)` |
| `--accent-hover` | `oklch(0.41 0.18 256)` |
| `--accent-active` | `oklch(0.37 0.18 256)` |
| `--accent-soft` | `oklch(0.945 0.035 255)` |
| `--accent-border` | `oklch(0.88 0.06 255)` |
| `--on-accent` | `oklch(0.99 0 0)` |

### Semantic status colors

| Token | Value | Meaning |
|---|---|---|
| `--ok` / `--ok-soft` | `oklch(0.53 0.14 152)` / `oklch(0.955 0.04 152)` | online/paid/success |
| `--warn` / `--warn-soft` | `oklch(0.58 0.13 78)` / `oklch(0.962 0.04 85)` | connecting/waiting/draining |
| `--danger` / `--danger-soft` | `oklch(0.54 0.19 25)` / `oklch(0.962 0.025 25)` | error/suspended/revoked |
| `--info-soft` | `oklch(0.955 0.03 255)` | info banners |

### Dark sidebar

| Token | Value |
|---|---|
| `--sidebar-bg` | `oklch(0.215 0.028 260)` |
| `--sidebar-2` | `oklch(0.25 0.03 260)` |
| `--sidebar-fg` | `oklch(0.94 0.01 250)` |
| `--sidebar-muted` | `oklch(0.64 0.02 255)` |
| `--sidebar-border` | `oklch(0.30 0.03 260)` |
| `--sidebar-active` | `oklch(0.315 0.045 256)` |
| `--sidebar-hover` | `oklch(0.27 0.035 258)` |

## Typography

- **Body/UI**: `-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif` (`--font-sans`). Base 14px, line-height 1.5.
- **Mono** (`--font-mono`): `ui-monospace, "SF Mono", "Cascadia Code", "Roboto Mono", Menlo, Consolas, monospace`. Used for **all technical values**: hostnames, endpoints, ports, IPs, tokens, timestamps, table headers, eyebrows, counts.
- Weights: 500–650 for headings/buttons; 650–680 for metric values. Letter-spacing `-.01em`–`-.02em` on headings/values.
- Brand: `Cloud2Hide` wordmark (15px/650) + mono sub `Access to your services` (10.5px, sentence case, letterspaced).

## Spacing / radius / shadow

- Radii: `--radius` 10px (controls), `--radius-sm` 7px, `--radius-lg` 14px (cards, modals).
- Shadows: `--shadow-sm` (cards), `--shadow` (raised), `--shadow-lg` (modal/toast/panel).
- Control heights: buttons 38px (`.btn-sm` 32px, `.btn-lg` 44px); inputs/selects 40px; badge 24px; nav item ~36px.

## Components

| Component | Key specs |
|---|---|
| **Button** `.btn` | 38px, radius 9px, weight 600. Variants: `-primary` (accent bg + on-accent text), `-secondary` (surface + border-strong), `-ghost` (transparent, muted), `-danger` (danger bg). One primary per action. |
| **Badge** `.badge` | 24px pill, 12px/600, dot `.bdot` (7px). Variants: `-online` green, `-warn` amber, `-danger` red, `-neutral` gray, `-info` blue. **Always dot + text, never color-only.** |
| **Card** `.card` | surface + border + radius-lg + shadow-sm. Header `.card-head` (title 14px/650 + sub 12.5px muted), body `.card-pad` (20px). |
| **Metric** `.metric` | label 12px muted → value 28px/680 → delta 12px (`up`=green, `down`=danger, `flat`=muted). |
| **Table** | wrapper `.table-wrap` (border+radius+shadow). `th` mono 11px uppercase muted, `td` 13.5px padding 14px. Row hover `--surface-2`. Clickable rows `.clickable` (cursor). |
| **Form** `.field` | `.label` 12.5px/600; `.hint` 12px muted; input/select 40px radius 9px; focus = accent border + 3px accent-soft ring. |
| **Choice card** `.check` | 2-col grid of selectable cards; `.checked` = accent border + accent-soft bg. Used for radios (protocol, policy, auth, plan, account type). |
| **Switch** `.switch` | 38×22 toggle; on = accent track + knob slide. |
| **Chip** `.chip` | mono pill for whitelist IPs/CIDRs; has ×-remove. |
| **Segmented** `.seg` | filter/range control; active = surface bg + shadow. |
| **Tabs** `.tab` | underline style; active = fg + accent underline. |
| **Modal** `.modal` | overlay; `.modal-card` max-width 520px (`wide` 620px), radius 14, head/body/foot. Close × top-right. |
| **Toast** `.toast` | bottom-right stack, fg-strong bg + near-white text, slide-in, auto-dismiss ~3.2s. |
| **Banner** `.banner` | info/warn/danger inline notice (icon + text), soft bg + colored border. |
| **Secret box** `.secret-box` | dark (`--fg-strong`) bg, mono light text, copy button. For tokens/commands/endpoints. |
| **Empty state** `.empty` | icon + title + desc + single primary action. |
| **KV list** `.kv` | 2-col key(200px muted)/value grid, hairline rows. |

## Interaction states (hard rules)

- **Hover**: never lighten text. Buttons/nav move background ±L or border. `.btn-primary:hover` → `--accent-hover`. Secondary → `--surface-2` + darker border. Nav → `--sidebar-hover`.
- **Focus-visible**: global `outline: 2px solid var(--accent); outline-offset: 2px` on every focusable element.
- **Contrast**: normal text ≥ 4.5:1, large text/icons ≥ 3:1. Status never relies on color alone (always text + dot).
- Disabled is the only state allowed to reduce contrast.

## Visual rules

- Single accent, **at most twice per screen** (default budget = eyebrow/active + primary CTA).
- Sans body + mono technical values — the one "flourish". No gradients on backgrounds, no emoji as icons, no decorative bars.
- One primary button per action per viewport; other entry points are secondary/ghost/text and use different copy.
- Mobile (< 920px): sidebar collapses to drawer (`.scrim` backdrop); 4-col → 2-col → 1-col; tables become cards/priority columns. No horizontal page scroll.


---

# 02 — Domain Model

All data is client-side JS objects in `cloud2hide-console.html`. Shapes below are authoritative for the prototype.

## Enums

### Service status
`online | connecting | waiting_for_client | error | disabled | suspended`
Badge: online→green, connecting→amber, waiting_for_client→amber, error→red, disabled→neutral, suspended→red.

### Client status
`connected | connecting | disconnected | revoked | error`
Badge: connected→green, connecting→amber, disconnected→amber, revoked→neutral, error→red.

### Region health
`healthy | draining | unavailable` → green / amber / neutral. Health and IPv6 capability are **separate** axes.

### Service protocol
`https | tcp | udp` — immutable after creation.

### Firewall policy
`public | my_ip | whitelist`

### Plans
`free | pro | premium`

### Account type
`personal | business`

## Entities

### Service
```js
{ id, name, protocol, hostname, port, region, client, target, status,
  today, month, firewall, auth, authUser, authPass?, whitelist, trackedAllowed, edge,
  preset?, members?, aliases? }
```
- `hostname`: 3-word tunnel name `<word>-<word>-<word>.cloud2hide.com` for **all** protocols (unified namespace — no separate TCP/UDP subdomain). Three easy words, randomly selected from a large word library in production (collision-retry); demo uses a small embedded list.
- `firewall`: `public|my_ip|whitelist`.
- `auth`: `null | 'basic'` (HTTPS only). `authUser` = basic-auth username; `authPass` = demo-only password, masked in the editor, never rendered as text.
- `whitelist`: `string[]` of IPs/CIDRs (only meaningful when `firewall==='whitelist'`). Entries validated as IPv4 or CIDR (`validIpOrCidr`).
- `trackedAllowed`: `string[]` of tracker ids (per-service selection; effective = union with account-wide trackers).
- `members`: for presets creating N services, `[{protocol, port}]`. Rendered grouped as one service. `protocolLabel` = join of members (e.g. `TCP + UDP`); `endpoint` = `host · TCP :25565 · UDP :25565`.
- `preset`: `'minecraft' | 'web-server' | null`.
- `edge`: `eu-fi-01` style, lowercase region code; new services derive it from region via `edgeForRegion(region)`.
- `aliases`: for HTTPS services, up to one custom domain (`ServiceAlias`); Pro plan or higher (spec 22 §8).

### Client
```js
{ id, name, status, region, last, services }
```

### ServiceAlias (custom domain)
```js
{ hostname, status, error }
```
- `hostname`: customer-owned FQDN, lowercase; at most one per HTTPS service.
- `status`: `pending_dns | issuing | active | error`; `error` carries user-facing copy only.
- Invariants: HTTPS-only; globally unique; never under the platform zone (`cloud2hide.com`); each alias gets its own certificate (never SAN-bundled, spec 22 §8).

### Dedicated IP
```js
{ id, name, region, v4, v6, client, mappings: [{ protocol, port, service }] }
```
`v4`/`v6` are strings or `null`. `region` id → which pool the address came from.

### IP Pool (internal, NOT user-facing)
```js
{ id, name, v4, v6, regions: [regionId...] }
```
- `v4`/`v6` = address prefixes (e.g., `203.0.113`, `2001:db8:1::`); `null` = family unavailable.
- A pool serves exactly one Region in production (`regions` has one entry). `poolForRegion(regionId)` resolves region → pool.

### Region
```js
{ id, name, code, health, v4, v6, edges }
```
`code` is UI-facing (`EU-FI`); `v4`/`v6` = capability booleans.

### Tracker (IP Tracker device)
```js
{ id, name, ip, last, accountWide }
```
`accountWide` bool = whitelisted across all services. **Premium-only feature.**

### API Token
```js
{ id, name, profile, spending, created, last, status, ai?, aiLevel?, spendCap? }
```
- `profile`: `Read Only | Service Management | Full Infrastructure Access | AI · Full | AI · Limited | AI · View only`.
- `spending`: `Not authorized | Purchases allowed | Up to $N/mo` (separate from profile).
- AI tokens (spec 18): `ai:true`, `aiLevel: 'full|limited|view'`, `spendCap` = monthly purchase budget (string, `null` when not set). Payment gateway never accessible to AI.

### Plan
```js
{ name, price, serviceLimit, traffic, trafficGB, logs, archive, overage, blurb }
```

### Preset (create-service templates)
| id | name | produces |
|---|---|---|
| `custom` | Custom Service | 1 service, user chooses protocol |
| `web-server` | Web Server | 1 HTTPS service (locked) |
| `minecraft` | Minecraft | 2 services (TCP 25565 + UDP 25565) grouped as one |

## Invariants

- One Dedicated IP → at most one Client. A DIP is assignable only to non-revoked clients (`Assign client`); `Move client` transfers to another client or unassigns.
- A Dedicated IP is **Region-bound**: allocated in one Region and never migrated, even where routing would allow it. Region failure leaves DIP endpoints down (alert in console, spec 14 §5).
- A Client is bound to one Region, chosen when the client is added; every Service of that client is in the same Region (`region` must match the client's Region).
- Service protocol immutable; hostname/port stable for service lifetime.
- **Unified namespace invariants**: a hostname hosts at most one HTTPS service plus any number of TCP/UDP ports; ports unique per Region + protocol (the edge routes L4 by port only, spec 14 §3); a name is Region-bound — all services on it share its Region, and moving region moves the whole group with unchanged endpoints (constrained to the client's Region, spec 22 §7).
- Whitelist only active under `firewall==='whitelist'`.
- Tax routing (spec 15 §2): EU `personal` → Paddle (MoR, EU VAT); EU `business` with a valid VAT ID → 0% reverse charge via Stripe; EU business without a valid VAT ID is not accepted; US/non-EU → no tax. Prices are USD net; display currency is informational.
- Trackers (device list, account-wide toggle, tracked-IP selection) require `premium`.
- Basic Auth only offered for `https` services.
- Account-wide tracker + per-service `trackedAllowed` both grant access; account-wide is locked/checked in the per-service editor.

## Demo data (reference values)

- Services: `home-assistant` (https, basic auth), `web-app` (https, fallback EU-DE), `ssh` (tcp, my_ip), `dev-api` (https, whitelist, error), `minecraft` (preset tcp+udp, disabled).
- Clients: `home-server` (connected), `office-nas` (disconnected), `laptop` (revoked).
- Regions: `eu-fi`, `eu-de`, `eu-fr`, `us-ny`, `us-ca`(draining), `ap-sg`(unavailable).
- IP pools: `pool-fi` (eu-fi), `pool-de` (eu-de), `pool-fr` (eu-fr), `pool-ny` (us-ny), `pool-ca` (us-ca), `pool-sg` (ap-sg).
- Trackers: `work-laptop` (accountWide), `phone`.
- Plans (USD net): Free $0 · 1 svc · 1 client · 1 GB · 1d logs; Pro $9.90 · 10 svc · 10 clients · 10 GB · 5d logs; Premium $24.90 · 25 svc · 25 clients · **30 GB** · 10d live + 30d archive.
- Usage: `usageGB = 7.2` (demo).


---

# 03 — App Shell & Overview

## Layout

```
┌ sidebar (244px, dark, fixed) │ topbar (60px, sticky, light)        │
│  brand                       │ [menu-btn] Title · desc  [bell]     │
│  nav (groups)                ├─────────────────────────────────────┤
│  ...                         │ main.content (max-width 1240px)     │
├ footer: Settings + account   │   one <section class="screen"> per  │
└──────────────────────────────┘   route, toggled by display         │
```

## Sidebar nav (order, `data-screen` ids)

1. **Workspace**: `overview`, `services`, `dedicated-ips`, `firewall`, `regions`, `clients`
2. **Insights**: `analytics`, `billing`
3. **Developer**: `api-tokens`, `downloads`
4. Pinned bottom: `settings`, then account menu (avatar + name + org).

- Nav item: 18px inline SVG icon + label; active = `--sidebar-active` bg + fg + blue icon; count badge on right (`services`, `clients` counts).
- Permission-aware: items only render if capability exists (prototype renders all).

## Topbar

- Left: hamburger (mobile only, opens drawer) + page title (16px/650) + description (13px muted).
- Right: **notifications** bell button (`id=notif-btn`, no dot — the panel is informational only). Clicking toggles the notifications panel.

## Notifications panel

- `.panel`, top-right under header. Items: title, description, mono timestamp.
- Informational history only — **no read/unread state**. Email is the primary channel.
- Sample: Region failover, Traffic threshold (90%), Invoice generated.

## Route table (`SCREENS` → title/desc)

| `data-screen` | Title | Desc |
|---|---|---|
| `overview` | Overview | Operational status at a glance |
| `services` | Services | Expose local services to the public internet |
| `service-detail` | Service | Configuration and connection status |
| `clients` | Clients | Machines running the Cloud2Hide client |
| `dedicated-ips` | Dedicated IPs | Reserved public addresses for your services |
| `firewall` | Firewall | Per-service inbound access policies |
| `regions` | Regions | Edge locations and their capabilities |
| `analytics` | Analytics | Traffic usage across your services |
| `logs` | Logs | Connection activity across your services |
| `billing` | Billing | Plan, usage and payment |
| `api-tokens` | API & Tokens | Programmatic access to Cloud2Hide |
| `downloads` | Downloads | Install the client on your machines |
| `settings` | Settings | Organization, notifications and security |

Each nav item carries a 24×24 stroke SVG icon (`--sidebar-muted`, accent when active) — overview, services, dedicated-ips, firewall, regions, clients, analytics, logs, billing, api-tokens, downloads, settings.

Routing: `showScreen(id)` hides all `.screen`, shows target, sets active nav + topbar title/desc, persists `localStorage['c2h-screen']`, and triggers screen-specific render (`firewall`→`buildFirewallSelector`, `analytics`→`drawChart`).

## Overview screen

### Usage widgets (`overview-widgets`)

Two graphic widgets between the metric cards and Quick actions (data-od-ids `ov-slot-N`, `ov-tank`):
- **Services in use**: a slot grid — one rounded tile per allowed slot (`plans[currentPlan].serviceLimit`; 1/10/25 slots, responsive column count), accent-filled tiles = services in use (all services, incl. disabled — matches backend enforcement), dashed-outline tiles = free slots. Sub: "N of M in use".
- **Traffic this month**: a horizontal tank bar — gradient accent fill at `usage_gb / traffic_gb`, a 90% marker line, danger-tinted fill at/over the limit, mono "x.x GB / y GB" labels, and a hint (limit reached → hard-stop or overage copy per plan).

Four metric cards (`data-od-id`): `metric-services`, `metric-clients`, `metric-traffic`, `metric-plan`.

| Metric | Value (computed) |
|---|---|
| Services | total + "N online" |
| Clients | total + "N connected" |
| Traffic today | static demo `412 MB`, +18% |
| Subscription | `plans[currentPlan].name` + price/mo, sub `"{usageGB} GB of {trafficGB} GB used"` (recomputed in `updateOverview()`) |

Two half-width cards:
- **Quick actions** (`overview-quick-actions`): primary `Create Service` + secondary `Add Client`.
- **Needs attention** (`overview-attention`): `2 open` badge; warn banner for `Dev API` error (link → service detail) + info banner for traffic usage — `%` computed from `usageGB / plans[currentPlan].trafficGB` in `updateOverview()`.

**Recent activity** (`overview-activity`) table: Time (mono), Actor, Action, Resource, Result badge. Sample rows incl. a `System` region-failover row.

`updateOverview()` recomputes services/clients/plan metrics after any mutation.


---

# 04 — Services

## Services list (`services`)

- Segmented filter (`services-filter`): `all | online | attention` (attention = error/waiting_for_client).
- Primary `Create Service` button top-right.
- Table columns: **Name** (title + `→ target` sub; presets show `Preset · N services`), **Protocol** (`protocolLabel`: `TCP + UDP` for grouped, + `Preset` badge), **Public endpoint** (mono + copy), **Region** (mono), **Status** (badge), **Client**, **⋮** (delete).
- Row click → `gotoService(id)`.
- Empty state: icon + "No services yet" + `Create Service`.

## Service detail (`service-detail`)

Header: back (`Services`) + name + status badge.

- **Public endpoint** card (`service-detail-endpoint`): accent-soft secret box with copyable `endpoint(s)`; hint "hostname stays reserved while disabled/offline". Grouped presets append `#sd-members` listing each member (`TCP :25565` etc.) with copy.
- **Actions** card: `Disable/Enable` toggle (`sd-toggle-btn`), `Edit`, `Move region`, `Delete Service` (danger ghost).
- **Connection** kv: Protocol (`protocolLabel`), Region (+ `Fallback` badge when `region==='EU-DE'` and not disabled), Client, Edge (uppercase mono), Local target (mono).
- **Traffic** kv: Today, This month, Security = `firewallPolicies[firewall].name + (auth==='basic' ? ' · Basic Auth' : '')`.
- **Custom domain** card (`service-detail-domain`, HTTPS only, hidden otherwise): Pro plan or higher (Free shows a locked hint via `canDomain()`; the API rejects with `alias.plan_required`).
  - Empty state: domain input + `Add custom domain` + hint "Add the domain first — the exact DNS record to create is shown next."
  - With an alias: alias row (`hostname` mono + status badge + `Remove`), a status note, and the **DNS record table** — `CNAME` `<hostname>` → `<generated hostname>` and one `A` `@` → `<edge ip>` row per active edge, each with a copy button; apex hint: "Use the CNAME for a subdomain (some DNS providers want only the subdomain part as the name). For a root/apex domain use the A records, or an ALIAS/ANAME record pointing at `<generated hostname>`."
  - Status notes: `pending_dns` "Waiting for DNS — we check automatically every minute."; `issuing` "Certificate is being issued — usually under a minute."; `active` "Active — your domain and the generated hostname both work."; `error` shows the API's `error` text + "Fix the DNS records below, then remove and re-add the domain."
- **Services list**: rows with a custom domain show a small domain badge in the Name cell (`custom_domain`).

### Custom domain (`submitAddAlias` / `removeServiceAlias`)
- Add: lowercased hostname input → `aliases.push({hostname, status:'pending_dns'})`; toast `Custom domain added — waiting for DNS`.
- Remove: confirm dialog → alias removed; the generated hostname is unaffected.

### Enable/Disable
`toggleService()`: disabled↔online, re-render, toast `Service disabled.` / `Service enabled · online`.

### Delete
`openDeleteService(id)`: destructive confirm dialog (permanent; releases generated endpoint; DIP not deleted). `submitDeleteService()` removes, navigates to list.

### Edit
`openEditService()`: Name + Client (reassign to another non-revoked client) + Local target editable; Protocol shown as disabled/immutable. `submitEditService()` PATCHes name/target/client_id (config generation bumped so the edge reconfigures immediately).

### Move region (name-level)
`openMoveServiceRegion()`: modal with region select (draining/unavailable disabled) + info banner listing every service on the name ("This name hosts N service(s) … They move together — **endpoints stay the same**; traffic reroutes to the new Region."). `submitMoveServiceRegion()` sets region + edge for the whole group, re-renders, toast `Moved N service(s) to <region>`.
- Constraint: a Client is bound to one Region (spec 02), so a move is only accepted when the target equals the client's Region; otherwise the API rejects with a `region` field error. Restoring user-initiated moves requires a client migration flow (spec 22 §7).

## Create Service (`openCreateService`)

Modal fields, top-to-bottom:
1. **Template** (3 choice cards, `cs-preset`): `custom` (default) / `web-server` / `minecraft`. `renderCreateFields()` updates the protocol area live:
   - `minecraft`: info banner "Creates TCP + UDP on :25565, grouped as one service"; local port prefilled `25565`.
   - `web-server`: protocol locked `HTTPS · automatic TLS`.
   - `custom`: protocol radios `https|tcp|udp`.
2. **Name** (required).
3. **Public hostname** (TCP/UDP only, `renderCsHostnameField()`): select `New name (random)` (default) or any existing name (listed with its hosted services). Choosing an existing name **locks the Region select** to that name's Region (`updateCsHostname()`); HTTPS always gets a fresh random name.
4. **Region** select: generated from `regions` via `createServiceRegionOptions()` — all healthy regions with IPv4 (`EU-FI`, `EU-DE`, `EU-FR`, `US-NY`); `US-CA` (draining) and `AP-SG` (unavailable) shown disabled. Locked to the selected Client's Region (`applyServiceRegionLock()`), and to the name's Region when an existing name is selected.
5. **Client** select (`home-server`, `office-nas (offline)`).
6. **Local address** select (`127.0.0.1`, `192.168.1.25 · eth0`) + **Local port** (required).

`submitCreateService()`:
- Validate name + port (toast error if missing).
- Resolve `proto`, `members`, `hostname`:
  - minecraft → `proto=tcp`, `members=[{tcp,25565},{udp,25565}]`, `port=25565`.
  - web-server → `proto=https`.
  - custom → chosen proto; tcp/udp get a random port.
  - https → `hostname=randomHostname()`; tcp/udp → chosen existing name (region adopted from it) or `randomHostname()`.
  - Port collision check per name + protocol (`portTakenOnHostname`) → error toast `Port N is already in use on this name`.
- `status` = `waiting_for_client` if client is offline else `online`.
- `firewall='public'`, `auth=null`, `edge=edgeForRegion(region)`.
- Unshift to `services`, re-render, toast (`Minecraft preset created · 2 services` / `Service created · Waiting for client` / `Service is online`), navigate to detail.

## Helpers

- `protocolLabel(s)`: `TCP + UDP` for members else `proto.toUpperCase()`.
- `endpoint(s)`: `https://<hostname>`; `tcp/udp` → `<hostname>:<port>`; members → `<host> · TCP :25565 · UDP :25565`.
- `randomHostname()`: three distinct random words from the demo wordlist + `.cloud2hide.com`, collision-retry against allocated names. Production: large word library.
- `servicesOnHostname(host)`, `regionOfHostname(host)`, `portTakenOnHostname(host, proto, port)`.

## Traffic overuse per Service

- Edit modal: "Traffic overuse" select (`es-overuse`): **Inherit (account default)** / **Allowed — overage billed** / **Disallowed — hard stop**; saved via `PATCH /v1/services/{id}` `overuse`.
- Detail "Connection" list shows the effective setting ("Inherit", "Allowed — overage billed", "Disallowed — hard stop"). Only meaningful on billed plans; hard-stop plans are always blocked at the limit.


---

# 05 — Clients & Dedicated IPs

## Clients (`clients`)

- Primary `Add Client` button.
- Table: **Name**, **Status** (badge), **Region**, **Last connection**, **Services**, **Revoke** (hidden for revoked).
- `openCreateClient()`: name input + **Region** select (required; healthy + IPv4 regions) + one-time connect command (secret box, copyable `c2h connect --token c2h_client_9f2k8x7a3b`) + warn banner "This token will only be shown once." → `submitCreateClient()` unshifts a `connected` client. The Client is bound to the chosen Region; its Services must be in that Region.
- `openRevokeClient(id)`: destructive dialog ("disconnected, cannot reconnect with current token; Services remain configured"). `submitRevokeClient()` → status `revoked`, region `—`.
- Empty state (`clients-empty`) when no clients: "No clients yet" + `Add Client`.

## Dedicated IPs (`dedicated-ips`)

- Header hint: "One Dedicated IP serves at most one Client at a time." + primary `Allocate IP`.
- Cards (`dip-card-<id>`) per DIP: title + id, family badge (`IPv4`/`IPv6`/`Dual-stack`), rows: IPv4, IPv6, Region (mono code), Assigned client; port mappings list if any.
- Footer actions: `Move client`/`Assign client`, `Release` (danger).
- Empty state (`dips-empty`) when no DIPs: "No Dedicated IPs" + `Allocate IP`.

### Assign client (`openAssignDip`) — for unassigned DIPs
- Client select listing non-revoked clients (offline ones suffixed `(offline)`); hint "One Dedicated IP serves at most one Client at a time."
- No eligible clients → info banner with `Add Client` (opens the client modal).
- `submitAssignDip()`: requires a selection (error toast otherwise), sets `d.client`, re-renders, toast `Dedicated IP assigned to <name>`.

### Move client (`openMoveDip`) — for assigned DIPs
- Shows current assignment; select offers `Unassign` + all other non-revoked clients.
- `submitMoveDip()`: sets `d.client` (or `null` for Unassign), re-renders, toast `Dedicated IP moved to <name>` / `Dedicated IP unassigned`.

### Port mappings (`openAddDipMapping` / `removeDipMapping`)
- Every DIP card has a "Port mappings" row with `+ Add mapping`.
- Modal: Protocol (`https|tcp|udp`) + Public port (required, validated 1–65535) + Service select (all org services).
- Duplicate protocol+port on the same DIP → error toast `A mapping for <PROTO> :<port> already exists.`; `submitAddDipMapping()` pushes `{protocol, port, service}` and re-renders.
- Rows render `PROTO :port` + service name with ×-remove (`removeDipMapping(id, index)`, no confirm — low-stakes).

### Allocate (`openAllocateIp`)
1. **Region** select (required; drives which pool the address comes from). Options exclude unavailable/no-v4 regions.
2. Address families: IPv4 (default checked) + IPv6 checkbox. `updateIpFamilies()` disables IPv6 ("Unavailable in this region") when the region's pool has no v6.
3. Info banner: "Dedicated IPs are bound to one Region and cannot be moved."
- `submitAllocateIp()`: draws address from `poolForRegion(regionId)` prefix + random octet, unshifts DIP.

### IP Pool ↔ Region model (internal, not exposed)

```
ipPools:
  pool-fi        v4 203.0.113  v6 2001:db8:1::  regions [eu-fi]
  pool-de        v4 203.0.117  v6 2001:db8:5::  regions [eu-de]
  pool-fr        v4 203.0.114  v6 2001:db8:2::  regions [eu-fr]
  pool-ny        v4 203.0.115  v6 null         regions [us-ny]
  pool-ca        v4 203.0.116  v6 2001:db8:4::  regions [us-ca]
  pool-sg        v4 null       v6 null         regions [ap-sg]
```
- A pool serves exactly one Region; the address family is fixed for the address's lifetime.
- A Dedicated IP is never migrated between Regions (spec 14 §5). Dedicated IPs are offered only in Regions whose provider routes extra IPs; other Regions show no DIP option.
- Region IPv6 capability derives from pool's `v6`.

## Regions (`regions`)

Table: **Region** (name + code), **Health** (badge), **IPv4** (badge), **IPv6** (badge), **Edges** (mono). Health and IPv6 shown as **separate** columns. Footer hint reinforces that a healthy region doesn't guarantee IPv6.


---

# 06 — Firewall, Basic Auth & Tracked Devices

Firewall page is per-service: a Service selector (`fw-service`) + `firewall-body` with three cards. `renderFirewall()` rebuilds on any change. `buildFirewallSelector()` resets `currentFirewallService` to the first service when the selected one no longer exists; with zero services the body renders an empty state ("No services yet" → Go to Services).

## Card 1 — Inbound policy (`firewall-policy`)

Three policies (choice cards):
| key | Label | Desc |
|---|---|---|
| `public` | Public / Everyone | Anyone on the internet can reach this service. |
| `my_ip` | My IP only | Only your current public IP address. |
| `whitelist` | Whitelisted IPs | Static IPs, ranges and tracked devices you choose. |

- `setFirewall(policy)` sets `service.firewall` and re-renders.
- **Whitelist editor** appears when `firewall==='whitelist'`:
  1. **IP addresses & ranges**: text input (`wl-ip`) + `Add` → `addWhitelistIp()` validates via `validIpOrCidr()` (IPv4 or CIDR; invalid → error toast "Enter a valid IP address or CIDR range.") and pushes to `service.whitelist`, rendered as `.chip` with ×-remove (`removeWhitelistIp(index)`).
  2. **Tracked IPs** (Premium-gated, below): "Select all tracked devices" checkbox + one row per tracker (`toggleTrackedForService(id)`), account-wide trackers shown checked+disabled with `account-wide` tag.

## Card 2 — Require login / Basic Auth (`firewall-auth`)

- HTTPS services: `Public` vs `Basic Auth` choice cards → `setServiceAuth(auth)`. `basic` reveals Username (`setAuthUser`) + Password fields. Password is demo-only (`authPass`), rendered masked, saved via `setAuthPass` (toast "Password updated"); it is never rendered as text anywhere.
- Non-HTTPS: info banner "Basic Auth is only available for HTTPS services."

## Card 3 — Tracked devices (`firewall-trackers`) — **Premium only**

- Not premium → locked card: info banner "Tracked devices … available on the **Premium** plan." + `View plans` (→ billing). Header shows a `Premium` badge instead of `+ Add tracker`.
- Premium → device list (name + mono `ip · last`) with "Whitelist account-wide" switch (`toggleAccountWide(id)`), and `+ Add tracker`.

## IP Tracker

- `openAddTracker()`: device name + one-time `c2h-tracker connect --token c2h_track_8d2k1m` + info banner ("announces current IP so whitelist stays valid on a dynamic connection"). `submitAddTracker()` unshifts `{accountWide:false}` tracker. Tracker ships as its own minimal binary `c2h-tracker` (spec 13 §7).
- Downloads page has a matching `downloads-tracker` card with a **Premium** badge.

## Effective access model

A service's whitelist grants access from: static IPs/CIDRs in `service.whitelist` **∪** trackers in `service.trackedAllowed` **∪** trackers with `accountWide=true`. Default is per-service; account-wide is the opt-in shortcut.

## Premium gating

`isPremium()` = `currentPlan === 'premium'`. Gates: tracked-IP section (Card 1), tracked-devices card (Card 3), `+ Add tracker`. Upsell buttons call `showScreen('billing')`. Gating re-evaluates on navigation since `renderFirewall` runs in `buildFirewallSelector()`.


---

# 07 — Analytics & Billing

## Analytics (`analytics`)

### Traffic chart (`analytics-chart`)
- Header: Service select (`analytics-service`: All services + each) + Metric select (`total|in|out`). Service options are rebuilt from `services` on every navigation (`refreshAnalyticsServices()`, selection preserved when still present).
- Range segmented (`analytics-range`): `1h | 6h | 24h | 7d | 30d` (default 30d).
- `drawChart()` renders an inline SVG: gridlines, filled area (`url(#g)` accent gradient) + accent line + data dots, plus a **dashed muted plan-limit line** at `plans[currentPlan].trafficGB` (labeled, y-scale includes it). Data = `chartData[]` slice per range.
- Legend: Actual (accent) / Plan limit (muted). Footer: "Total: N GB".
- `renderTopServices()`: top 3 by `month` (MB-normalized via `monthMB()`), horizontal accent bars scaled to the max value, capped at 100%. Empty state when no services.

### Plan usage (`analytics-usage`)
`renderPlanUsage()`: `usageGB (7.2)` vs `plans[currentPlan].trafficGB`, progress bar (accent, width = pct), and a hint:
- hard-stop plan (or overuse disallowed on the account) at/over limit → "Traffic limit reached — new traffic is blocked."
- billed plan with overuse allowed ≥100% → "Traffic limit reached — overage billed at $0.10/GB."
- ≥90% → "Approaching your traffic limit."
- else → "well within your allowance."

### Overuse toggles (implemented)

- Billed plans (Pro/Premium) can opt out of overage billing: account default `PATCH /v1/org/overuse` (`settings-overuse` card, Organization tab) and per-Service override in the Edit modal. Overuse disallowed = hard stop at the limit (edge blocks with `traffic_blocked`, same as Free). Free plan is always hard stop.

### Connection logs (`logs`)

Screen for browsing connection activity (spec 16 §3): metadata only — never payloads, never credentials.

- Filter row (`data-od-id`): `logs-service` (All services + each, rebuilt from `services`), `logs-result` (All results · ok · blocked · auth_failed · traffic_blocked), `logs-range` (`1h | 24h | 7d | 30d | Max (plan retention)`, default 24h), `logs-pagesize` (100 · 250 · 500 per page), `logs-format` (JSON Lines · CSV) + Download button (`logs-download`).
- Table (`logs-body`, newest first): Time, Service, Source IP, Host/SNI, Result badge (ok accent / blocked warn / auth_failed danger), Duration, In, Out. Each row `log-row-N`.
- Paging (`logs-footer`): `logs-prev` / `logs-next` + `logs-pageinfo` "Page N of M" (M from `total` / page size); rows are replaced per page, the DOM never holds more than one page. Cursors for visited pages are kept in JS (`logsView.cursors`) so Prev works; changing any filter resets the page stack (page size is preserved).
- Download (`downloadLogs()`): `GET /v1/org/logs/export` with the current filters, format JSON Lines (default, for AI/tooling) or CSV; streams as a file attachment, capped at 100 000 rows.

## Billing (`billing`)

Layout, top to bottom: the **tax note** banner (`billing-tax-note`), a two-column grid of **Current plan** + **Invoices**, then one consolidated **Payment** card (`billing-payment`). The UI never names a payment provider — only "card", "bitcoin" and "credits" (spec 15).

### Tax note (`billing-tax-note`)
- Pinned banner at the top of the screen: "Personal use in the EU is charged VAT at your home-country rate. EU business accounts pay 0% VAT (reverse charge). Personal use outside the EU, and businesses outside the EU, have no tax added." (spec 15 §2).

### Current plan (`billing-plan`) — dynamic (`renderBilling`)

Granted accounts (hidden `premium_flag`, spec 11): plan card shows **Premium** + a `granted` badge, Change plan is hidden and replaced with "This Premium plan is granted to your account — no charges apply."; the DIP allocate modal shows "granted — no charge" instead of the $9.90 note.
- Badge + price (`billing-plan-price`), features list (`billing-plan-features`): traffic, serviceLimit, logs, overage. `Change plan` button.

### Invoices & receipts (`billing-invoices`)
- One table for both **payable invoices** and **paid receipts**, from `GET /billing/invoices` (spec 12). Columns: Number (mono), Date, Amount, Status, action. Amounts are USD net.
- Status chips: `Paid` (accent-green), `Due` (warn), `Autopay` (neutral), `Expired` (neutral).
- Actions:
  - **Paid** → `Download` fetches our receipt PDF (`GET /billing/invoices/{id}/pdf`); a `Provider` link opens the Stripe/Paddle document when available.
  - **Due** → `Pay` opens the provider checkout (BTC/credit link, or Stripe's hosted invoice).
  - **Autopay** → non-clickable "Autopay <date>" (the next Stripe renewal, derived from the subscription period).
- Receipts are also emailed on payment (link + PDF attachment, spec 16); the console is the download surface.

### Payment (`billing-payment`)
One card with three rows (label + value + action), separated by 16px:
- **Billing profile** — summary `Personal · Finland` / `Business · Finland · VAT SE…` / `Not set`; `Edit` opens `openPayProfile()`.
- **Payment method** — card segment only (hidden for EU personal); stored card (`Visa •••• 4242`) or "No card on file"; `Add` / `Update` → `POST /billing/payment-method` (hosted card setup URL). A `billing.payment_method_required` error on plan change opens this flow instead of a dead-end toast.
- **Credits** — current balance (`credits-balance`) + history (`credits-history`, one `credit-row-N` per entry) + `Top up` (`cta-topup`).

### Payment profile (`openPayProfile`)
- Modal with the tax note context + **Customer type** (Personal/Business), a **Continent → Country** two-level selector, **ZIP / Postal code**, **State** (shown only when country = US) and **EU VAT ID** (business only); `Save` → `PATCH /org` (`account_type`, `country`, `zip`, `state`, business `vat_id`). Address fields are bookkeeping only.
- The same fields live on Settings → Organization (`orgFields()`); both write the same org values.

### Change plan (`openChangePlan`)
- Stepped modal. If the profile is incomplete (customer type + country), the **Billing profile** fields are shown inline as step 1; otherwise the modal opens straight on the plan choice.
- Plan choices come from `GET /billing/plans` (spec 12); the current plan is pre-checked and tagged `Current`. Each card shows the plan name, price (`Free` / `$X/mo`), the blurb, and a feature list so plans are comparable at a glance:
  - `N services · N clients`
  - `X GB traffic / month`
  - log retention (`1-day connection logs` / `5-day connection logs` / `10-day live connection logs + 30-day archive`)
  - overage (`Hard stop at the traffic limit` or `$0.10/GB beyond the limit`)
  - higher-tier-only features: `Custom domains` (`custom_domains`, Pro+), `Tracked devices (dynamic IP whitelisting)` (`trackers`, Premium).
  - Feature bullets are composed from the plan row fields; nothing is hardcoded per plan key.
- Modal sub: "Changes apply immediately — prorated" (spec 15).
- **Payment method** follows the segment (`methodOptionsHtml`): EU personal → `Pay with card` only; card segment → `Pay with card` · `Pay with bitcoin` · `Pay with credits` (balance shown; disabled unless the balance covers the price). Choosing a different plan re-renders the method list (`onPlanPick()`).
- `confirmChangePlan()` / `submitChangePlan()` sends `{ plan, method }` (spec 12); credits settle locally, card uses the stored card (charged immediately for an existing subscription) or starts card setup when none is on file, bitcoin starts a checkout, and free schedules cancellation at period end. Toast "Plan changed to X" or "Your plan will switch to Free at the end of the billing period."
- **No card on file (card segment)**: Continue does **not** error — it opens the Stripe card setup (redirect) with the chosen plan in the return URL (`?payment=success&plan=<key>`). On return the console polls `GET /billing/payment-method` and **resumes the plan change automatically** once the card is saved; if the card isn't detected in ~12s it asks the user to retry. A hint in the modal says the change completes automatically after the card is added.
- Bitcoin plan payment covers **one period only** — renewal is manual (top up credits or pay again). Auto-renewal requires a card subscription or prepaid credits.
- Downgrade hint: existing services kept, new ones limited.

### Top up (`openTopup`)
- Stepped modal, same structure as Change plan: inline profile step when incomplete, then fixed denominations $10/$25/$50/$100 and the segment's **Payment method** list (`Pay with card`, plus `Pay with bitcoin` for the card segment). Credits appear when the payment settles (spec 15 §7).
- **Checkout routing in the console**: an EU-personal (Paddle) response opens the **Paddle.js overlay** (`Paddle.Initialize` + `Paddle.Checkout.open({transactionId})`, config from `GET /paddle/config`); card/bitcoin responses open their hosted URLs in a new tab. Success is only toasted once a checkout has actually started (or the change applied locally).

## Plans

| key | Name | Price | Services | Clients | Traffic | Logs | Overage |
|---|---|---|---|---|---|---|---|
| `free` | Free | $0 | 1 | 1 | 1 GB/mo | 1 day | hard stop |
| `pro` | Pro | $9.90 | 10 | 10 | 10 GB/mo | 5 days | $0.10/GB |
| `premium` | Premium | $24.90 | 25 | 25 | **30 GB/mo** | 10 days live + 30 days archive | $0.10/GB |

`currentPlan = 'pro'` default.

## Plan effects across app

`plans[currentPlan]` drives: Overview subscription metric, Billing current-plan card, Analytics plan-usage limit, and (via `isPremium()`) Firewall tracker gating.


---

# 08 — API & Tokens, Downloads, Settings (+ VAT)

## API & Tokens (`api-tokens`)

- Primary `Create Token` button.
- Table: **Name**, **Profile**, **Spending**, **Created**, **Last used**, **Status**, **Revoke**.
- `openCreateToken()`: Name + Access profile (`Read Only|Service Management|Full Infrastructure Access`) + Spending authorization (`Not authorized|Purchases allowed`). Hint: "Full Infrastructure Access does not imply spending permission."
- `submitCreateToken()`: creates token → **one-time secret reveal** modal (`c2h_api_…`, copyable, "won't be shown again").
- `openDeleteToken(id)`: revokes (removes) + toast.
- Empty state (`tokens-empty`) when no tokens: "No API tokens" + `Create Token`.

## AI coder access (`ai-coder-access`)

- Card on the API & Tokens screen: "Give an AI assistant scoped access to this account — the payment gateway is never accessible" + `Create AI token`.
- `openCreateAiToken()`: Name (required) + 3 permission level choice cards (`full` / `limited` / `view only`, spec 18 §3) + **Monthly spending cap** input ($, shown only for `full`, `renderAiCap()`).
- `submitCreateAiToken()`: validates name + cap format (`^\d+(\.\d{1,2})?$`); unshifts `{ai:true, aiLevel, spendCap}` token with `profile: 'AI · Full|Limited|View only'` and `spending: 'Up to $N/mo' | 'Not authorized'`.
- Then the **one-time reveal** modal shows the full copy-paste text (`aiCoderSnippet(secret, levelLabel, cap)` — manual URL + version, freshness rule, embedded token, permission line, "payment gateway never accessible"), copyable as one block. Never shown again; rows are revoke-only, no edits (spec 18).

## Downloads (`downloads`)

Cards: **Linux**, **macOS**, **Windows**, **Docker**, **IP Tracker** (Premium badge). Each: icon, platform + arch subtitle, `Download` button, mono install line (`curl -fsSL https://get.cloud2hide.com | sh`, `brew install c2h`, `winget install Cloud2Hide.C2H`, `docker pull cloud2hide/client:1.0.0`, `c2h-tracker connect …`). Footer hint: `c2h connect --token c2h_client_…`.

Delivery model: native packages / executables are primary (`curl | sh`, `brew`, `winget`); **Docker is tentative** — the card is kept in the prototype, no commitment to image distribution, decision deferred.

## Settings (`settings`)

Tabs: `Organization | Notifications | Security | Data` (default Organization). Panes via `data-pane`.

### Notifications (`data-pane=notif`)
- Notification email + two checkboxes (Security & billing, Infrastructure events) + Save.

### Security (`data-pane=security`)
- Change password + Enable 2FA (secondary buttons).

### Data (`data-pane=data`)
- **Export data** (`cta-export-data`): downloads `cloud2hide-export.json` (prototype builds it client-side from demo state; production: `POST /org/export` → JSON archive via emailed link). Contains services, clients, DIPs, tokens, trackers, plan + usage, org fields.
- **Wipe data** (`cta-wipe-data`, danger): destructive confirm ("permanently deletes Services, Clients, Dedicated IPs, traffic data, trackers and settings — your account remains"). `submitWipeOrgData()` clears all state, re-renders everything (incl. empty states + `buildFirewallSelector`), navigates to overview, toast `All data wiped`. Production: re-auth required (spec 11).
- **Delete account** (`cta-delete-account`, danger): destructive confirm ("deletes your account and all its data, including login"). `submitDeleteAccount()` wipes state + clears `c2h-screen` storage + toast `Account deleted`. Production: re-auth required, `DELETE /account`.

### Organization (`data-pane=org`)

**Display currency** select (`org-currency`): `USD | EUR | GBP | CNY | AUD` (default USD). Informational — the charge is always in USD; the console notes the local amount is converted.

**Account type** radio (`acct-type`): `personal` (default) | `business`. `setAccountType(v)` → `renderOrgFields()`.

- **Personal**: Full name + Email + **Continent → Country** (home country) + ZIP / Postal code. EU personal accounts are billed by Paddle, which applies the applicable EU VAT.
- **Business** (optional invoice address):
  - Business name (`biz-name`).
  - EU VAT ID (`biz-vat`) + hint "We'll validate your VAT ID when you save." Required for EU businesses.
  - **Continent → Country** (country of registration, `biz-country`).
  - Street address, ZIP / Postal code, City — used on invoices when set.
  - `#vat-treatment` banner: default "VAT 0% — Reverse charge. Billing is done without VAT; VAT is payable by your company." for a validated EU VAT ID.
- Country selection is two-level (`biz-continent` → `biz-country`): choosing a continent lists its countries. The stored value is the ISO-2 country code (`organizations.country`); it drives payment routing + tax (spec 15 §2).
- Account ID (immutable) shown for both. `Save changes` → `saveOrg()`.

## Tax logic (silent, save-time only)

`saveOrg()` for business:
1. Read company/country/vatId.
2. If `vatId` present: validate `vatValid(v)` (regex `^[A-Za-z]{2}[0-9A-Za-z+*]{8,12}$`) **and** `simulateVies(v).valid` (server-side VIES in prod; simulated here).
   - Fail → toast error **"VAT ID couldn't be validated, please check it is correct."** and abort. No VIES mention in UI.
3. Valid EU VAT ID → set `#vat-treatment` to **"VAT 0% — Reverse charge. Billing is done without VAT; VAT is payable by your company."**
4. EU business without a valid VAT ID is **not accepted** — instruct the client to validate their VAT ID or order as a consumer (spec 15 §2).
5. Success toast "Organization saved."

**Never** mention "VIES" in the UI. Format check is client-side only; authoritative validation happens on save (backend/VIES in prod). Tax routing (Paddle for EU consumers, Stripe otherwise) is per spec 15 §2.

## Traffic overuse (Organization tab)

- `settings-overuse` card: billed plans get an "Allow overuse" checkbox (account default, on by default; off = hard stop at the limit). Free/hard-stop plans show an informational card instead. Saved via `PATCH /v1/org/overuse`. Per-Service override lives in the Service Edit modal (spec 04).


---

# 09 — Interaction Rules (cross-cutting)

## Modals & dialogs

- Single `#modal` root; `openModal(html)` injects `.modal-card` (close × top-right), `closeModal()` hides.
- Destructive actions use unambiguous labels (`Delete Service`, `Release IP`, `Revoke Client`, `Revoke`), never "Continue". Explicit confirmation required.
- Modals focus-trap (Tab cycles within the modal, `Escape` closes); first focusable autofocused.

## Toasts

`toast(msg, type)` → `#toasts` bottom-right; type `success|error`; auto-dismiss ~3.2s. Used for completed actions and non-critical errors. Never the only surface for critical failures.

## Copy

`copyText(btn, selector)` uses `navigator.clipboard` (fallback `execCommand`); button swaps to checkmark + `copied` class for ~1.5s. Applies to hostnames, endpoints, ports, tokens, commands. Never echo secrets in toasts.

## Validation & state

- Required fields: toast error on empty (Name/Local port, VAT on save).
- Whitelist entries validated as IPv4 or CIDR (`validIpOrCidr`) — invalid → error toast.
- Dependent fields revalidate on parent change (e.g. region → pool → IPv6 availability).
- Submit buttons reflect progress (`Creating…`, `I've connected`). Idempotency/optimism not needed in prototype, but server-confirmed status is the model (e.g. enable → poll, never claim online from success alone).

## Gating (plan-driven)

- `isPremium()` gates all IP-Tracker surface (tracked-IP selection, tracked-devices card, add tracker) with a "View plans" upsell → `showScreen('billing')`.
- Capability/plan state comes from `plans[currentPlan]`, never hardcoded per screen.

## Terminology & restraint (hard rules)

- Never render: `Tunnel`, `Datacenter`, `vSwitch`, `IP Pool`, `Control Plane`, `Data Plane`; use `Service`, `Region`.
- Never expose internal: provider credentials, DB ids, raw error codes (`ERR_EDGE_17`), security thresholds.
- One accent ≤ twice/screen. No emoji as icons. No color-only status.
- Data never fabricated: demo values are clearly labeled/internally consistent; forecasts/limits derive from `plans`/`usageGB`.

## Re-render triggers (single source of truth)

| Change | Re-renders |
|---|---|
| create/delete service | `renderServices`, `renderTopServices`, `updateOverview` |
| enable/disable/edit service | `renderServices`, `renderServiceDetail`, `updateOverview` |
| move service region (name group) | `renderServices`, `renderServiceDetail`, `updateOverview` |
| add/revoke client | `renderClients`, `updateOverview` |
| allocate/release/move DIP | `renderDips` |
| assign/move client on DIP | `renderDips` |
| add/remove DIP mapping | `renderDips` |
| create/revoke AI token | `renderTokens` |
| firewall/auth/tracker change | `renderFirewall` |
| change plan | `renderBilling`, `renderPlanUsage`, `updateOverview` |
| account-type/VAT change | `renderOrgFields` |
| wipe data / delete account | all render fns, `updateOverview`, `buildFirewallSelector`, navigate to `overview` |
| navigate to `analytics` | `refreshAnalyticsServices`, `drawChart` |

`updateOverview()` also recomputes the subscription sub (`{usageGB} GB of {trafficGB} GB used`), the attention traffic `%`, and hides the Dev-API error banner when no such errored service exists.

## Persistence

- Active screen persisted in `localStorage['c2h-screen']`, restored on load.
- Everything else is in-memory demo state.


---

# 12 — API Contract (authoritative)

Status: **FINAL by AI discretion** — no user review required. Public REST surface for the console and third parties. Internal control↔edge gRPC stays out of scope (spec 10 §6).

## 1. Basics

- Base URL: `https://api.cloud2hide.com/v1`. All requests/responses JSON (UTF-8). Dates ISO-8601 UTC.
- Auth: console = session cookie + `X-CSRF-Token` on mutating requests; third parties = `Authorization: Bearer c2h_api_…`.
- Token access profiles (spec 08): `Read Only`, `Service Management`, `Full Infrastructure Access`; spending authorization (`Not authorized` / `Purchases allowed`) is independent — `Full Infrastructure Access` does **not** imply spending.
- All resources org-scoped (spec 11 §2). UI is **English only, permanently** (no i18n).

## 2. Conventions

- Pagination: `?page=&per_page=` (default 50, max 200) → `{ "data": [...], "page": 1, "per_page": 50, "total": N }`.
- List filters: `?status=`, `?region=`, `?client=` where applicable.
- Idempotency: creation endpoints accept optional `Idempotency-Key` header; `409 conflict` on duplicate.
- Rate limits: tokens 300 req/min/org; sessions 600 req/min; `POST /services` 10/min; unauthenticated endpoints (register/login/verify) 120 req/min/IP. Exceeded → `429` + `Retry-After`. Implemented with in-memory token buckets per control-plane instance in v1.
- Never echo secrets in responses; token plaintext returned **only** on creation (spec 09).

## 3. Error model

Every error: HTTP status + body `{ "error": { "code": "…", "message": "user-safe text", "fields": {"name": "reason"} } }`.

| Code | Status | Meaning |
|---|---|---|
| `auth.invalid_credentials` | 401 | bad email/password |
| `auth.2fa_required` | 401 | valid creds, TOTP pending |
| `auth.forbidden` | 403 | role/token profile insufficient |
| `auth.spending_forbidden` | 403 | action needs `Purchases allowed` |
| `org.vat_invalid` | 422 | VAT ID failed format/VIES (same toast as spec 08) |
| `service.limit_reached` | 409 | plan serviceLimit exceeded |
| `service.protocol_immutable` | 409 | protocol/port edit attempt |
| `service.invalid_target` | 422 | target not on client interfaces |
| `dip.unavailable_family` | 409 | IPv6 not available in region pool |
| `dip.region_immutable` | 409 | Dedicated IPs are Region-bound and cannot be moved |
| `dip.client_taken` | 409 | client already has this DIP (1 DIP ↔ ≤1 client) |
| `alias.plan_required` | 403 | custom domains need the Pro plan or higher |
| `alias.https_only` | 422 | custom domains are only available for HTTPS services |
| `alias.reserved` | 409 | hostname is under the platform zone |
| `alias.taken` | 409 | hostname already used by a Service or another alias |
| `alias.limit_reached` | 409 | one custom domain per Service |
| `dip.mapping_conflict` | 409 | duplicate protocol+port on the same DIP |
| `token.spend_cap_exceeded` | 402 | AI purchase would exceed the token's monthly cap |
| `tracker.premium_required` | 403 | IP Tracker features on non-premium plan |
| `plan.downgrade_conflict` | 409 | downgrade below service/traffic usage (services kept, see spec 07) |
| `service.limit_reached` | 409 | creating a Service would exceed the plan's service limit |
| `client.limit_reached` | 409 | creating a Client would exceed the plan's client limit |
| `billing.payment_method_required` | 402 | paid plan change without a stored payment method |
| `billing.payment_failed` | 402 | card declined or requires authentication; local plan unchanged |
| `billing.provider_error` | 502 | payment provider rejected the change; local plan unchanged |
| `billing.provider_mismatch` | 409 | Paddle checkout requested for a Stripe-segment account (spec 15 §2) |
| `billing.insufficient_credits` | 402 | credit-funded purchase exceeds the account credit balance (spec 15 §7) |
| `billing.profile_required` | 422 | customer type and/or country not set — complete the payment profile first (spec 15 §2) |
| `billing.vat_required` | 422 | EU business without a validated VAT ID — validate it or order as a consumer |
| `billing.btc_unavailable` | 503 | Bitcoin payments temporarily unavailable (BTCPay node syncing) |
| `billing.overdue` | 402 | account suspended, payment required |
| `org.reauth_required` | 403 | sensitive action (wipe data, delete account) needs re-authentication |
| `rate_limited` | 429 | see §2 |
| `validation_failed` | 422 | generic field errors in `fields` |
| `not_found` | 404 | resource or id |
| `internal` | 500 | never leaks raw errors (spec 09) |

## 4. Endpoints by resource

### Auth & org
| Method | Path | Profile | Notes |
|---|---|---|---|
| POST | `/auth/register` | — | email → verify link (24h) |
| POST | `/auth/send-registration-link` | — | `{email}` → queues a registration-link email. Rate limits: recipient ≤3/24h, IP ≤10/h, 60s per-IP cooldown → `rate_limited` 429. Subject/body fixed server-side |
| POST | `/auth/verify-email` | — | `{token}` (24h, single-use) → marks the user's email verified |
| POST | `/auth/resend-verification` | session | queues a new verification email (rate-limited per recipient/IP) |
| GET | `/firewall/my-ip` | Read Only+ | returns the caller's public IP and re-registers it as the org's `my_ip` entry; same-origin only (`Origin`/`Sec-Fetch-Site`), and the IP comes from the trusted proxy chain (`C2H_TRUSTED_PROXIES`) or the socket |
| GET | `/org/notifications` | Read Only+ | `{email, security_billing, infrastructure}` |
| PATCH | `/org/overuse` | Full+ | `{allowed: bool}` — account default for traffic overuse on billed plans (off = hard stop at the limit); bumps config generation |
| GET | `/org/logs` | Read Only+ | connection log entries, newest first. Filters: `service_id`, `result` (`ok|blocked|auth_failed`), `range` (`1h|24h|7d|30d`), `limit` ≤500 (default 100), cursor `before`/`before_id`. → `{entries[], total, has_more, next_before, next_before_id}` |
| GET | `/org/logs/export` | Read Only+ | same filters + `format=csv|json` (JSON Lines default); file attachment, capped at 100 000 rows. `archive=1` exports the 30-day premium archive instead of live logs (src IPs truncated to /24 IPv4, /48 IPv6) |
| PATCH | `/org/notifications` | Full+ | update prefs; outbox sender skips email categories the org disabled |
| POST | `/auth/verify-email` | — | token |
| POST | `/auth/login` | — | sets cookie; with 2FA enabled returns 401 `auth.2fa_required` + one-time `challenge` (5 min) |
| POST | `/auth/logout` | session | |
| POST | `/auth/2fa/setup` | session | → `{secret, otpauth_url}` (secret stored, not yet enabled) |
| POST | `/auth/2fa/confirm` | session | `{code}` → enables TOTP, returns 10 one-time backup codes (shown once) |
| POST | `/auth/2fa/disable` | session | `{password, code}` — re-auth required |
| POST | `/auth/login/2fa` | — | `{challenge, code}` — second login step; backup codes accepted and consumed |
| POST | `/auth/password` | session | requires current password |
| POST | `/auth/reauth` | session | password/TOTP for sensitive ops |
| GET | `/org` | Read Only+ | org, account type, tax fields, display currency, account id |
| PATCH | `/org` | Full+ | account type, company, country, VAT ID (validation per spec 08, VIES server-side), zip, state, address, city, display currency |
| GET | `/org/usage` | Read Only+ | `usageGB`, plan limit, period |
| POST | `/org/export` | Full+ | JSON archive of all org data; response `202` + emailed download link |
| DELETE | `/org/data` | Full+ (re-auth) | wipe all data; account remains |
| DELETE | `/account` | owner (re-auth) | delete account + all data + login |

### Services
| Method | Path | Profile | Notes |
|---|---|---|---|
| GET | `/services` | Read Only+ | list, filters §2; rows include `custom_domain` when the service has an alias |
| POST | `/services` | Service Mgmt+ | template `custom|web-server|minecraft`, name, region, client, address, port, `hostname` (`null` = new random 3-word; existing name = attach, Region adopted from the name); `region` must match the client's Region; returns endpoint + status |
| GET | `/services/{id}` | Read Only+ | incl. members, traffic, security, `aliases[{hostname,status,error}]`, and `edge_ips` (HTTPS only — the Region's active edge IPs, for apex A/AAAA records) |
| PATCH | `/services/{id}` | Service Mgmt+ | name, local target, `client_id` (reassign to another non-revoked client **in the same Region**), region (name-level move; rejected while the target differs from the client's Region) — protocol immutable |
| POST | `/services/{id}/aliases` | Service Mgmt+ | `{hostname}` — HTTPS + Pro or higher, one per Service; the alias stays `pending_dns` until the hostname resolves to the platform, then a per-domain cert is issued via HTTP-01 (`issuing` → `active`/`error`) |
| DELETE | `/services/{id}/aliases/{alias}` | Service Mgmt+ | removes the alias and deletes its certificate |
| DELETE | `/services/{id}` | Service Mgmt+ | releases endpoint, keeps DIP |
| POST | `/services/{id}/enable` · `/disable` | Service Mgmt+ | status transitions |

### Clients
`GET /clients` (Read Only+), `POST /clients` `{ name, region }` (Region required, healthy + IPv4; the Client and its Services stay in that Region) → `{ name, connectCommand, token }` (token shown once), `DELETE /clients/{id}` = revoke (Service Mgmt+).

### Enrollment (client / tracker)
| Method | Path | Notes |
|---|---|---|
| POST | `/enroll` | Bearer enroll token (`c2h_client_…` / `c2h_track_…`). `client` body `{wg_pubkey, version, os, arch}` → `{client_id, region, tunnel_ip, edges[{id, host, port, wg_pubkey}], interval}`; `tracker` body `{ip}` → `{tracker_id, announce_interval}` |
| POST | `/trackers/announce` | Bearer tracker token (long-lived credential) `{ip}` → `{announce_interval}` — called every 60s |
| GET | `/clients/{id}/services` | Bearer client token (long-lived credential) → service list `{id, protocol, public_port, target, status}` + `edges[{id, host, port, wg_pubkey}]` (the Region's current edges; the client refreshes its WireGuard peers when this set changes) + `interval` — the client daemon polls this for its forwarders |

### Dedicated IPs
| Method | Path | Notes |
|---|---|---|
| GET | `/dedicated-ips` | cards incl. mappings |
| POST | `/dedicated-ips` | region, families `{v4,v6}`, `use_credits?` ($9.90 from credit balance); address from region pool |
| PATCH | `/dedicated-ips/{id}` | `client` (assign/move/unassign) only; `region` → `dip.region_immutable` (DIPs are Region-bound, spec 14 §5) |
| POST | `/dedicated-ips/{id}/mappings` | `{protocol, port, serviceId}`; duplicate → `dip.mapping_conflict` |
| DELETE | `/dedicated-ips/{id}/mappings/{mappingId}` | remove mapping |
| DELETE | `/dedicated-ips/{id}` | release; mappings removed |

### Firewall & trackers (per service)
| Method | Path | Notes |
|---|---|---|
| PATCH | `/services/{id}/firewall` | `{ policy }` `public|my_ip|whitelist` |
| GET | `/services/{id}/firewall` | incl. whitelist + trackedAllowed |
| PUT | `/services/{id}/firewall/whitelist` | replace list (validated IPv4/CIDR) |
| PATCH | `/services/{id}/auth` | `{ mode: null|basic, username, password }` (HTTPS only) |
| GET/POST | `/trackers` | Premium only |
| PATCH | `/trackers/{id}` | `{ accountWide }` |
| GET | `/firewall/my-ip` | caller's public IP (for `my_ip` hint) |

### Tokens & billing & analytics
| Method | Path | Notes |
|---|---|---|
| GET/POST | `/tokens` | POST → `{ name, profile, spending }` **or** `{ name, ai: true, aiLevel, spendCap }` → secret shown once (`c2h_api_…`) |
| DELETE | `/tokens/{id}` | revoke |
| GET | `/billing/plan` | current plan + features; includes `period_end` and `cancel_at_period_end` when a subscription is active |
| GET | `/billing/plans` | all plans with features (`service_limit`, `client_limit`, `traffic_gb`, `logs_days`, `archive`, `overage_mode`, `overage_per_gb`, `blurb`, `custom_domains`, `trackers`) — powers the Change plan picker (spec 07) |
| POST | `/billing/plan` | `{ plan, method? }` where `method` is `card` \| `bitcoin` \| `credits` (defaults to `credits` when `use_credits: true`, else `card`; ignored for `free`); Paddle accounts always use `card`. `credits` charges the balance and activates on a manual (`prepaid`) provider; `bitcoin` returns a one-period BTCPay invoice (manual renewal); `card` requires a stored payment method (`billing.payment_method_required`). `free` schedules cancellation at period end and returns the current plan with `cancel_at_period_end: true` and `period_end`. Effects per spec 07 |
| GET | `/billing/invoices` | all invoices/receipts: `id`, `number`, `status` (`open`\|`paid`\|`upcoming`\|`expired`), `method`, `period_*`, `amount_net`, `amount_gross`, `vat_*`, `due_at`, `paid_at`, `line_items`, and `download_url` (paid) / `provider_url`. Includes a synthetic `upcoming` row for the next Stripe renewal unless cancellation is scheduled |
| GET | `/billing/invoices/{id}/pdf` | our receipt/invoice PDF (`application/pdf`, attachment); org-scoped |
| GET/POST | `/billing/payment-method` | masked PAN only on GET (Stripe segment); POST takes an optional `{ plan }` and appends `plan=<key>` to the Stripe setup success URL so the console can resume a plan change after the card is saved (spec 07) |
| POST | `/billing/paddle/checkout` | `{ plan }`; EU-consumer accounts only → `{ provider: "paddle", checkout_url, transaction_id }`; plan activates on the Paddle webhook |
| GET | `/paddle/config` | public; `{ enabled, client_token, environment }` for the Paddle.js checkout overlay (client token is public) |
| POST | `/billing/paddle-webhook` | Paddle Billing events, `Paddle-Signature` verified (spec 15 §2) |
| POST | `/billing/credits/checkout` | `{ amount_usd }` ($10/$25/$50/$100); routes to Paddle (EU consumer) or BTCPay → `{ provider, checkout_url \| checkout_link }` |
| POST | `/billing/paddle/credits/checkout` | `{ amount_usd }`; EU-consumer credit top-up → `{ provider: "paddle", checkout_url }` |
| POST | `/billing/credits/stripe/checkout` | `{ amount_usd }` ($10/$25/$50/$100); Stripe segment → `{ provider: "stripe", checkout_url }` (one-time card payment) |
| POST | `/billing/btcpay/checkout` | `{ amount_usd }` (fixed denominations $10/$25/$50/$100); Stripe segment only → `{ provider: "btcpay", invoice_id, checkout_link }` |
| POST | `/billing/btcpay-webhook` | BTCPay Greenfield events, `BTCPay-Sig` verified; `InvoiceSettled` credits the account (spec 15 §7) |
| GET | `/billing/credits` | `{ balance_usd, entries[] }` — platform-wide prepaid credit ledger |
| GET | `/analytics/traffic` | `?service=&metric=total|in|out&range=1h|6h|24h|7d|30d` → series + plan limit |
| GET | `/analytics/top-services` | top 3 by month |
| GET | `/analytics/usage` | usage vs limit + hint tier |

### Meta
| Method | Path | Notes |
|---|---|---|
| GET | `/regions` | health, v4/v6 capability (separate axes) |
| GET | `/events` | audit/activity feed (spec 11 §6) |
| GET | `/downloads` | platforms, latest versions, install lines |
| GET | `/health` | unauthenticated liveness |

## 5. AI token enforcement (spec 18)

| Endpoint group | `view` | `limited` | `full` |
|---|---|---|---|
| All `GET` | ✓ | ✓ | ✓ |
| Services, Firewall, Clients, Trackers (CRUD) | — | ✓ | ✓ |
| Dedicated IP allocation / plan changes (purchases) | — | — | ✓ (within `spendCap`) |
| Dedicated IP mappings (add/remove) | — | — | ✓ (follows DIP scope) |
| Billing, invoices, tokens, org mutation | — | — | — |
| Payment gateway (`billing/payment-method`) | **never** | **never** | **never** |

- `spendCap` = cumulative purchase cost via that token per calendar month; exceeded → `token.spend_cap_exceeded` (402).
- Purchases = `POST /dedicated-ips` ($9.90/mo net, spec 15), `POST /billing/plan`, future billed resources.

## 6. Webhooks (future)

Reserved for v2: `service.status_changed`, `billing.invoice_generated`, `billing.payment_failed`. Not built in v1; event IDs already sequential for ordering.


---

