# ISP360 Captive Portal Specification

**Version:** 1.0  
**Date:** 2026-06-13  
**Prepared by:** Polakis Technologies  

Companion: [DATABASE_DESIGN.md](DATABASE_DESIGN.md) | [RADIUS_SPEC.md](RADIUS_SPEC.md) | [PAYMENTS_SPEC.md](PAYMENTS_SPEC.md) | [RBAC_SPEC.md](RBAC_SPEC.md) | [UI_SHELL_PATTERN.md](UI_SHELL_PATTERN.md)

---

## 1. Overview

ISP360 provides a **visual captive portal builder** with theme engine, page composer, and MikroTik Hotspot integration. ISPs design branded login experiences for WiFi users without writing code — while power users can inject custom HTML/CSS/JS.

The captive portal stack supports:

- **Portal builder** — drag-and-drop page composer (`captive_portal_edit.php`)
- **Theme engine** — colors, fonts, logos, custom CSS (`captive_portal_themes`)
- **Starter templates** — hotel, school, ISP, public WiFi, event (`/templates/`)
- **Auth methods** — voucher, payment (STK), username/password, free trial, MAC, social (configurable)
- **MikroTik binding** — per-hotspot portal URL and walled garden sync
- **RADIUS integration** — voucher/MAC/user auth via FreeRADIUS (see [RADIUS_SPEC.md](RADIUS_SPEC.md))
- **Surveys & ads** — pre-login surveys, splash advertisements

### Core Classes

| Class | File | Responsibility |
|-------|------|----------------|
| `CaptivePortalEngine` | `includes/CaptivePortalEngine.php` | Render pages, resolve tenant, inject theme variables |
| `CaptiveAuthManager` | `includes/CaptiveAuthManager.php` | Voucher/payment/free auth, session lifecycle |
| `CaptivePortalPublisher` | `includes/CaptivePortalPublisher.php` | Publish pages, sync MikroTik HTML, cache bust |
| `PortalThemeEngine` | `includes/PortalThemeEngine.php` | CSS variable compilation, asset URLs |

### Design Principles

| Principle | Implementation |
|-----------|----------------|
| Tenant-branded | Every render scoped to `tenant_id` from domain or hotspot |
| Published vs draft | `captive_portal_pages.is_published` — only published pages served live |
| No admin shell on public portal | Minimal HTML layout; mobile-first responsive |
| MikroTik parameters preserved | `mac`, `ip`, `link-login`, `link-orig`, `chap-id`, `chap-challenge` passed through forms |
| RADIUS is auth authority | Portal triggers login; MikroTik → FreeRADIUS validates |
| Separate from customer portal | `captive_portal_sessions` ≠ `portal_sessions` (subscriber portal) |

### Captive vs Customer Portal

| Aspect | Captive Portal | Customer Portal (`portal_*`) |
|--------|----------------|------------------------------|
| User | Anonymous WiFi guest | Registered subscriber |
| Session table | `captive_portal_sessions` | `portal_sessions` |
| Auth | Voucher, STK, trial, MAC | Username/password |
| Domain | `tenant_domains.domain_type = captive` | `domain_type = portal` |
| Layout | `layouts/captive.php` | `layouts/portal_*.php` |

---

## 2. Architecture

