# ADR-093: Zero-Backend Standalone PWA — localStorage-Only State with Client-Side PIN Guard

## Status

**Accepted** (2026-06-29)

## Status History

```yaml
- 2026-06-29:
    status: Accepted
    decision_maker: hkl
    rationale: Drive Consolidator instantiates the zero-backend pattern; architecture codified to guide future personal utility tools with no server persistence
    changed_via: adr-kit (360lm)
```

## Context

The 360lm platform has historically organized PWAs into two categories:

1. **ERP PWAs** (ADR-012, ADR-011): Hub PIN authentication, shared `lm360`/`lm360_prod` PostgreSQL databases, coordinated via proxy routing
2. **Isolated PWAs** (ADR-086): Own databases, own authentication models (magic link, OAuth, username/password), zero cross-access to 360lm schemas

However, not all applications require a backend database. **Drive Consolidator** at `drive.srv1111289.hstgr.cloud` introduces a third sub-category: **zero-backend PWAs** — single-file HTML/CSS/JS applications with no PostgREST, no database, and no server-side authentication.

**Current state:**
- **Drive Consolidator:** nginx:alpine serving single-file HTML/CSS/JS PWA
- **All state:** localStorage only (profiles, per-user config, saved email hints)
- **Authentication:** 4-digit PIN hashed client-side with SHA-256 via Web Crypto API (`crypto.subtle.digest()`)
- **Authorization:** Delegated entirely to Google OAuth for actual Drive data access
- **PIN role:** UI guard only — gates the local interface but does not protect server resources (none exist)
- **localStorage namespacing:** `dc-users`, `dc-cfg-{id}`, `dc-user-{id}-accounts` — all prefixed to prevent collision with other apps on the same origin
- **Service worker:** Standard cache-first strategy; must NOT intercept external auth provider calls (googleapis.com, accounts.google.com)

This pattern suits personal utility tools where:
- All user data never leaves the user's browser + the external provider (Google Drive, etc.)
- User population is the tool's owner or small trusted circle (not employees with 360lm accounts)
- A full backend would be over-engineering for a workflow helper
- Actual data authorization is delegated to an external provider with its own auth layer

ADR-086 covers isolated PWAs with their own DB (Health Tracker model). This ADR covers the further-isolated case of zero server dependency.

## Decision

**A zero-backend PWA is appropriate when ALL of the following criteria are met:**

| Criterion | Description |
|---|---|
| **No server-side data** | All state lives in the user's browser (localStorage, IndexedDB, memory). No backend database. No PostgREST. No RPC calls to create/read/update/delete user data. |
| **No 360lm user pool** | Users are NOT 360lm employees. Authentication is not via Hub PIN (ADR-012). User population is external (personal tool, family tool, small trusted group). |
| **External auth delegation** | Actual data authorization is delegated to an external provider (Google, AWS Cognito, Auth0, etc.) with its own security model. Zero-backend PWAs do NOT authenticate users themselves. |
| **Single-file HTML** | The entire PWA is a single self-contained HTML file with embedded CSS and JavaScript. No build pipeline. No npm dependencies (except Web Crypto API and fetch, which are native). |
| **UI lock, not security gate** | The PIN (or password) gates the local interface only. It does not protect server resources. Threat model: prevent accidental exposure of Google tokens if browser profile is shared on a device, NOT prevent sophisticated attackers. |

**Zero-Backend PWA Standards:**

1. **Authentication & State:**
   - Optional client-side PIN guard via SHA-256 hash (`crypto.subtle.digest('SHA-256', encoder.encode(pin))`)
   - No bcrypt, no server-side verification, no token issuance
   - localStorage is the sole source of truth for app-specific config (user profiles, settings, cache)
   - No cookies (bypass mixed-content issues and simplify logout)

2. **External Provider Integration:**
   - Use OAuth 2.0 redirect flow for the external provider (e.g., Google OAuth)
   - Store access tokens in sessionStorage only (cleared on browser close)
   - Refresh tokens (if any) stored in localStorage with short expiry windows
   - Service worker must NOT intercept or cache external auth endpoints
   - Explicit `googleapis.com` exclusion in SW cache rules to guarantee fresh auth calls

3. **localStorage Namespacing:**
   - Prefix ALL keys with app identifier (e.g., `dc-`, `drive-consolidator-`)
   - Example keys: `dc-users`, `dc-cfg-{id}`, `dc-user-{id}-accounts`
   - Prevents collision with other apps on the same origin
   - Document namespace prefix in the HTML file's header comment

4. **Service Worker & Caching:**
   - Cache-first strategy for static assets (index.html, CSS, JS)
   - Network-first or network-only for external API calls (googleapis.com, accounts.google.com)
   - Manual version bump via CACHE_VER string (ADR-005 pattern applies)
   - Never cache auth endpoints or token refresh calls

