# PROJECT: Site Passcode Security — API-Verified Auth

**Last updated:** 2026-04-10
**Status:** Backend work in progress — frontend pending backend completion
**Priority:** High — passcodes for trusted/administrator access currently in localStorage plaintext

---

## Problem Statement

When a user loads the Aether frontend, the site bootstrap response includes `access_code_kv_json` — a JSON object containing all passcodes for all access levels (administrator, trusted, public, authenticated). The frontend stores this verbatim in `$ae_loc.site_access_code_kv`, which is persisted in localStorage.

**Result:** Anyone with DevTools → Application → Local Storage can see every passcode for every access level on any Aether site. For public/authenticated this is low risk, but for trusted and administrator this is a real exposure — these passcodes can grant control over event data, badge printing, edit mode, etc.

The passcode check (`handle_check_access_type_passcode` in `e_app_access_type.svelte`) is entirely local — it reads the cached values and compares directly. No API call is made. The backend already has a `/authenticate_passcode` endpoint that verifies server-side, but it needs the fixes described below before the frontend can rely on it.

### Source of Truth

`site.access_code_kv_json` is the single source of truth for all passcodes. The `v_site_domain` DB view joins this field from the site table — there is no separate copy. Both the bootstrap response and `/authenticate_passcode` read from the same data.

---

## Threat Model

| Threat | Current | After Fix |
|---|---|---|
| Attacker inspects localStorage | Sees all passcodes in plaintext | Sees a JWT (opaque, no passcode) |
| Attacker uses stolen trusted passcode | Trivial if they have localStorage access | Still possible if they enter the passcode — unavoidable |
| Attacker replays an old passcode after it changes | Works forever (cached value never refreshes) | Fails — API verifies against current DB value |
| Attacker tampers with `access_type` in localStorage | Grants apparent permission but API calls still fail | Same — `access_type` is still persisted separately |
| Passcode reuse across sessions | Works indefinitely | JWT TTL enforces session expiry per role |
| Offline / API-unavailable entry | Works (local cache) | **Blocked** — requires API to verify |

### The fundamental constraint

Passcode-based access is inherently weaker than username/password login with a hashed credential. The system's security model layers passcode access below user login, and API calls themselves are still gated by `x-aether-api-key` + `x-account-id`. The passcode primarily controls **what the frontend shows** and some API-level permission gates for trusted routes.

---

## Proposed Solution: API-Verified Passcode + JWT Session

### Core idea

1. **Never send passcodes to the client.** The frontend stops reading/storing `access_code_kv_json` from the bootstrap response.
2. **Passcode entry triggers an API call** to `/authenticate_passcode`. API verifies server-side against the DB.
3. **On success, the API returns a JWT** — the JWT contains the role, account context, and expiry.
4. **Store the JWT in `$ae_loc.jwt`** (already a field, already wired into `$ae_api`).
5. **On page reload**, check the JWT's `eat` (expires-at) claim locally (base64 decode, no signature verification needed client-side). If expired, drop to anonymous. If valid, `access_type` is already persisted in `$ae_loc`.

### Session restore on reload

- `access_type` still persists in localStorage (no change here)
- The JWT is the **proof** that the access was legitimately granted and is still valid
- On page load: decode JWT payload (base64 the middle segment), check `eat` vs `Date.now()/1000`
- If JWT expired → reset `access_type` to anonymous, clear JWT
- If JWT valid → no action needed, `access_type` is already correct

This gives session expiry without a network call on every page load.

---

## TTL Per Role — Decided

| Access Level | JWT TTL | Notes |
|---|---|---|
| `super` | 8 hours | Highest privilege |
| `manager` | 24 hours | |
| `administrator` | 48 hours | |
| `trusted` | 48 hours | Onsite staff — covers multi-day events |
| `public` | 24 hours | |
| `authenticated` | 12 hours | |
| `anonymous` | N/A | No passcode |

---

## Caching Decision

**No passcode caching.** Every passcode entry makes one API call. The JWT handles session persistence — no passcode ever touches localStorage. Performance impact is only at the moment of entry (~50–150ms), which is acceptable for a once-per-session action.

---

## Backend Changes Required

