# Phuze Integration Skill

Phuze adds passwordless email login to any website — one script tag, three lines of code.

Base URL: `https://phuze.edato.me/api/v1`
Widget: `https://phuze.edato.me/phuze.js`
Human intro: `https://phuze.edato.me/intro`
Human docs: `https://phuze.edato.me/docs`

> **Starting fresh (no keys yet)?** Complete setup first → [phuze-setup.md](https://phuze.edato.me/phuze-setup.md)
> **Already have `publishable_key` and `secret_key`?** Continue below.

---

## Widget Integration

`localhost` and `127.0.0.1` are always allowed — no domain registration needed for local dev.

### Minimal HTML

The widget is **self-contained**. When mounted it renders the email form (logged-out) or an email display + "Sign out" button (logged-in) automatically. Do NOT build a separate logged-in/logged-out panel around it — the widget manages that itself.

```html
<!-- Keep this element always present in the DOM -->
<div id="phuze-widget"></div>

<script src="https://phuze.edato.me/phuze.js"></script>
<script>
  Phuze.init({ publishableKey: 'pk_live_...' })
  Phuze.mount('#phuze-widget')

  // Optional: update YOUR OWN app UI (not the widget — it updates itself)
  Phuze.onSessionChange(function(session) {
    // session is PhuzeSession when logged in, null when logged out
    // use this to show/hide content in your app, not to rebuild auth UI
  })
</script>
```

### React (Next.js)

The widget manages its own logged-in/logged-out UI — do not conditionally render it or build a parallel auth panel. Use `onSessionChange` only to update state in your own app (e.g., showing a welcome message, loading user data).

```tsx
'use client'
import Script from 'next/script'
import { useState, useCallback } from 'react'

export default function Auth() {
  const [session, setSession] = useState<PhuzeSession | null | undefined>(undefined)

  const handleReady = useCallback(() => {
    Phuze.init({ publishableKey: process.env.NEXT_PUBLIC_PHUZE_PUBLISHABLE_KEY! })
    Phuze.mount('#phuze-widget')
    Phuze.onSessionChange(setSession)
  }, [])

  return (
    <>
      <Script src="https://phuze.edato.me/phuze.js" strategy="afterInteractive" onReady={handleReady} />

      {/* Always in DOM — never conditionally render this element */}
      <div id="phuze-widget" />

      {/* Your app UI that responds to session state */}
      {session && <p>Welcome, {session.email}!</p>}
    </>
  )
}
```

### Widget Customization

Pass any of the following to `Phuze.init()`:

```js
Phuze.init({
  publishableKey: 'pk_live_...',

  // Behaviour
  redirectUrl: 'https://myapp.com/dashboard', // where to send the user after clicking the sign-in link
                                               // must be on an authorized domain; defaults to current page URL
  locale: 'zh-TW',                            // locale for sign-in email + verify page; defaults to browser detection
  locales: ['en', 'zh-TW'],                   // restrict allowed locales; auto-detection picks best match from this list

  // Text
  text: {
    button: 'Log in',
    buttonLoading: 'Sending…',
    buttonSignOut: 'Log out',
    placeholder: 'you@company.com',
    sentTitle: 'Check your inbox.',
    sentSubtitle: 'Click the link to sign in — this tab will update automatically.',
  },

  // Theme
  theme: {
    accentColor: '#0f172a',
    accentHoverColor: '#1e293b',
    accentTextColor: '#ffffff',
    borderRadius: '4px',
    borderColor: '#cbd5e1',
  },
})
```

All `phuze-*` CSS class names can also be overridden via stylesheet for anything not covered by `theme`.

### DOM Binding (session-driven UI without render code)

Applied automatically at `Phuze.mount()`, kept in sync on every session change.

**Body state classes** — exactly one of `phuze-checking` / `phuze-active` / `phuze-anonymous` on `<body>` at all times after mount. Drive auth-dependent UI with pure CSS (e.g. `.phuze-anonymous .dashboard-link { display: none; }`). **FOUC rule: hardcode `<body class="phuze-checking">` in the HTML** — the script loads async, and without the hardcoded class auth-gated elements flash before mount. The widget adopts a pre-existing class (never duplicates). If the `<body>` tag can't be edited, add a synchronous `<head>` one-liner that adds the class on `DOMContentLoaded`.

**Data attributes** (session fields available: `uid`, `email`, `expires_at` — nothing else interpolates):

```html
<a data-phuze-show="active" href="/dashboard">Dashboard</a>   <!-- display:none unless state matches; original display restored -->
<span data-phuze-text="email">there</span>                     <!-- textContent = field while logged in; original content auto-saved to data-phuze-text-default and restored on logout -->
<a data-phuze-attr="href:/account?user={uid}" href="/login">Account</a> <!-- attr = template while logged in; original restored on logout; href/src interpolation is URL-encoded -->
```

Bindings only ever use `textContent`/`setAttribute` — no HTML injection path.

**Programmatic (Tier 3):**

```js
const unsub = Phuze.subscribe(fn)  // fn(session) on every change; returns unsubscribe — call it in React useEffect cleanup / Vue onUnmounted
Phuze.session                      // synchronous: undefined = still checking, null = logged out, session object = logged in
```

**Opt-outs** via `Phuze.init({ bind: { ... } })`: `bodyClass: false` (no state classes) or a CSS selector string (apply classes to that element instead of `<body>`); `domAttributes: false` (no `data-phuze-*` scanning).

---

## Custom / Headless Login (no widget)

Only if you're building a **fully custom login UI** (not using the widget). You drive the backend directly, replicating what the widget does internally.

**⚠ Security invariants you MUST keep** (the widget enforces these — a custom flow that skips them regresses fixed issues): generate `pending_id` with **`crypto.randomUUID()`, never `Math.random()`** (it's the guessable key to an in-flight session); all calls need a registered **`Origin`** + HTTPS; pickup is one-time within a ~5-min window; treat `session_token` like a session cookie.

```js
// 1. Cryptographically random handoff id (NOT Math.random)
const pendingId = crypto.randomUUID()

// 2. Request the magic link
await fetch('https://phuze.edato.me/api/v1/auth/magic-link', {
  method: 'POST',
  headers: { 'Authorization': 'Publishable pk_live_...', 'Content-Type': 'application/json' },
  body: JSON.stringify({ email, pending_id: pendingId }),  // pending_id is REQUIRED for the handoff
})

// 3. Poll pickup ~every 2s (up to ~10 min) until the user clicks the emailed link
const r = await fetch('https://phuze.edato.me/api/v1/sessions/pickup?pending_id=' + encodeURIComponent(pendingId),
  { headers: { 'Authorization': 'Publishable pk_live_...' } })
// { ready: false } → keep polling; { ready: true, session_token, uid, email, expires_at } → done

// 4. Store data.session_token (like a cookie); 5. verify it on your server (POST /sessions/verify, secret key)
```

The token comes from **pickup**, not the email link (the widget/backend never expose the raw magic token to JS). `GET /sessions/current` (`X-Session-Token` header) re-checks a stored token; `POST /sessions/logout` (`X-Session-Token`) revokes it. Without `pending_id` there is no way to receive the session back into your tab.

---

## Server-Side Session Verification

The widget stores the session token in `localStorage` under the key `phuze_session`. `PhuzeSession` (from `getSession()` / `onSessionChange`) does **not** contain the raw token — read it from localStorage directly.

**Client — read the token and send it to your server:**

```js
const token = localStorage.getItem('phuze_session')
const res = await fetch('/api/your-protected-endpoint', {
  headers: { 'Authorization': 'Bearer ' + token }
})
```

**Server — verify the token with Phuze:**

```js
const token = req.headers.authorization?.replace('Bearer ', '')
const res = await fetch('https://phuze.edato.me/api/v1/sessions/verify', {
  method: 'POST',
  headers: {
    'Authorization': 'Secret sk_live_...',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ token }),
})
const { valid, uid, email } = await res.json()
if (!valid) { /* reject the request */ }
```

The full valid response is `{ valid, uid, email, session_id, client_id, created_at, expires_at, type }` — `email` may be absent in rare cases, `type` is always `"user"` today. **Never validate this response with a closed/strict schema** (zod `.strict()`, Pydantic `extra="forbid"`, `additionalProperties: false`): destructure or pick the fields you need, as above.

The secret key is server-only. Never put it in frontend code or public env vars.

---

## Listing Your Users

`GET /api/v1/clients/me/users` (CLI or Secret) lists everyone who has signed in to your client — Phuze has no separate signup, so users appear after first login. Paginated (`?limit=1..25&cursor=<uid>`; follow `next_cursor` until null).

```bash
curl https://phuze.edato.me/api/v1/clients/me/users \
  -H "Authorization: CLI cli_..."
# → { "users": [ { "uid", "email", "first_seen_at", "last_active_at", "login_count",
#                  "mau_current", "tags": [ { "tag", "assigned_at", "issued_by" } ] } ],
#     "next_cursor": null, "mau": { "count", "limit" } }
```

`last_active_at` = the user's last **login** (the signal behind billing MAU / `mau_current`), not a per-request last-seen. `tags` are your own client-scoped tags. The response is client-scoped — it never reveals a user's other Phuze clients. Don't strict-validate.

---

## Quotas, Plans & Overage

Daily login quotas are per-plan (a "login" = one accepted magic-link send).
**Do not hardcode quota numbers** — read the live values:
`GET /api/v1/clients/me/quota` returns your client's actual effective limits;
human-readable current tiers/prices/overage rate are at
https://phuze.edato.me/pricing (rendered from the same config). Paid plans do
**not** stop at their quota: extra logins are accepted and billed as overage
at the published per-1,000 rate (invoiced by email during alpha), with a hard
cap above the quota where sends stop (429 `quota_exceeded`). Free stops at
its quota. MAU is **not** a plan limit — only a high flat abuse guardrail;
you are never billed for registered or returning users.

`GET /api/v1/clients/me/quota` (CLI or Secret) reports:
`plan`, `limits: { mau, logins_per_day, logins_hard_cap_per_day }`,
`current_period: { start, mau_count, daily_login_count, daily_reset_at,
overage_count }`, and `test_accounts`. `overage_count` is this month's
accepted-over-quota logins. Don't strict-validate.

**Sender name (Builder+):** set a display name for your users' sign-in emails
(`"Your brand" <noreply@phuze.edato.me>`) via `PATCH /api/v1/clients/me`
`{ "from_name": "Your brand" }` (CLI; 1–40 chars, Unicode fine, `<>"@` and
control characters stripped; `null` clears) or the dashboard. Settable on any
plan; takes effect on Builder and above.

---

## Feedback & Feature Requests

`POST /api/v1/feedback` (CLI or Secret): `{ "topic": "custom_domains" |
"other", "message": "..." }` — `message` (≤2000 chars) is required for
`other`, optional for `custom_domains`. Use `custom_domains` to register
interest in sending from your own domain (`auth.yourdomain.com`) — enough
interest is what schedules that feature. Also available as a dashboard card.
Lightly rate-limited (3/min per client). Returns `201`.

---

## Rate Limits & Test Accounts

Magic-link sends are throttled per target address: **1/min · 5/hr** by default. During development you WILL hit this on the second re-send to your own test address. Do not retry-loop; declare the address as a **test account** instead — it then gets **10/min · 60/hr** for 14 days.

```bash
# CLI token required (the secret key is rejected on this endpoint by design)
curl -X POST https://phuze.edato.me/api/v1/clients/me/test-accounts \
  -H "Authorization: CLI cli_..." \
  -H "Content-Type: application/json" \
  -d '{"email": "dev@yourapp.com"}'
# → { "email": "...", "expires_at": "...", "refreshed": false, "test_accounts": { "limit": 1, "used": 1 } }
```

Re-POST the same address to refresh its 14-day clock. Remove with `DELETE /clients/me/test-accounts/<url-encoded-email>`. Plan quota: free 1 · builder 5. Adding an address notifies the account owner by email.

Every `429 rate_limited` from `/auth/magic-link` carries `retry_after_seconds` (body + `Retry-After` header) and an optional `reason` to branch on:

| `reason` | Handle it by |
|---|---|
| `test_account_limit` | backing off `retry_after_seconds` (you hit the 10/min · 60/hr preset) |
| `test_account_expired` | re-POSTing the address to `/clients/me/test-accounts` (response includes `expires_at`), then retrying |
| `not_test_account` | declaring the address as a test account, or accepting default limits |
| *(absent)* | backing off `retry_after_seconds` |

Never parse the `message` text — branch on `reason`. Never validate 429 bodies with a closed/strict schema.

---

## Tags

Tags are string labels you attach to your own end-users — membership tiers, roles, feature flags, cohorts. Phuze only stores and serves them; your app owns the gating logic. Tags are scoped to your client — you only ever see tags you assigned, never another client's.

```js
// Assign a tag
await fetch('https://phuze.edato.me/api/v1/users/' + uid + '/tags', {
  method: 'POST',
  headers: { 'Authorization': 'Secret sk_live_...', 'Content-Type': 'application/json' },
  body: JSON.stringify({ tag: 'premium' }),
})
// → { "tag": "premium", "uid": "u_..." }  — idempotent, re-assigning is a no-op

// List a user's tags
const res = await fetch('https://phuze.edato.me/api/v1/users/' + uid + '/tags', {
  headers: { 'Authorization': 'Secret sk_live_...' },
})
const { tags } = await res.json() // → { "uid": "u_...", "tags": ["beta-tester", "premium"] }

// Remove a tag
await fetch('https://phuze.edato.me/api/v1/users/' + uid + '/tags/premium', {
  method: 'DELETE',
  headers: { 'Authorization': 'Secret sk_live_...' },
})
// → 204, whether or not the tag was present (idempotent)
```

Naming rule: `[a-z0-9-]{1,64}` (lowercase letters, digits, hyphens). Up to 64 tags per user. `404` if the uid was never served by your client.

---

## TypeScript Types

```ts
interface PhuzeSession {
  active: true
  uid: string
  email?: string
  expires_at: string  // ISO 8601
}

declare global {
  interface Window {
    Phuze: {
      init(opts: {
        publishableKey: string
        apiBase?: string
        redirectUrl?: string   // post-sign-in redirect; defaults to current page URL
        locale?: string        // e.g. 'en', 'zh-TW'; defaults to browser detection
        locales?: string[]     // allowed locales; auto-detection picks best match
        text?: {
          button?: string
          buttonLoading?: string
          buttonSignOut?: string
          placeholder?: string
          sentTitle?: string
          sentSubtitle?: string
        }
        theme?: {
          accentColor?: string
          accentHoverColor?: string
          accentTextColor?: string
          borderRadius?: string
          borderColor?: string
        }
      }): void
      mount(selector: string): void
      getSession(): Promise<PhuzeSession | null>
      logout(): Promise<{ logged_out: boolean }>
      onSessionChange(fn: (session: PhuzeSession | null) => void): void
    }
  }
}

// Client info returned by GET /clients/me
interface PhuzeClient {
  client_id: string
  name: string
  email: string
  authorized_domains: string[]
  publishable_key: string
  status: 'pending' | 'active' | 'suspended'
  type: 'general' | 'trusted'
  plan: 'free'
}
```

---

## Common Pitfalls

| Pitfall | Fix |
|---|---|
| Using placeholder keys (`pk_live_...` literally) | Complete setup first — [phuze-setup.md](https://phuze.edato.me/phuze-setup.md) |
| Building a separate logged-in panel that hides/shows the widget | Don't — the widget shows email + "Sign out" button when logged in. Use `onSessionChange` only to update your own app UI (nav, content, etc.), not to rebuild auth UI around the widget |
| `mount()` renders nothing | The target element must exist in the DOM when `mount()` is called — keep it always present, hide/show with CSS if needed |
| Widget shows blank briefly on load | Expected — widget starts in `checking` state while it calls `/sessions/current`. Resolves within one network round trip |
| `403 forbidden` from API | The request origin is not in your authorized domains — add it via `phuze domains add <domain>` or use localhost for local dev |
| `429 rate_limited` re-sending magic links while developing | Don't retry-loop — declare your test address: `POST /clients/me/test-accounts` (CLI token). See "Rate Limits & Test Accounts" |
| Session not received after email click | Normal — the widget polls every 2s and updates the original tab automatically within 2 seconds of the link being clicked |

---

## End-User Account Deletion (Trusted Clients Only)

End-user deletion is gated by client type. Only `trusted` clients can call `POST /users/me/delete-request`. General clients receive `403 forbidden`.

```js
fetch('https://phuze.edato.me/api/v1/users/me/delete-request', {
  method: 'POST',
  headers: {
    'Authorization': 'Publishable pk_live_...',
    'X-Session-Token': sessionToken,
    'Origin': window.location.origin,
  },
}).then(r => r.json()).then(res => {
  // res.message = "Check your email to confirm account deletion."
})
```

The user receives a confirmation email. Clicking it permanently deletes their account and revokes all sessions.

---

## Client Account Deletion (Dangerous)

Deleting a client is **permanent and irreversible** — all sessions, CLI tokens, quota records, and the client itself are deleted.

```bash
# Step 1 — request a deletion code (sent to your registered email)
curl -X POST https://phuze.edato.me/api/v1/clients/me/delete-request \
  -H "Content-Type: application/json" \
  -d '{"email":"owner@example.com"}'

# Step 2 — confirm with the 6-digit code from your inbox
curl -X POST https://phuze.edato.me/api/v1/clients/me/delete-confirm \
  -H "Content-Type: application/json" \
  -d '{"email":"owner@example.com","code":"123456"}'
# → { "deleted": true, "client_id": "cl_..." }
```

---

→ **Need to register a new client?** [phuze-setup.md](https://phuze.edato.me/phuze-setup.md)