5. **File Structure:**
   ```
   drive-consolidator/
   ├── public/
   │   └── index.html            (single file: HTML + <style> + <script>)
   └── docker-compose.yml        (nginx:alpine, single container)
   ```

6. **Infrastructure & Deployment:**
   - nginx:alpine container serving static HTML
   - No PostgREST, no sidecar, no database
   - Traefik routing via docker-compose labels (same as other PWAs)
   - Deployment: git pull + docker-compose up -d
   - No backup script needed (all user data is on external provider; app state is ephemeral)

## Implementation Notes

### Checklist for Creating a New Zero-Backend PWA

- [ ] **Purpose & Scope:** Document why this is a zero-backend tool vs. isolated PWA vs. ERP PWA. Confirm user population is external/personal, NOT 360lm employees.
- [ ] **Naming & Location:** Create app under `/var/www/Others/[app_name]/` or similar isolated location
- [ ] **Single HTML File:**
  - [ ] Create `public/index.html` with embedded CSS and JavaScript
  - [ ] No build pipeline, no npm install (only native APIs)
  - [ ] Include `<meta charset="UTF-8">` and viewport meta tag (ADR-076)
  - [ ] Link `safe-bottom.css` if app has sticky UI (ADR-002 principle)
  - [ ] Include safe-area inset styles for notched devices (ADR-081)
- [ ] **localStorage Namespacing:**
  - [ ] Define app prefix in README and HTML header comment (e.g., `dc-` for Drive Consolidator)
  - [ ] Use prefix consistently for all keys
  - [ ] Example: `dc-users` (list), `dc-cfg-{userId}` (per-user settings), `dc-user-{userId}-accounts` (saved data)
- [ ] **Authentication (if needed):**
  - [ ] If using PIN: implement SHA-256 hashing via `crypto.subtle.digest()` (NOT bcrypt)
  - [ ] Store hashed PIN in localStorage only (not transmitted anywhere)
  - [ ] Document PIN threat model: UI lock, not security gate
  - [ ] If using OAuth: implement standard OAuth 2.0 redirect flow
  - [ ] Store access tokens in sessionStorage; refresh tokens (if any) in localStorage with expiry
- [ ] **Service Worker:**
  - [ ] Create `public/sw.js` with cache-first strategy for static assets
  - [ ] Use network-first or network-only for external API endpoints
  - [ ] Add explicit exclusion rules for `googleapis.com`, `accounts.google.com`, etc.
  - [ ] Implement CACHE_VER string for manual version bumps (ADR-005)
  - [ ] Test cache invalidation: clear app data, reload, confirm fresh fetch
- [ ] **External API Integration:**
  - [ ] Document which external providers the app calls (Google, AWS, etc.)
  - [ ] Use OAuth redirect for login; do NOT embed API keys in HTML
  - [ ] Implement error handling for network failures and token expiry
  - [ ] Test in offline mode: app should degrade gracefully, not crash
- [ ] **Docker & Traefik:**
  - [ ] Create `docker-compose.yml` with nginx:alpine service
  - [ ] Add Traefik labels for routing (same pattern as ADR-073)
  - [ ] Use `Host()` rule pointing to app subdomain (e.g., `consolidator.srv1111289.hstgr.cloud`)
  - [ ] Test routing: `curl -H 'Host: [app].srv1111289.hstgr.cloud' http://localhost`
- [ ] **Testing:**
  - [ ] Manual smoke test: load in browser, verify UI lock works (if PIN used), verify localStorage persists across reload
  - [ ] OAuth flow test: login via external provider, confirm tokens stored correctly, logout clears sessionStorage
  - [ ] Service worker test: open DevTools Network, reload offline, confirm static assets load from cache
  - [ ] Multi-user test (if applicable): create two profiles, verify data isolation via different localStorage keys
  - [ ] Optional: Playwright spec for critical flows (e.g., login, token refresh)
- [ ] **Documentation:**
  - [ ] Create `README.md`: architecture overview, storage schema, OAuth flow, deployment runbook
  - [ ] Add one-line entry to `/var/www/360lm/docs/adr/README.md` linking to this ADR
  - [ ] Document localStorage namespace prefix and expiry windows (if any)
- [ ] **Deployment:**
  - [ ] Deploy via `git pull && docker-compose up -d`
  - [ ] Verify via curl health check (ping container or check logs)
  - [ ] No backup cron needed (state is ephemeral; user data lives on external provider)

### Reference Implementation: Drive Consolidator

**Location:** `/var/www/Others/drive-consolidator/`

**Key files:**
- `public/index.html` — ~800-line single-file PWA (auth UI, Drive file listing, export workflow)
- `public/sw.js` — service worker, cache namespace `dc-v1`
- `docker-compose.yml` — nginx:alpine, exposes port 3400 (dev) or 3500 (prod)
- No backend, no database, no `.env` file with secrets (Google OAuth credentials handled via redirect flow)