```mermaid
flowchart TB
    subgraph guest [WiFi Guest]
        Browser["Mobile Browser"]
    end

    subgraph mikrotik [MikroTik Hotspot]
        HS["Hotspot Server"]
        WG["Walled Garden"]
    end

    subgraph public [Public Entry]
        CaptivePhp["captive.php"]
        ApiCaptive["api/v1/captive/*.php"]
    end

    subgraph ajax [AJAX — No Staff Auth]
        VoucherLogin["ajax/captive_voucher_login.php"]
        Auth["ajax/captive_authenticate.php"]
        Packages["ajax/captive_package_list.php"]
        StkPush["ajax/portal_stk_push.php"]
    end

    subgraph admin [Admin Builder UI]
        PortalList["captive_portals.php"]
        PortalEdit["captive_portal_edit.php"]
        Themes["captive_portal_themes.php"]
        Preview["captive_portal_preview.php"]
        HotspotPortal["hotspot_portals.php"]
    end

    subgraph core [Core Classes]
        CPE["CaptivePortalEngine.php"]
        CAM["CaptiveAuthManager.php"]
        CPP["CaptivePortalPublisher.php"]
        PTE["PortalThemeEngine.php"]
        RM["RadiusManager.php"]
        PM["PaymentManager.php"]
    end

    subgraph data [Database]
        Pages["captive_portal_pages"]
        ThemesTbl["captive_portal_themes"]
        Templates["captive_portal_templates"]
        CPSessions["captive_portal_sessions"]
        HotspotPortals["hotspot_portals"]
        Vouchers["vouchers"]
        Payments["payments"]
    end

    subgraph radius [FreeRADIUS]
        FR["radiusd"]
    end

    Browser -->|HTTP blocked| HS
    HS -->|Redirect| CaptivePhp
    CaptivePhp --> CPE
    CPE --> Pages
    CPE --> ThemesTbl
    Browser --> VoucherLogin
    Browser --> Auth
    VoucherLogin --> CAM
    CAM --> RM
    CAM --> CPSessions
    Auth --> HS
    HS --> FR
    PortalEdit --> CPP
    CPP --> HotspotPortals
    Themes --> PTE
```

---

## 3. Database Tables

### 3.1 Portal Builder (ISP360-Hosted)

| Table | Role |
|-------|------|
| `captive_portal_themes` | Brand tokens: colors, fonts, logo, custom CSS |
| `captive_portal_templates` | Reusable layout shells (`layout_type`, `config_json`) |
| `captive_portal_pages` | Published pages with slug, HTML content, type |
| `captive_portal_assets` | Images, CSS, JS, fonts uploaded per theme/page |
| `captive_portal_surveys` | Pre-login questionnaire (`questions_json`) |
| `captive_portal_survey_responses` | Submitted answers linked to session |

### 3.2 Runtime Sessions

| Table | Role |
|-------|------|
| `captive_portal_sessions` | Guest session (MAC, auth method, expiry, bytes) |
| `captive_portal_login_log` | Auth attempt audit (success/failed/blocked) |

### 3.3 Hotspot Binding (MikroTik)

| Table | Role |
|-------|------|
| `hotspot_portals` | Links hotspot → external portal URL |
| `hotspot_portal_templates` | Legacy/alternate HTML snippets per portal (login/status/logout) |
| `hotspot_login_methods` | Enabled auth methods per hotspot (`method_type`, `config_json`) |
| `hotspot_walled_garden` | Allowed destinations before auth (payment gateways, portal CDN) |
| `hotspot_advertisements` | Splash ads on portal pages |

### 3.4 Entity Relationships

```
tenants
  └── captive_portal_themes (1:N)
        └── captive_portal_templates (1:N)
              └── captive_portal_pages (1:N)
                    ├── captive_portal_assets
                    └── captive_portal_surveys
  └── hotspots (1:N)
        ├── hotspot_portals → captive_portal_templates (via config)
        ├── hotspot_login_methods
        ├── hotspot_walled_garden
        └── hotspot_advertisements
captive_portal_sessions → vouchers | payments | packages | customers
```

### 3.5 Page Types

| `page_type` | Purpose | Default slug |
|-------------|---------|--------------|
| `login` | Main auth screen | `login` |
| `register` | Guest registration (optional) | `register` |
| `payment` | Package purchase + STK | `buy` |
| `voucher` | Voucher code entry | `voucher` |
| `info` | Terms, help, about | `info` |
| `survey` | Pre-auth survey gate | `survey` |
| `redirect` | External URL redirect | `redirect` |

### 3.6 Layout Types (Templates)

| `layout_type` | Blocks |
|---------------|--------|
| `login` | Logo, auth tabs, footer |
| `welcome` | Splash + CTA |
| `payment` | Package cards + pay button |
| `voucher` | Single code input |
| `survey` | Dynamic questions from JSON |
| `custom` | Free-form HTML |

