# RealPage Internal Launchpad — App Builder Guide

> **This document is designed to be fed into an AI coding assistant (Claude, Copilot, Cursor, etc.) as project context.** It describes everything the assistant needs to know to build a secure, standards-compliant HTML app for the RealPage Internal Launchpad.

---

## What Is the Launchpad?

The Internal Launchpad (`https://launchpad.realpage.com`) is a secure, Azure-hosted platform for deploying self-contained HTML apps for RealPage employees. It provides:

- **Azure AD SSO** — all users authenticate via the RealPage Azure AD tenant before any content is served
- **Role-based access control** — general (all staff after admin approval) and invite-only apps
- **Serverless backend APIs** — AI proxies, shared cross-user storage, per-user storage, and more
- **Security-gated app delivery** — every app request passes through auth + optional IP allowlist enforcement before a byte of HTML is sent to the browser

Apps are self-contained HTML files stored in Azure Blob Storage and served on demand. The platform handles auth, routing, and security headers — you focus on the HTML/CSS/JS.

---

## App Format Requirements

Every Launchpad app is a **single self-contained HTML file**:

- All CSS must be inline (`<style>` tags) — no external stylesheets from non-approved domains
- All JavaScript must be inline (`<script>` tags) — no external JS files except approved CDNs (see CSP)
- Images and fonts: use base64 data URIs or approved external sources (see CSP `img-src` and `font-src`)
- No server-side code — apps are pure HTML/CSS/JS
- **No build step** — what you write is what gets uploaded

### File size guidance
- Recommended: under 500 KB
- Practical maximum: ~2 MB (larger files are slow to serve through VPN)
- For data-heavy apps: store data server-side using the Shared Record or App Storage APIs instead of baking it into the HTML

---

## Content Security Policy (CSP)

All apps are served with this CSP. **Your app MUST work within it — the browser will block anything that violates it:**

```
default-src 'self'
script-src  'self' 'unsafe-inline'
            cdn.jsdelivr.net cdnjs.cloudflare.com unpkg.com
            cdn.tailwindcss.com ajax.googleapis.com
            code.jquery.com stackpath.bootstrapcdn.com maxcdn.bootstrapcdn.com
style-src   'self' 'unsafe-inline'
            fonts.googleapis.com cdn.jsdelivr.net cdnjs.cloudflare.com
            unpkg.com cdn.tailwindcss.com stackpath.bootstrapcdn.com maxcdn.bootstrapcdn.com
font-src    'self' fonts.gstatic.com data:
img-src     'self' data: https:
connect-src 'self'
frame-ancestors 'none'
object-src  'none'
```

### The most important constraint: `connect-src 'self'`

Your app can **only** make `fetch()` / XHR calls to paths on `launchpad.realpage.com`. External API calls are blocked by the browser before they leave the machine.

| Call | Allowed? |
|------|----------|
| `fetch('/api/anthropic-proxy')` | ✅ Yes |
| `fetch('/api/shared-record/...')` | ✅ Yes |
| `fetch('https://api.anthropic.com/...')` | ❌ Blocked by CSP |
| `fetch('https://api.openai.com/...')` | ❌ Blocked by CSP |
| `fetch('https://any-external-service.com')` | ❌ Blocked by CSP |

**Always use the Launchpad proxy endpoints** for AI calls. Never call external APIs directly.

---

## Security Requirements (MANDATORY)

The upload portal runs an automated security scanner. Apps that fail are rejected before reaching the admin queue. Verify before uploading:

- [ ] **No hardcoded secrets** — no API keys, passwords, tokens, or credentials anywhere in the HTML (comments included)
- [ ] **No external fetch calls** — all `fetch()` / XHR targets must start with `/api/` or `/`
- [ ] **No `eval()` or `new Function(string)`** — dynamic code execution is prohibited
- [ ] **No `innerHTML` with untrusted data** — always use `textContent` for user-supplied or API-returned strings
- [ ] **No `localStorage` for sensitive data** — use `/api/app-storage` instead (it's server-side and per-user)
- [ ] **No `document.cookie` manipulation**
- [ ] **No real-looking token strings** in comments or examples — use `<your-token>` or `REPLACE_ME` placeholders
- [ ] **No image beacon patterns** — patterns like `new Image().src = 'https://...' + userEmail` are blocked (exfiltration risk). Use `/api/app-data` or `/api/shared-record` for data sharing instead.

**Scanner coverage:** The scanner uses line-by-line pattern detection, so most obfuscated patterns (e.g., splitting code across lines, or storing data in intermediate variables) can bypass detection. The platform's real defense is **CSP (`img-src https:`, `connect-src 'self'`) and isolated app origins (Phase 8)**, which are applied server-side and cannot be bypassed by app code.

---

## User Context (Auto-Injected by the Platform)

When your app is served through the Launchpad, two global variables are injected into your HTML before `</body>`:

```js
window.__LAUNCHPAD_APP_ID__  // string — the app's unique ID, e.g. "my-sales-dashboard"
window.__LAUNCHPAD_USER__    // string — the signed-in user's email (lowercase)
```

These are available to all your inline scripts. Use them to scope API calls and personalize the UI:

```js
const APP_ID = window.__LAUNCHPAD_APP_ID__ || 'dev-fallback';
const USER   = (window.__LAUNCHPAD_USER__  || 'dev@realpage.com').toLowerCase();

// Personalize
document.getElementById('greeting').textContent = `Hello, ${USER.split('@')[0]}`;

// Scope storage to this app + user automatically
const prefs = await fetch(`/api/app-storage/${APP_ID}/preferences`).then(r => r.ok ? r.json() : null);
```

> **Local development note:** These variables are `undefined` when opening the HTML directly in a browser. Always provide a fallback value so local dev doesn't break.

---

## Available Backend APIs

All endpoints require Azure AD authentication. The user's browser session cookie is sent automatically — **no API key is required in your app code.** Never put API keys in client-side code.

---

### 1. Current User Identity — `GET /.auth/me`

Returns the signed-in user's details. Use this to gate features by role.

```js
const me = await fetch('/.auth/me').then(r => r.json());
const principal = me.clientPrincipal;

const email   = (principal?.userDetails || '').toLowerCase();
const roles   = principal?.userRoles || [];
const isAdmin = roles.includes('admin');
```

Response shape:
```json
{
  "clientPrincipal": {
    "identityProvider": "aad",
    "userId": "azure-ad-object-id",
    "userDetails": "user@realpage.com",
    "userRoles": ["authenticated"]
  }
}
```

---

### 2. Anthropic (Claude) Proxy — `POST /api/anthropic-proxy`

Call Anthropic's Claude models through the platform proxy. The API key is stored server-side — your app never touches it.

**Allowed models:** `claude-haiku-4-5-20251001` (fast, economical) · `claude-sonnet-4-6` (higher quality)  
**Max tokens per response:** 4096 (hard cap enforced server-side)  
**Rate limit:** 20 requests / hour per user

```js
async function callClaude(userMessage, conversationHistory = []) {
  const messages = [
    ...conversationHistory,
    { role: 'user', content: userMessage }
  ];

  const res = await fetch('/api/anthropic-proxy', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: 'claude-haiku-4-5-20251001', // or 'claude-sonnet-4-6'
      max_tokens: 1024,
      system: 'You are a helpful assistant for RealPage employees.', // optional
      messages: messages.slice(-20) // keep context window manageable
    })
  });

  if (res.status === 429) throw new Error('Rate limit reached — try again in an hour.');
  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(err.error?.message || `HTTP ${res.status}`);
  }

  const data = await res.json();
  return data.content[0].text;
}
```

**Choose the right model:**
- Use `claude-haiku-4-5-20251001` for: classification, extraction, simple Q&A, high-volume calls
- Use `claude-sonnet-4-6` for: complex reasoning, document analysis, code generation, nuanced writing

---

### 3. OpenAI Proxy — `POST /api/openai-proxy`

Same request/response shape as the Anthropic proxy. Returns `503` if OpenAI is not configured in this environment.

**Allowed models:** `gpt-4o-mini` (fast) · `gpt-4o` (high quality)  
**Rate limit:** shared with Anthropic proxy — 20 requests / hour per user

```js
const res = await fetch('/api/openai-proxy', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    model: 'gpt-4o-mini',
    max_tokens: 1024,
    messages: [{ role: 'user', content: prompt }]
  })
});
```

---

### 4. Per-User Key-Value Store — `/api/app-storage/{appId}/{key}`

Persistent storage scoped to **this app + this user**. User A cannot read User B's data. Perfect for preferences, saved filters, progress tracking, and per-user configuration.

| Property | Limit |
|----------|-------|
| Key format | Lowercase alphanumeric, dash, underscore — max 64 chars |
| Value | Any JSON — max 32 KB serialized |
| Isolation | Fully per-user, per-app |

```js
const APP_ID = window.__LAUNCHPAD_APP_ID__;

// ── Save ──────────────────────────────────────────────────────────────────
async function savePreferences(prefs) {
  await fetch(`/api/app-storage/${APP_ID}/preferences`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ value: prefs })
  });
}

// ── Load ──────────────────────────────────────────────────────────────────
async function loadPreferences() {
  const res = await fetch(`/api/app-storage/${APP_ID}/preferences`);
  if (res.status === 404) return {}; // first visit — no data yet
  if (!res.ok) return {};            // fail silently, use defaults
  const data = await res.json();
  return data.value ?? {};
}

// ── Delete ────────────────────────────────────────────────────────────────
await fetch(`/api/app-storage/${APP_ID}/preferences`, { method: 'DELETE' });
```

---

### 5. Cross-User Shared Records — `/api/shared-record/{appId}/{recordId}`

Shared key-value store where **all users with app access can read and write**. Perfect for collaborative tools, admin-managed configuration, and shared dashboards.

| Property | Limit |
|----------|-------|
| recordId format | Lowercase alphanumeric, dash, underscore — max 50 chars |
| Value | Any JSON — max 32 KB serialized |
| Isolation | Shared across all users with access to this app |
| Concurrency | ETag-based optimistic locking |

```js
const APP_ID = window.__LAUNCHPAD_APP_ID__;

// ── Load a record ─────────────────────────────────────────────────────────
async function loadRecord(recordId) {
  const res = await fetch(`/api/shared-record/${APP_ID}/${recordId}`);
  if (res.status === 404) return { value: null, etag: null };
  if (!res.ok) throw new Error(`Load failed: ${res.status}`);
  const etag  = res.headers.get('ETag');
  const data  = await res.json();
  return { value: data.value, etag };
}

// ── Save a record (with optimistic concurrency) ───────────────────────────
async function saveRecord(recordId, value, etag = null) {
  const headers = { 'Content-Type': 'application/json' };
  if (etag) headers['If-Match'] = etag; // omit on first write

  const res = await fetch(`/api/shared-record/${APP_ID}/${recordId}`, {
    method: 'PUT',
    headers,
    body: JSON.stringify({ value })
  });

  if (res.status === 409) {
    // Another user saved between your load and save — reload and let user retry
    throw new Error('Conflict: record was updated by another user. Reload and try again.');
  }
  if (!res.ok) throw new Error(`Save failed: ${res.status}`);

  return res.headers.get('ETag'); // save for the next update
}

// ── List all records for this app ─────────────────────────────────────────
async function listRecords() {
  const res  = await fetch(`/api/shared-record/${APP_ID}`);
  const data = await res.json();
  return data.records; // [{ id, value, etag, updatedAt, updatedBy }]
}

// ── Delete a record ───────────────────────────────────────────────────────
async function deleteRecord(recordId, etag = null) {
  const headers = etag ? { 'If-Match': etag } : {};
  await fetch(`/api/shared-record/${APP_ID}/${recordId}`, { method: 'DELETE', headers });
}
```

**When to use Shared Record vs App Storage:**
- `shared-record` — admin updates config that all users immediately see; collaborative editing; shared dashboards
- `app-storage` — user saves their own preferences, progress, or private notes

---

### 6. Editor-Gated Data Store — `/api/app-data/{appId}`

A shared data store with **read-open, write-restricted** semantics. Any user with app access can read; only designated editors (set by admin) can write. Useful for pricing data, curated config, or other information that should be visible to all but editable by a restricted group.

| Property | Limit |
|----------|-------|
| Editors | Configured by admin via "App Editors" field in app settings |
| Value | Any JSON — max 32 KB serialized |
| Concurrency | ETag-based optimistic locking (same as shared-record) |

```js
const APP_ID = window.__LAUNCHPAD_APP_ID__;

// ── Load pricing data (any user with app access) ────────────────────────
async function loadPrices() {
  const res = await fetch(`/api/app-data/${APP_ID}`);
  if (res.status === 404) return { prices: [] };
  if (!res.ok) throw new Error(`Failed to load: ${res.status}`);
  const etag = res.headers.get('ETag');
  const data = await res.json();
  return { prices: data.value?.prices || [], etag };
}

// ── Update pricing data (editors only) ─────────────────────────────────
async function updatePrices(prices, etag = null) {
  const headers = { 'Content-Type': 'application/json' };
  if (etag) headers['If-Match'] = etag;

  const res = await fetch(`/api/app-data/${APP_ID}`, {
    method: 'PUT',
    headers,
    body: JSON.stringify({ value: { prices } })
  });

  if (res.status === 403) {
    throw new Error('You do not have editor permissions for this app.');
  }
  if (res.status === 409) {
    throw new Error('Conflict: data was changed by another editor. Reload and try again.');
  }
  if (!res.ok) throw new Error(`Update failed: ${res.status}`);

  return res.headers.get('ETag'); // save for next update
}
```

**Difference from shared-record:**
- `app-data` — restricted write access (admin-controlled allowlist)
- `shared-record` — open write access (all users with app access can write)

---

### 7. Programmatic App Updates — `POST /api/mgmt-update/{appId}`

Replace your app's HTML content without going through the admin approval queue again. Useful for apps whose data is generated externally (e.g., nightly reports).

Generate an API token from **My Apps → API Tokens** on the Launchpad, then use it in a script:

```bash
# Bash / curl
curl -X POST https://launchpad.realpage.com/api/mgmt-update/my-app-id \
  -H "X-Api-Token: lp_your64hextoken" \
  -H "Content-Type: application/json" \
  -d "{\"filename\": \"report.html\", \"content\": \"$(cat report.html | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))')\"}"
```

```python
# Python
import requests, pathlib

html = pathlib.Path('report.html').read_text()

r = requests.post(
    'https://launchpad.realpage.com/api/mgmt-update/my-app-id',
    headers={
        'X-Api-Token': 'lp_your64hextoken',
        'Content-Type': 'application/json'
    },
    json={'filename': 'report.html', 'content': html}
)
print(r.json())  # {"updated": "my-app-id", "title": "My Report"}
```

> **Important:** Use the `X-Api-Token` header, NOT `Authorization: Bearer`. Azure SWA strips the Authorization header before it reaches the function.

---

## Access Levels

| Level | Who sees it | Lifecycle | Use when |
|-------|------------|-----------|----------|
| `general` | All RealPage employees (after admin approval) | 90-day idle → archive | Internal tools, company-wide dashboards |
| `invite-only` | Only users/groups granted access | 90-day idle → archive | Sensitive data, team-specific tools, unreleased apps |

For apps handling HR data, financial data, confidential pricing, or any PII: **always use `invite-only`** and explicitly grant access per user or AD group via the gallery Share button.

---

## IP Allowlisting (Per App)

Apps handling highly sensitive data can be restricted to specific IP addresses at the server level — the HTML file is never sent to browsers connecting from disallowed IPs.

**Self-serve (app owners):** Go to **My Apps → Manage → IP Restriction** and add your IP addresses (e.g., corporate VPN or office gateway). Changes take effect immediately.

**Admin configuration:** Admins can set IP restrictions via the app Edit modal in the admin dashboard.

The IP check happens after Azure AD authentication, so only authenticated users from approved IPs can access the app. Users on an unapproved network see a styled 403 page — no app content is exposed. Client IP detection uses the `x-azure-clientip` header (most accurate) or leftmost `x-forwarded-for` entry as fallback.

---

## Recommended App Boilerplate

Use this as a starting point for any new Launchpad app:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>My App — RealPage</title>
<style>
/* All styles inline — no external stylesheets from unapproved domains */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #f4f6f9; color: #222; }
</style>
</head>
<body>

<div id="app">Loading…</div>

<script>
// Launchpad-injected globals (available when served through app-gate)
const APP_ID = window.__LAUNCHPAD_APP_ID__ || 'dev';
const USER   = (window.__LAUNCHPAD_USER__  || 'dev@realpage.com').toLowerCase();

// ── Helpers ───────────────────────────────────────────────────────────────

// Safe DOM text setter — never use innerHTML with untrusted data
function setText(selector, text) {
  const el = document.querySelector(selector);
  if (el) el.textContent = text;
}

// Create element with class and text
function el(tag, cls, text) {
  const e = document.createElement(tag);
  if (cls)  e.className   = cls;
  if (text) e.textContent = text;
  return e;
}

// Storage helpers
async function loadStorage(key, fallback = null) {
  const res = await fetch(`/api/app-storage/${APP_ID}/${key}`).catch(() => null);
  if (!res || !res.ok) return fallback;
  const d = await res.json().catch(() => null);
  return d?.value ?? fallback;
}

async function saveStorage(key, value) {
  return fetch(`/api/app-storage/${APP_ID}/${key}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ value })
  });
}

// Claude helper
async function askClaude(prompt, opts = {}) {
  const res = await fetch('/api/anthropic-proxy', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: opts.model || 'claude-haiku-4-5-20251001',
      max_tokens: opts.maxTokens || 1024,
      messages: [{ role: 'user', content: prompt }]
    })
  });
  if (res.status === 429) throw new Error('Rate limit reached — try again in an hour.');
  if (!res.ok) throw new Error(`Claude error: HTTP ${res.status}`);
  const d = await res.json();
  return d.content[0].text;
}

// ── App initialization ─────────────────────────────────────────────────────

async function init() {
  const app = document.getElementById('app');
  app.replaceChildren(); // clear loading state

  // Load persisted state for this user
  const state = await loadStorage('state', { /* default state */ });

  render(state);
}

function render(state) {
  const app = document.getElementById('app');
  // Build UI using DOM methods for safety
  const title = el('h1', 'title', `Welcome, ${USER.split('@')[0]}`);
  app.appendChild(title);
  // ... build the rest of your UI
}

init();
</script>
</body>
</html>
```

---

## Safe DOM Patterns

**Always use `textContent` for user-supplied or API-returned data. Only use `innerHTML` for trusted, static template strings.**

```js
// ✅ SAFE — textContent escapes all HTML
element.textContent = apiResponse.title;

// ✅ SAFE — static template with no user data interpolated
container.innerHTML = '<div class="card"><h3>Static Header</h3><p>Static body.</p></div>';

// ✅ SAFE — build DOM nodes programmatically
function createCard(title, desc) {
  const card = document.createElement('div');
  card.className = 'card';
  const h = document.createElement('h3');
  h.textContent = title;  // safe
  const p = document.createElement('p');
  p.textContent = desc;   // safe
  card.append(h, p);
  return card;
}

// ❌ UNSAFE — never do this with dynamic data
element.innerHTML = `<h3>${apiResponse.title}</h3>`;  // XSS if title contains <script>
element.innerHTML = userInput;                          // always unsafe
```

---

## Common Patterns

### AI chat with conversation history

```js
const history = [];

async function sendMessage(userText) {
  history.push({ role: 'user', content: userText });

  const res = await fetch('/api/anthropic-proxy', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: 'claude-haiku-4-5-20251001',
      max_tokens: 1024,
      messages: history.slice(-20) // last 20 turns keeps tokens manageable
    })
  });

  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data  = await res.json();
  const reply = data.content[0].text;
  history.push({ role: 'assistant', content: reply });
  return reply;
}
```

### Admin-gated feature (using /.auth/me roles)

```js
async function init() {
  const me = await fetch('/.auth/me').then(r => r.json());
  const roles = me.clientPrincipal?.userRoles || [];

  if (roles.includes('admin')) {
    document.getElementById('admin-panel').style.display = 'block';
  }
}
```

### Shared config editable by admins, read by all

```js
let configEtag = null;

async function loadConfig() {
  const res = await fetch(`/api/shared-record/${APP_ID}/config`);
  if (res.status === 404) return {};
  configEtag = res.headers.get('ETag');
  return (await res.json()).value ?? {};
}

async function saveConfig(config) {
  const headers = { 'Content-Type': 'application/json' };
  if (configEtag) headers['If-Match'] = configEtag;

  const res = await fetch(`/api/shared-record/${APP_ID}/config`, {
    method: 'PUT',
    headers,
    body: JSON.stringify({ value: config })
  });

  if (res.status === 409) { alert('Config changed by someone else. Reloading…'); location.reload(); return; }
  if (!res.ok) throw new Error(`Save failed: ${res.status}`);
  configEtag = res.headers.get('ETag');
}
```

---

## Platform Limits Reference

| Item | Limit |
|------|-------|
| App HTML file size | No hard limit; ~2 MB practical max |
| Shared record value | 32 KB per record |
| App storage value | 32 KB per key |
| AI requests | 20 / hour per user (both proxies share this pool) |
| AI max tokens per response | 4096 (hard cap) |
| External fetch calls | 0 — all blocked by CSP |
| Approved CDN scripts | cdn.jsdelivr.net, cdnjs.cloudflare.com, unpkg.com, cdn.tailwindcss.com, ajax.googleapis.com, code.jquery.com, stackpath/maxcdn.bootstrapcdn.com |
| recordId length | 50 chars max |
| app-storage key length | 64 chars max |
| API token prefix | `lp_` + 64 hex chars |

---

## App Lifecycle

| State | Trigger | Action |
|-------|---------|--------|
| Active | App accessed within 30 days | Normal |
| Stale | 30 days since last access | Yellow badge in admin panel |
| Archived | 90 days since last access | App hidden; owner emailed |
| Deleted | 30 days since archival | Permanently removed |

Owners can restore archived apps from **My Apps → Restore** before the 30-day deletion window closes.

---

## Upload Checklist

Before uploading your app:

1. **Security scan passes** — no secrets, no external fetch, no `eval()`, no unsafe `innerHTML` with dynamic data
2. **CSP compliance** — tested in browser with DevTools open; no CSP errors in Console
3. **Works without `__LAUNCHPAD_APP_ID__`** — graceful fallback for local dev
4. **No console errors** — clean runtime, no unhandled promise rejections
5. **Mobile-friendly** — test at 375px width (many users on laptops with split screens)
6. **Access level chosen** — general for all-staff tools, invite-only for anything sensitive

Upload at: `https://launchpad.realpage.com/upload.html`