**localStorage Schema:**
```javascript
dc-users          // Array: [{ id: 'user-1', email: 'user@gmail.com', name: 'User Name' }]
dc-cfg-{userId}   // Object: { theme: 'light', lastExport: '2026-06-29T10:00:00Z' }
dc-user-{userId}-accounts // Array: saved Google account emails for quick access
```

**Service Worker Caching:**
- Static assets (index.html, sw.js): cache-first
- googleapis.com, accounts.google.com: network-only (bypass cache)
- CACHE_VER: `'dc-v1'` in sw.js; bump to `'dc-v2'` on deployment

**OAuth Flow:**
1. User clicks "Login with Google"
2. Redirect to `https://accounts.google.com/o/oauth2/v2/auth?client_id=...&redirect_uri=...`
3. User authorizes, redirected back to `drive.srv1111289.hstgr.cloud/?code=...`
4. App exchanges code for token via Google's token endpoint (client-side POST from browser)
5. Access token stored in sessionStorage; refresh token (if provided) in localStorage with expiry
6. User can now access Google Drive via googleapis.com/drive/v3/

**PIN Guard (if used):**
```javascript
async function verifyPin(pin) {
  const encoder = new TextEncoder();
  const hash = await crypto.subtle.digest('SHA-256', encoder.encode(pin));
  const hashHex = Array.from(new Uint8Array(hash))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');
  return hashHex === localStorage.getItem('dc-pin-hash');
}
```

**Deployment:**
- `git clone` or `git pull` to fetch latest HTML
- `docker-compose up -d` to start nginx container
- No database migration, no backup script
- Health check: `curl -H 'Host: drive.srv1111289.hstgr.cloud' http://localhost`

**Status (2026-06-29):** LIVE (dev only; prod deployment TBD).

## Alternatives Considered

### 1. Full isolated PWA (with its own database)

**Rejected:**
- Adds unnecessary infrastructure (PostgreSQL, PostgREST, backup script, role management) for a tool where all data lives in Google Drive
- User data is not on 360lm servers; no 360lm database needed
- Isolated PWAs are appropriate for multi-user apps or apps with server-side business logic (e.g., Health Tracker with OCR, caregiver links)
- Drive Consolidator is a single-user workflow helper — zero server-side processing required
- Database and API sidecar add operational overhead (Docker container, health monitoring, migration scripts) with zero benefit

### 2. No authentication at all (open access)

**Rejected:**
- Shared devices or shared browser profiles could expose one user's Google access tokens to another user
- PIN guard is a lightweight UI lock that prevents accidental exposure in multi-user device scenarios
- Zero additional infrastructure cost (PIN guard is ~30 lines of JavaScript)
- Threat model is realistic for personal utility tools in home/family environments

### 3. bcrypt for PIN hashing

**Rejected (ponytail YAGNI):**
- bcrypt requires an npm dependency (bcryptjs or similar)
- Zero-backend PWAs have no build pipeline; all code must be native or use CDN
- Web Crypto SHA-256 is sufficient for a UI lock that does not protect server resources
- PIN attacks are not sophisticated (threat model: shared device, not targeted attack)
- SHA-256 + 4-digit PIN provides 65,536 possible hashes; acceptable for UI deterrence

### 4. IndexedDB instead of localStorage

**Rejected:**
- IndexedDB is asynchronous; adds complexity to initialization logic
- Config payload for personal utility tools is tiny (< 1 KB): profile list, per-user settings, cache hints
- localStorage is synchronous and sufficient; no performance benefit to IndexedDB
- IndexedDB has no advantage for small data sets; added abstraction cost for no gain

### 5. Store access tokens in localStorage (never expire)

**Rejected:**
- Exposes long-lived tokens if browser is compromised or device is stolen
- OAuth refresh tokens should be stored in localStorage (may persist across sessions)
- Access tokens should be short-lived and stored in sessionStorage (cleared on browser close)
- Adds resilience: even if someone steals the device while browser is open, only current session's token is at risk

## Consequences

### Positive

1. **Zero backend overhead:** No database, no API server, no backup/restore procedures. Deployment is `git pull && docker-compose up -d`.
2. **Instant shipping:** Single HTML file can be deployed in minutes. No schema migrations, no Docker image builds (nginx:alpine is standard).
3. **User privacy:** All user data stays in the user's browser or with the external provider (Google, etc.). No 360lm database intrusion.
4. **Cost efficiency:** One lightweight nginx container per zero-backend PWA. No PostgREST license, no PostgreSQL overhead, no backup storage costs.
5. **Clear threat model:** PIN guard gates the UI; actual security depends on the external provider's OAuth. No false sense of server-side protection.
6. **Scalability:** Each user's state is independent (no server sync needed). No database contention, no session serialization overhead.
7. **Reusability:** Same pattern can host multiple personal utility tools (PDF editor, markdown scratchpad, expense calculator) without coordination.