---

## 4. Class: `PortalThemeEngine`

**File:** `includes/PortalThemeEngine.php`

Compiles tenant theme into renderable CSS and variable map.

### 4.1 Theme Resolution

```php
class PortalThemeEngine
{
    /**
     * Load theme for tenant. Falls back to default theme, then platform defaults.
     */
    public function resolveTheme(int $tenant_id, ?int $theme_id = null): array;

    /**
     * Compile CSS variables block for <style> injection.
     */
    public function compileCssVariables(array $theme): string;

    /**
     * Merge theme custom_css after variable block.
     */
    public function compileStylesheet(array $theme): string;

    /**
     * Resolve asset URL — local upload or CDN.
     */
    public function assetUrl(int $tenant_id, string $file_path): string;
}
```

### 4.2 Default CSS Variables

```css
:root {
    --portal-primary:    {{primary_color}};
    --portal-secondary:  {{secondary_color}};
    --portal-accent:     {{accent_color}};
    --portal-bg:         {{background_color}};
    --portal-text:       {{text_color}};
    --portal-font:       {{font_family}}, system-ui, sans-serif;
    --portal-radius:     12px;
    --portal-shadow:     0 4px 24px rgba(0,0,0,0.12);
}
```

### 4.3 Shared Assets

Static foundation in `/templates/shared/`:

| File | Purpose |
|------|---------|
| `shared/css/portal.css` | Base mobile layout, form styles, package cards |
| `shared/js/portal.js` | MikroTik param preservation, AJAX helpers, STK polling |

Theme `custom_css` loads **after** shared CSS for override specificity.

---

## 5. Class: `CaptivePortalEngine`

**File:** `includes/CaptivePortalEngine.php`

### 5.1 Constructor & Context

```php
class CaptivePortalEngine
{
    private PDO $pdo;
    private PortalThemeEngine $themeEngine;
    private array $mikrotikParams;  // mac, ip, link-login, link-orig, etc.
    private ?int $tenant_id;
    private ?int $hotspot_id;

    /**
     * Resolve tenant from:
     *   1. tenant_domains (Host header, domain_type=captive)
     *   2. ?tenant= query param (dev/lab)
     *   3. hotspot_id → hotspots.tenant_id
     */
    public static function fromRequest(PDO $pdo): self;
}
```

### 5.2 Page Rendering

```php
/**
 * Render published page by slug.
 * @throws PortalPageNotFoundException
 */
public function renderPage(string $slug): string;

/**
 * Render preview (admin) — allows unpublished pages.
 */
public function renderPreview(int $page_id, int $tenant_id): string;

/**
 * Build full HTML document with theme, ads, survey gate.
 */
protected function wrapLayout(string $body_html, array $page, array $theme): string;
```

### 5.3 Template Variable Injection

Placeholders replaced at render time:

| Variable | Source |
|----------|--------|
| `{{tenant_name}}` | `tenants.company_name` |
| `{{logo_url}}` | `captive_portal_themes.logo_url` |
| `{{mac}}` | MikroTik `mac` param |
| `{{ip}}` | MikroTik `ip` param |
| `{{link_login}}` | MikroTik `link-login` (URL-encoded) |
| `{{link_orig}}` | MikroTik `link-orig` |
| `{{packages_html}}` | Rendered package cards block |
| `{{auth_methods}}` | Enabled login method tabs |
| `{{survey_form}}` | Survey questions if required |
| `{{advert_banner}}` | Active `hotspot_advertisements` |
| `{{csrf_token}}` | Session CSRF for AJAX |
| `{{page_title}}` | `captive_portal_pages.title` |

### 5.4 Block System (`config_json` on templates)

```json
{
  "blocks": [
    {"id": "header", "type": "logo", "visible": true},
    {"id": "hero", "type": "splash_image", "asset_id": 12},
    {"id": "auth", "type": "login_tabs", "methods": ["voucher", "payment", "trial"]},
    {"id": "packages", "type": "package_grid", "profile_ids": [1, 2, 3]},
    {"id": "footer", "type": "text", "content": "Powered by {{tenant_name}}"}
  ],
  "options": {
    "show_terms_link": true,
    "require_survey": false,
    "mobile_bottom_cta": true
  }
}
```