**Note:** The backend fixes described below have been implemented and tested in the `aether_api_fastapi` repository (the `/authenticate_passcode` endpoint now uses explicit role priority, returns a full passcode JWT with `auth_type: 'passcode'`, applies per-role TTLs, and validates passcode length). Frontend changes can proceed once the backend deployment with these fixes is available.

### Backend Agent Follow-Up

If the backend team revisits this area, keep the next round focused on narrowing escape hatches rather than adding new ones:

1. Audit every `x-no-account-id` use and decide whether it is still required for bootstrap, public delivery, or a global-default fallback.
2. Prefer JWT-backed auth once a session exists; do not add new transport-level bypass paths for authenticated UI flows.
3. Mark any remaining bypass-only helper as temporary and add a removal target.
4. Plan the eventual removal of `access_code_kv_json` from public bootstrap payloads once passcode auth is fully deployed.

### Frontend special-case endpoints to review

These are the current frontend-facing exceptions that the backend work should assume are special-cased. None require a frontend/client code change today, but some are intentionally temporary.

| Frontend path / helper | Status | Notes |
| --- | --- | --- |
| `src/routes/+layout.ts` | Keep | Bootstrap site-domain lookup before account context is known. |
| `src/routes/manifest.webmanifest/+server.ts` | Keep | Public PWA branding lookup; bootstrap key only. |
| `src/lib/ae_core/ae_core__site.ts` | Keep | Cache-first site-domain bootstrap path. Still a bootstrap-only special case. |
| `src/lib/ae_api/api_get__data_store.ts` + `src/lib/ae_core/core__data_store.ts` + `src/lib/elements/element_data_store.svelte` | Temporary | Global-default fallback. Target state is JWT-backed account-scoped access only. |
| `src/lib/ae_core/ae_core_functions.ts` | Remove candidate | Legacy site-domain helper with forced no-account scope. |
| `src/routes/testing/+page.svelte` | Dev-only | Useful for trace testing; do not add to any production allowlist. |

**Phase 2 status:** Not started — removing `access_code_kv_json` from the public site model remains pending.

**File:** `aether_api_fastapi/app/routers/api.py`

The `/authenticate_passcode` endpoint exists and is structurally correct but has four issues that must be fixed before the frontend migrates to using it.

### Fix 1: Passcode matching must use explicit priority order

**Current (wrong):**
```python
for role, code in access_codes.items():  # dict insertion order — not guaranteed
    if str(code) == str(passcode):
        matched_role = role
        break
```

**Required:**
```python
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']

matched_role = None
for role in ROLE_PRIORITY:
    code = access_codes.get(role)
    if code and str(code) == str(passcode):
        matched_role = role
        break
```

This ensures that if a config mistake causes two roles to share a passcode, the higher-privilege role always wins. It also makes the intent explicit and independent of JSON storage order.

### Fix 2: JWT payload must include all six role flags

**Current (incomplete):**
```python
payload = {
    'account_id': account_id_random,
    'administrator': (matched_role == 'administrator'),
    'manager':       (matched_role == 'manager'),
    'super':         (matched_role == 'super'),
    # trusted / public / authenticated missing
    ...
}
```

**Required:**
```python
payload = {
    'account_id':    account_id_random,
    'super':         (matched_role == 'super'),
    'manager':       (matched_role == 'manager'),
    'administrator': (matched_role == 'administrator'),
    'trusted':       (matched_role == 'trusted'),
    'public':        (matched_role == 'public'),
    'authenticated': (matched_role == 'authenticated'),
    'json_str': json.dumps({
        'auth_type': 'passcode',   # distinguishes from user login JWTs
        'site_id':   site_id,
        'role':      matched_role  # canonical role string — frontend uses this
    })
}
```

The `auth_type: 'passcode'` marker is critical — it allows the frontend and any future backend consumers to distinguish a passcode JWT from a user login JWT.

### Fix 3: Per-role TTL

**Current:**
```python
token = sign_jwt(
    secret_key=settings.JWT_KEY,
    ttl=3600 * 24,  # hardcoded 24h for all roles
    **payload
)
```

**Required:**
```python
ROLE_TTL = {
    'super':         8  * 3600,   #  8 hours
    'manager':       24 * 3600,   # 24 hours
    'administrator': 48 * 3600,   # 48 hours
    'trusted':       48 * 3600,   # 48 hours
    'public':        24 * 3600,   # 24 hours
    'authenticated': 12 * 3600,   # 12 hours
}

token = sign_jwt(
    secret_key=settings.JWT_KEY,
    ttl=ROLE_TTL[matched_role],
    **payload
)
```