### Trade-Offs

1. **No server-side audit log:** All activity is local to the browser. No record of who accessed what, when. If audit logging is needed later, upgrade to isolated PWA.
2. **No cross-device sync:** User config / state does NOT sync across devices (no backend to coordinate). Each device has its own localStorage. If multi-device sync is needed, upgrade to isolated PWA.
3. **No offline-first sync:** If app has local edits and network goes down, changes persist only in localStorage. No background sync to external provider (would require backend). Manual export/re-import workflow may be needed.
4. **Fragile external dependency:** If external provider (Google, AWS) changes their OAuth or API, app breaks. No proxy layer to adapt. Tight coupling to external provider's terms of service.
5. **PIN is not encryption:** Hashed PIN prevents casual access but does NOT encrypt data in localStorage. If device is compromised, attacker can read raw localStorage data. If encryption is needed, app must implement WebCrypto AES-GCM (adds complexity).

### Risks and Mitigations

| Risk | Mitigation |
|---|---|
| Google OAuth credentials leaked in git history | Use OAuth redirect flow (user authenticates via Google's domain, never enters credentials in app). Do NOT hardcode API keys in HTML. Store client_id in HTML if public (OK for web apps); keep client_secret in server environment (nginx secrets or GitHub secrets for later). |
| User's Google access token exposed if device is stolen mid-session | Access tokens stored in sessionStorage (cleared on browser close). Recommend users log out before leaving device. Refresh tokens (if any) in localStorage with short expiry (< 1 week). |
| localStorage key collision with other apps on same origin | Mandatory namespace prefix (e.g., `dc-`, `consolidator-`). Document prefix in README and HTML header. Code review: grep all localStorage calls to confirm prefix usage. |
| Service worker caches auth endpoints, token refresh fails | Add explicit network-only rules for `accounts.google.com`, `oauth2.googleapis.com` in SW cache handler. Test cache behavior: DevTools -> Application -> Service Workers -> inspect Cache Storage. |
| User clears browser cache manually; app forgets all config | Implement recovery flow: user logs in again via OAuth, stored profile is re-fetched from external provider. Accept that zero-backend = no persistence guarantees. Document this in UI ("Clearing browser data will sign you out"). |
| App state grows too large for localStorage (> 5–10 MB) | Monitor localStorage usage via `localStorage.getItem().length`. If approaching limit, upgrade to IndexedDB or implement compression. Most personal tools stay < 1 MB. |
| External provider API changes or deprecates endpoint | No server proxy layer to adapt. App must be updated manually. Test external API calls quarterly; set up alerts for deprecation notices. Plan upgrade path if provider discontinues service. |

## Related Decisions

- **ADR-086 (Isolated PWA Architecture):** Isolated PWAs have their own databases and auth models but still require a backend. Zero-backend PWAs are a specialized subset of isolated apps with no backend at all.
- **ADR-012 (Hub as SSO Gateway):** Hub is for 360lm employees only. Zero-backend PWAs are for external users or personal tools; never register with Hub.
- **ADR-013 (Single HTML File, No Framework):** Zero-backend PWAs follow the same "single HTML file" principle as ERP PWAs, but without the Hub dependency or framework restrictions.
- **ADR-002 (safe-bottom.css):** Zero-backend PWAs should still link `safe-bottom.css` if they have sticky UI elements (principle applies universally).
- **ADR-005 (SW Cache Busting via CACHE_VER):** Zero-backend PWAs use the same CACHE_VER string bump strategy for service worker cache invalidation.
- **ADR-076 (Mobile-First Viewport):** Zero-backend PWAs are still mobile-first; viewport and meta tags apply.
- **ADR-081 (Safe-Area Insets):** Zero-backend PWAs should respect safe-area insets for notched devices.

## References

- **Memory:** `/root/.claude/projects/-var-www-360lm/memory/drive_consolidator.md` (when created) — Drive Consolidator live status, OAuth flow, localStorage schema, deployment runbook
- **Memory:** `/root/.claude/projects/-var-www-360lm/memory/infra_vps.md` — VPS infrastructure, Docker network, Traefik setup
- **Implementation:** `/var/www/Others/drive-consolidator/` (TBD) — Drive Consolidator source code and deployment config
- **Related ADRs:** ADR-002, ADR-005, ADR-012, ADR-013, ADR-076, ADR-081, ADR-086
- **Web Crypto API:** https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto
- **OAuth 2.0 for Web Applications:** https://tools.ietf.org/html/rfc6749#section-1.3.1

---

**Decision maker:** hkl  
**Changed via:** adr-kit (360lm)  
**Date:** 2026-06-29  
**Last reviewed:** 2026-06-29