Builder UI (`captive_portal_edit.php`) manipulates `config_json`; engine renders blocks to HTML.

---

## 6. Class: `CaptiveAuthManager`

**File:** `includes/CaptiveAuthManager.php`

### 6.1 Session Lifecycle

```php
/**
 * Create pending session when guest lands on portal.
 */
public function initSession(
    int $tenant_id,
    string $mac_address,
    ?string $ip_address,
    ?int $router_id,
    ?int $page_id
): string;  // returns session_token (UUID)

/**
 * Complete auth — status authenticated, set expires_at from profile.
 */
public function authenticate(
    string $session_token,
    string $auth_method,
    array $auth_data
): array;

/** Terminate session (logout or expiry). */
public function terminate(string $session_token, string $reason): void;
```

### 6.2 Voucher Auth

```php
public function authVoucher(
    int $tenant_id,
    string $session_token,
    string $voucher_code,
    array $mikrotik_params
): array;
```

Flow:

1. Validate voucher via `sp_redeem_voucher` or voucher status check
2. `RadiusManager::syncVoucherAuth()` — provision RADIUS user
3. UPDATE `captive_portal_sessions` (voucher_id, auth_method=voucher)
4. Return MikroTik login credentials: `username={tenant}.{code}`, `password={pin}`
5. Client POSTs to `link-login` with credentials (CHAP or PAP per hotspot config)
6. INSERT `captive_portal_login_log` (success)

### 6.3 Payment Auth (STK)

```php
public function authPayment(
    int $tenant_id,
    string $session_token,
    int $package_id,
    string $phone,
    array $mikrotik_params
): array;
```

Flow:

1. Resolve `hotspot_profiles` / `packages` price for `package_id`
2. `PaymentManager` + `MpesaGateway::initiateCollection()` (or tenant default gateway)
3. Link `payments.payment_id` + `mpesa_transactions` to `captive_portal_sessions`
4. Poll until completed (AJAX) or callback
5. On success: create hotspot user / voucher in RADIUS with profile session timeout
6. Return login credentials for MikroTik

### 6.4 Free Trial

```php
public function authTrial(
    int $tenant_id,
    string $session_token,
    string $mac_address,
    array $mikrotik_params
): array;
```

Rules (tenant config in `hotspot_login_methods.config_json`):

| Rule | Default |
|------|---------|
| Max trials per MAC per day | 1 |
| Trial duration | 30 minutes |
| Trial bandwidth profile | `trial` hotspot profile |

Creates temporary RADIUS user `{tenant}.trial.{mac_hash}` with short `Session-Timeout`.

### 6.5 Username/Password

Uses existing `hotspot_users` — portal validates, returns credentials for MikroTik login form POST to `link-login`.

---

## 7. Class: `CaptivePortalPublisher`

**File:** `includes/CaptivePortalPublisher.php`

### 7.1 Publish Workflow

```php
/**
 * Mark page published, invalidate cache, optionally sync MikroTik.
 */
public function publishPage(int $page_id, int $tenant_id, int $user_id): void;

/**
 * Unpublish — remove from live routing.
 */
public function unpublishPage(int $page_id, int $tenant_id, int $user_id): void;

/**
 * Set default portal template for tenant.
 */
public function setDefaultTemplate(int $template_id, int $tenant_id): void;
```

On publish:

1. SET `is_published = 1`, `published_at = NOW()`
2. Audit log: `captive_portal.publish`
3. Clear APCu/file cache: `captive_page_{tenant_id}_{slug}`
4. If `hotspot_portals` linked → update `portal_url` if needed

### 7.2 MikroTik Portal URL

Per hotspot, `hotspot_portals.portal_url`:

```
https://{captive_domain}/captive.php?page=login&hotspot_id={id}
```

MikroTik hotspot profile `html-directory` not used when external portal enabled — `login-url` points to ISP360.