### Fix 4: Add minimum length validation to `passcode` field

**Current:**
```python
passcode: str = Field(..., description="The passcode to verify")
```

**Required:**
```python
passcode: str = Field(..., min_length=5, description="The passcode to verify")
```

This matches the frontend's 5-character trigger and prevents empty/trivial submissions.

### Complete corrected endpoint (for reference)

```python
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']

ROLE_TTL = {
    'super':         8  * 3600,
    'manager':       24 * 3600,
    'administrator': 48 * 3600,
    'trusted':       48 * 3600,
    'public':        24 * 3600,
    'authenticated': 12 * 3600,
}

class PasscodeAuthRequest(BaseModel):
    """Request model for site-based passcode authentication."""
    site_id:  str = Field(..., description="Random string ID of the site")
    passcode: str = Field(..., min_length=5, description="The passcode to verify")

@router.post('/authenticate_passcode', response_model=Resp_Body_Base)
async def authenticate_passcode(
        auth_req: PasscodeAuthRequest,
        response: Response = Response,
        ):
    """
    Passcode-to-JWT Endpoint.
    Verifies a passcode against site.access_code_kv_json (single source of truth —
    v_site_domain joins from the same site record).
    Returns a signed JWT with the site's account context, full role flags, and
    a per-role TTL. The jwt.json_str.auth_type='passcode' field distinguishes
    this token from a user login JWT.
    """
    site_id  = auth_req.site_id
    passcode = auth_req.passcode

    # 1. Look up the site record
    search_data = {'id_random': site_id}
    if record := sql_select(table_name='site', data=search_data):
        # 2. Parse access codes
        access_codes_raw = record.get('access_code_kv_json')
        access_codes = {}
        if access_codes_raw:
            try:
                access_codes = json.loads(access_codes_raw) if isinstance(access_codes_raw, str) else access_codes_raw
            except Exception as e:
                log.error(f"Failed to parse access_code_kv_json for site {site_id}: {e}")

        # 3. Verify passcode in explicit priority order (highest privilege wins)
        matched_role = None
        for role in ROLE_PRIORITY:
            code = access_codes.get(role)
            if code and str(code) == str(passcode):
                matched_role = role
                break

        if matched_role:
            log.info(f"Auth Success: Verified '{matched_role}' passcode for site {site_id}")

            # 4. Resolve account context
            account_id_random = record.get('account_id_random')
            if not account_id_random:
                if account_id_int := record.get('account_id'):
                    account_id_random = get_id_random(record_id=account_id_int, table_name='account')

            # 5. Mint JWT with complete role flags and per-role TTL
            payload = {
                'account_id':    account_id_random,
                'super':         (matched_role == 'super'),
                'manager':       (matched_role == 'manager'),
                'administrator': (matched_role == 'administrator'),
                'trusted':       (matched_role == 'trusted'),
                'public':        (matched_role == 'public'),
                'authenticated': (matched_role == 'authenticated'),
                'json_str': json.dumps({
                    'auth_type': 'passcode',
                    'site_id':   site_id,
                    'role':      matched_role
                })
            }

            token = sign_jwt(
                secret_key=settings.JWT_KEY,
                ttl=ROLE_TTL[matched_role],
                **payload
            )

            return mk_resp(
                data={'jwt': token, 'account_id': account_id_random, 'role': matched_role},
                response=response
            )
        else:
            log.warning(f"Auth Failed: Invalid passcode for site {site_id}")
            return mk_resp(data=False, status_code=401, response=response, status_message="Invalid passcode.")
    else:
        log.warning(f"Auth Failed: Site {site_id} not found.")
        return mk_resp(data=False, status_code=404, response=response, status_message="Site not found.")
```

### Backend Phase 2 (follow-up — not blocking frontend)

**Remove `access_code_kv_json` from the `Site_Domain_Base` response model** (`site_domain_models.py`). This ensures passcodes are never sent to the client even if future code reads from the bootstrap. Requires confirming no other endpoint consumers rely on `access_code_kv_json` being in the base response before making this change.