**RouterManager sync:**

```php
public function syncHotspotPortal(int $hotspot_id, int $tenant_id): void;
```

Sets on MikroTik:

```
/ip hotspot profile
set [find name="{profile}"] login-by=http-chap,cookie html-directory=flash/hotspot

/ip hotspot walled-garden
# Sync from hotspot_walled_garden table
```

Walled garden **must** include:

- Captive portal domain (HTTPS)
- Payment gateway API domains (see [PAYMENTS_SPEC.md](PAYMENTS_SPEC.md))
- CDN for portal assets

---

## 8. Starter Templates (`/templates/`)

Shipped HTML prototypes cloned into `captive_portal_templates` on tenant setup.

| Directory | Use Case | Pages |
|-----------|----------|-------|
| `templates/hotel/` | Hotel/resort WiFi | index, login, success |
| `templates/school/` | School/campus | index, login, terms |
| `templates/isp/` | ISP branded hotspot | index, login, packages |
| `templates/public_wifi/` | Café/mall | index, splash |
| `templates/event/` | Conference/event | index, register |
| `templates/shared/` | CSS/JS shared | portal.css, portal.js |

### 8.1 Template Import

```php
// TenantProvisioner or captive_portal_edit.php "Import starter"
public function importStarterTemplate(
    int $tenant_id,
    string $starter_code,  // hotel|school|isp|public_wifi|event
    int $user_id
): int;
```

Copies HTML into `captive_portal_templates.html_content`, creates default theme from starter palette, creates pages with slugs.

### 8.2 ISP Starter Package Cards

`templates/isp/packages.html` block pattern:

```html
<div class="package-card" data-package-id="{{package_id}}">
  <h3>{{package_name}}</h3>
  <p class="price">{{currency}} {{price}}</p>
  <p class="duration">{{validity}}</p>
  <button class="btn-pay" data-action="stk" data-package="{{package_id}}">Pay with M-Pesa</button>
</div>
```

Engine replaces from `hotspot_profiles` + `packages` joined data.

---

## 9. Public Entry Points

### 9.1 `captive.php` (Root — Public HTML)

**Recommended public renderer** (follows flat root architecture like `login.php`):

```php
<?php
require_once __DIR__ . '/includes/config.php';
require_once __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/CaptivePortalEngine.php';

$engine = CaptivePortalEngine::fromRequest($pdo);
$slug = $_GET['page'] ?? 'login';

try {
    echo $engine->renderPage($slug);
} catch (PortalPageNotFoundException $e) {
    http_response_code(404);
    echo $engine->renderPage('error');  // fallback page if configured
}
```

**No staff auth.** Tenant resolved from `Host` header via `tenant_domains`.

### 9.2 MikroTik Redirect Parameters

MikroTik appends to login URL:

| Parameter | Description |
|-----------|-------------|
| `mac` | Client MAC address |
| `ip` | Client IP |
| `username` | Attempted username (if any) |
| `link-login` | URL to submit login form |
| `link-orig` | Original requested URL |
| `chap-id` | CHAP challenge ID |
| `chap-challenge` | CHAP challenge |
| `error` | Previous login error message |

All hidden fields preserved in portal forms (`templates/shared/js/portal.js`).

### 9.3 API Endpoints (JSON)

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `api/v1/captive/login.php` | POST | JSON auth (mobile apps, external integrations) |
| `api/v1/captive/status.php` | GET | Session status by token |
| `api/v1/captive/packages.php` | GET | Available packages for hotspot |

### 9.4 AJAX Endpoints (Portal UI)

| Endpoint | Purpose |
|----------|---------|
| `ajax/captive_voucher_login.php` | Voucher validation + RADIUS sync |
| `ajax/captive_authenticate.php` | Username/password trial auth |
| `ajax/captive_package_list.php` | Package grid data for page |
| `ajax/portal_stk_push.php` | STK from captive payment page (reused) |

**Security:** CSRF token required; rate limit 10 attempts/MAC/15 min; no staff `permission_check`.

---

## 10. Workflows