---

## Frontend Changes Required

**These depend on the backend fixes above being deployed first.**

### 1a. `src/lib/app_components/e_app_access_type.svelte`

Replace `handle_check_access_type_passcode` entirely. The new version:

- Is `async`
- Adds `auth_pending: boolean = $state(false)` and `auth_error: string | null = $state(null)`
- Uses a direct `fetch` call (NOT `post_object` — avoids triggering the session-expired banner on a 401)
- On success: sets `$ae_loc.access_type = data.role`, stores `$ae_loc.jwt = data.jwt`, triggers `process_permission_check` as before
- On 401: shows inline error, clears `entered_passcode`, resets `checked_passcode = null` to allow retry
- On network error: shows inline connection error
- Clears `auth_error` when `entered_passcode` changes

API call shape:
```http
POST /authenticate_passcode
Content-Type: application/json
x-aether-api-key: <from $ae_api.headers['x-aether-api-key']>
Body: { site_id: $ae_loc.site_id, passcode: entered_passcode }
```

Add to template (near the passcode input):
```svelte
{#if auth_pending}
    <Loader size="1em" class="animate-spin text-gray-400" />
{/if}
{#if auth_error}
    <span class="text-error-500 text-xs">{auth_error}</span>
{/if}
```

### 1b. `src/routes/+layout.ts`

**Stop caching passcodes from bootstrap** — remove line ~394:
```ts
// ae_loc_init['site_access_code_kv'] = json_data.access_code_kv_json || {};
```

**Add passcode JWT expiry check** — after the block around line 84 where `ae_loc_json.jwt` is read, add:
```ts
// Enforce passcode JWT TTL on page load.
// Decodes the JWT payload (base64, no secret needed) and resets access to anonymous if expired.
// User login JWTs (auth_type !== 'passcode') are left untouched.
if (ae_loc_json?.jwt) {
    try {
        const parts = ae_loc_json.jwt.split('.');
        if (parts.length === 3) {
            const jwt_payload = JSON.parse(atob(parts[1]));
            const json_str = typeof jwt_payload.json_str === 'string'
                ? JSON.parse(jwt_payload.json_str)
                : jwt_payload.json_str;
            if (json_str?.auth_type === 'passcode' && jwt_payload.eat < Date.now() / 1000) {
                // Passcode JWT has expired — revoke access
                ae_loc_json.jwt = null;
                ae_loc_json.access_type = 'anonymous';
            }
        }
    } catch {
        // Malformed JWT — leave untouched, let existing handling deal with it
    }
}
```

### 1c. `src/lib/stores/ae_stores__auth_loc_defaults.ts` (cleanup)

Remove `site_access_code_kv` from the `AuthLocState` interface and the `auth_loc_defaults` object. The field is unused after 1a. Confirm no other component reads from it first (current grep: only `e_app_access_type.svelte` uses it — confirmed).

---

## Migration Notes

- Users with existing localStorage will still have `site_access_code_kv` cached — this is harmless after the frontend stops reading it. No forced cache clear needed.
- Existing persisted `access_type` is unaffected — users keep their current session level until their JWT expires or they manually clear storage.
- The `$ae_loc.jwt` field is already used by the user login flow. The `auth_type: 'passcode'` marker in `json_str` ensures the expiry logic only targets passcode sessions, not user login sessions.

---

## Files Affected

| File | Repo | Change |
| --- | --- | --- |
| `app/routers/api.py` | `aether_api_fastapi` | **Backend — do first.** Priority ordering, full JWT payload, per-role TTL, min_length on passcode |
| `app/models/site_domain_models.py` | `aether_api_fastapi` | Phase 2: remove `access_code_kv_json` from public model |
| `src/lib/app_components/e_app_access_type.svelte` | `aether_app_sveltekit` | Replace local check with async API call; loading/error UI |
| `src/routes/+layout.ts` | `aether_app_sveltekit` | Stop caching passcodes; add JWT expiry check |
| `src/lib/stores/ae_stores__auth_loc_defaults.ts` | `aether_app_sveltekit` | Cleanup: remove `site_access_code_kv` |
| `documentation/AE__Permissions_and_Security.md` | `aether_app_sveltekit` | Update passcode auth section to reflect new flow |