### 10.1 Guest Lands on Portal (MikroTik Redirect)

```mermaid
sequenceDiagram
    participant G as Guest Browser
    participant MT as MikroTik
    participant CP as captive.php
    participant CPE as CaptivePortalEngine
    participant CAM as CaptiveAuthManager
    participant DB as MySQL

    G->>MT: HTTP request (unauthenticated)
    MT->>G: 302 captive.php?mac=...&link-login=...
    G->>CP: GET page=login
    CP->>CPE: fromRequest() + renderPage(login)
    CPE->>DB: Load published page + theme
    CPE->>CAM: initSession(mac, ip)
    CPE-->>G: HTML login page
```

### 10.2 Voucher Login

```mermaid
sequenceDiagram
    participant G as Guest
    participant AJAX as captive_voucher_login.php
    participant CAM as CaptiveAuthManager
    participant RM as RadiusManager
    participant MT as MikroTik
    participant FR as FreeRADIUS

    G->>AJAX: POST voucher_code + session_token
    AJAX->>CAM: authVoucher()
    CAM->>RM: syncVoucherAuth()
    CAM-->>G: {username, password, link_login}
    G->>MT: POST link-login (CHAP)
    MT->>FR: Access-Request
    FR-->>MT: Access-Accept
    MT->>G: Redirect link-orig (internet)
```

### 10.3 Pay via STK (Package Purchase)

```mermaid
sequenceDiagram
    participant G as Guest
    participant CP as captive.php page=buy
    participant AJAX as portal_stk_push.php
    participant PM as PaymentManager
    participant CB as payment callback
    participant CAM as CaptiveAuthManager
    participant MT as MikroTik

    G->>CP: Select package
    G->>AJAX: phone + package_id + session_token
    AJAX->>PM: initiateCollection()
    PM-->>G: STK sent — poll status
    CB->>PM: completePayment()
    PM->>CAM: authenticate(payment)
    CAM-->>G: credentials
    G->>MT: POST link-login
```

### 10.4 Admin Publishes Portal

```mermaid
sequenceDiagram
    participant A as Admin
    participant UI as captive_portal_edit.php
    participant CPP as CaptivePortalPublisher
    participant RM as RouterManager
    participant DB as MySQL

    A->>UI: Save + Publish
    UI->>CPP: publishPage()
    CPP->>DB: is_published=1
    CPP->>CPP: Clear render cache
    A->>UI: Link to hotspot
    UI->>RM: syncHotspotPortal()
    RM->>RM: Update walled-garden + login-url
```

### 10.5 Pre-Login Survey Gate

```mermaid
sequenceDiagram
    participant G as Guest
    participant CP as captive.php
    participant AJAX as captive_survey_submit.php
    participant DB as MySQL

    G->>CP: page=login
    CP->>CP: Survey required? → redirect page=survey
    G->>AJAX: Submit answers
    AJAX->>DB: INSERT survey_responses
    AJAX-->>G: Set session flag survey_complete
    G->>CP: page=login (now allowed)
```

---

## 11. Admin UI Pages

All admin pages use [UI_SHELL_PATTERN.md](UI_SHELL_PATTERN.md).

| Page | Permission | Tables |
|------|------------|--------|
| `captive_portals.php` | `captive_portal.view` or `hotspot.edit` | `captive_portal_templates` |
| `captive_portal_edit.php` | `captive_portal.edit` | `captive_portal_templates`, `captive_portal_pages`, `captive_portal_themes` |
| `captive_portal_themes.php` | `captive_portal.edit` | `captive_portal_themes`, `captive_portal_assets` |
| `captive_portal_preview.php` | `captive_portal.preview` | `captive_portal_templates` |
| `hotspot_portals.php` | `hotspot.edit` | `hotspot_portals`, `hotspot_portal_templates` |
| `hotspot_walled_garden.php` | `hotspot.edit` | `hotspot_walled_garden` |

### 11.1 Portal Builder UI (`captive_portal_edit.php`)

Three-panel layout (same component pattern as dashboard — ISP360 design language):

| Panel | Content |
|-------|---------|
| Left | Page list, add page, layout picker |
| Center | Live preview iframe (`ajax/render_captive_preview.php`) |
| Right | Block editor, theme picker, publish button |

**Builder features:**

- Drag-and-drop block reorder
- Color picker bound to theme variables
- Asset upload → `captive_portal_assets`
- Import starter template dropdown
- Mobile / tablet / desktop preview toggle
- Publish → `captive_portal.publish` permission

### 11.2 AJAX Endpoints (Admin)

| Endpoint | Permission | Action |
|----------|------------|--------|
| `ajax/get_captive_portals.php` | `captive_portal.view` | List templates |
| `ajax/save_captive_portal.php` | `captive_portal.edit` | Save template + pages |
| `ajax/preview_captive_portal.php` | `captive_portal.preview` | Builder preview data |
| `ajax/render_captive_preview.php` | `captive_portal.preview` | HTML iframe render |
| `ajax/save_captive_theme.php` | `captive_portal.edit` | Save theme |
| `ajax/save_hotspot_portal.php` | `hotspot.edit` | Bind hotspot to portal URL |
| `ajax/upload_captive_asset.php` | `captive_portal.edit` | Upload image/CSS/JS |

---

## 12. Domain & Tenant Resolution

### 12.1 Captive Domain Setup

```
tenant_domains:
  domain_name: captive.acmeisp.co.ke
  domain_type: captive
  is_primary: 1
  ssl_enabled: 1
```

Nginx server block routes to `captive.php`; PHP resolves `tenant_id` from domain.

### 12.2 Multi-Hotspot Routing

```
captive.php?page=login&hotspot_id=42
```

Or subdomain per location: `hotel.acmeisp.co.ke` mapped to `hotspot_id` in `tenant_settings.captive_hotspot_map` JSON.

---

## 13. RBAC Permissions

| Code | Action |
|------|--------|
| `captive_portal.view` | List portals and themes |
| `captive_portal.create` | Create template/page |
| `captive_portal.edit` | Edit builder, themes, assets |
| `captive_portal.delete` | Delete draft pages |
| `captive_portal.publish` | Publish to live |
| `captive_portal.preview` | Preview unpublished |
| `hotspot.edit` | Bind portal to hotspot, walled garden |

**Roles:** ISP Admin (full), Support Agent (view + preview), Technician (edit + publish).

Public captive AJAX: **no RBAC** — CSRF + rate limiting only.

---

## 14. Caching & Performance

| Cache Key | TTL | Invalidation |
|-----------|-----|--------------|
| `captive_page_{tenant}_{slug}` | 300s | On publish/unpublish |
| `captive_theme_{tenant}_{theme_id}` | 600s | On theme save |
| `captive_packages_{hotspot_id}` | 120s | On profile change |

- Rendered HTML minified in production
- Assets served from `/uploads/captive/{tenant_id}/` with cache headers
- Optional CDN via `captive_portal_assets.cdn_url`

---

## 15. Security

| Risk | Mitigation |
|------|------------|
| XSS in custom HTML | Admin-only HTML edit; sanitize on render (strip `<script>` in preview mode); CSP header on public portal |
| CSRF on auth AJAX | Session-bound CSRF token per `captive_portal_sessions` |
| Brute voucher guess | Rate limit 10/MAC/15min; lock after 20 failures/hour |
| Cross-tenant portal | Domain resolution enforced; every query has `tenant_id` |
| Open redirect | `redirect_url` whitelist per tenant |
| Credential leakage | Never log RADIUS passwords; MikroTik CHAP handled client-side |

### 15.1 Content Security Policy (Public Portal)

```
Content-Security-Policy: default-src 'self'; script-src 'self' cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:;
```

---

## 16. Advertisements

`hotspot_advertisements` injected by engine when `advert_type` matches page context:

| Type | Placement |
|------|-----------|
| `banner` | Top of login page |
| `popup` | Modal before auth (skippable after N seconds) |
| `video` | Muted autoplay splash |
| `html` | Custom block |
| `redirect` | Interstitial before login |

Track `impressions` on render, `clicks` on CTA.

---

## 17. Cron Jobs

| Script | Schedule | Purpose |
|--------|----------|---------|
| `cron/cleanup_sessions.php` | `0 * * * *` | Expire `captive_portal_sessions` where `expires_at < NOW()` |
| `cron/captive_cache_warm.php` | `*/30 * * * *` | Pre-render popular pages (optional) |

---

## 18. Error Handling

| Error | HTTP | Guest Message |
|-------|------|---------------|
| Page not found | 404 | Friendly "Portal unavailable" |
| Invalid voucher | 400 | "Invalid or expired voucher code" |
| Trial exhausted | 429 | "Free trial already used today" |
| Payment failed | 402 | "Payment could not be processed" |
| Survey required | 302 | Redirect to survey page |
| Hotspot disabled | 503 | "WiFi service temporarily unavailable" |

Failed auth logged to `captive_portal_login_log` with `failure_reason`.

---

## 19. MikroTik Configuration Reference

### 19.1 Hotspot Profile (External Portal)

```
/ip hotspot profile
add name=isp360-external hotspot-address=10.5.50.1 dns-name=wifi.local \
    html-directory=flash/hotspot login-by=http-chap,cookie \
    http-cookie-lifetime=1d use-radius=yes radius-accounting=yes

/ip hotspot
add name=hotspot1 interface=wlan1 profile=isp360-external addresses-per-mac=2

/ip hotspot walled-garden
add dst-host=captive.acmeisp.co.ke action=allow
add dst-host=api.safaricom.co.ke action=allow
add dst-host=*.isp360.com action=allow
```

### 19.2 Login URL Override

Set `hotspot_portals.portal_url` in ISP360; sync via RouterManager pushes to router if RouterOS supports custom login URL via script or external landing (portal hosted entirely off-router — standard African ISP pattern).

---

## 20. Layout: `layouts/captive.php`

Minimal public layout (no admin sidebar):

```php
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
    <title>{{page_title}}</title>
    <link rel="stylesheet" href="/templates/shared/css/portal.css">
    <style>{{compiled_theme_css}}</style>
</head>
<body class="captive-body">
    {{advert_banner}}
    <main class="captive-main">{{page_content}}</main>
    <script src="/templates/shared/js/portal.js"></script>
    {{page_js}}
</body>
</html>
```

Distinct from admin `layouts/app-start.php` and subscriber `layouts/portal_header.php`.

---

## 21. Implementation Checklist (Wave 4)

- [ ] `includes/PortalThemeEngine.php`
- [ ] `includes/CaptivePortalEngine.php`
- [ ] `includes/CaptiveAuthManager.php`
- [ ] `includes/CaptivePortalPublisher.php`
- [ ] `layouts/captive.php`
- [ ] `captive.php` public entry
- [ ] Starter templates in `/templates/` (5 sets + shared)
- [ ] Admin pages: 4 captive builder + `hotspot_portals.php`
- [ ] AJAX: 7 captive endpoints (3 public + 4 admin)
- [ ] API: `api/v1/captive/login|status|packages.php`
- [ ] `RouterManager::syncHotspotPortal()` + walled garden sync
- [ ] Integration with `RadiusManager` + `PaymentManager`
- [ ] Cron session cleanup
- [ ] TenantProvisioner starter template import

---

## 22. Related Documents

| Document | Relevance |
|----------|-----------|
| [RADIUS_SPEC.md](RADIUS_SPEC.md) | Voucher, MAC, hotspot auth flows |
| [PAYMENTS_SPEC.md](PAYMENTS_SPEC.md) | STK push on captive payment pages |
| [UI_SPECIFICATIONS.md](UI_SPECIFICATIONS.md) | Builder screen field specs |
| [PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md) | File manifest, `/templates/` |
| [UI_SHELL_PATTERN.md](UI_SHELL_PATTERN.md) | Admin builder pages pattern |
| [MIKROTIK_INTEGRATION.md](MIKROTIK_INTEGRATION.md) | Hotspot sync, walled garden |
