# App API - User Authentication

When a function has `auth_level: "user"` or an LLM service uses `user_pays` billing, the app authenticates Gipity users via session cookies. (Players of a native game who sign in without a Gipity account send a player token instead; see "App players" below.)

**The `auth_level` gate runs at the platform, before your handler.** An `auth_level: "user"` (or `"member"`) function called without a signed-in user is rejected with `401 LOGIN_REQUIRED` *before your code executes* - `ctx.auth` is only ever populated inside the handler, so a signed-out caller never reaches it. Two consequences: (1) don't write a `if (!ctx.auth?.userGuid) return { error: 'Sign in required' }` guard for the unauthenticated case - it's dead code the platform already handles (inside the handler you can assume `ctx.auth.userGuid` is set). (2) A test that calls the function logged-out must assert the **rejection** (`ctx.fn.call` throws / the call 401s), not a returned error string - use `ctx.fn.callAs(userGuid, ...)` to exercise the signed-in path. Reserve in-handler checks for finer authorization (e.g. "is this row mine?"), which the gate can't express.

**Personal-data apps need auth even when nothing is paid.** If the request implies user-private data - "my receipts", a personal vault, private notes, anything storing a user's own uploads or records - default to per-user scoping: gate writes behind sign-in and key every row to `ctx.auth.userGuid` (the stable external id - not the internal numeric `userId`; see "Using auth" in `app-development`), then filter listings to that user. A "my X" app that's readable and deletable by anyone with the URL is a privacy hole. If you intentionally ship a public or shared version instead, say so explicitly in your summary so the user can decide (see "Personal data defaults to per-user scoping" in `web-app-basics`).

## How It Works
Users logged into Gipity already have a session cookie on `.gipity.ai`. Your app needs two things:
1. An **app token** (identifies your app to the API)
2. `credentials: 'include'` on all fetch calls (sends the user's session cookie cross-origin)

## Step 1: Get an App Token
`POST https://a.gipity.ai/api/token` with your project GUID.

```js
const res = await fetch('https://a.gipity.ai/api/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ app: '<PROJECT_GUID>' })
});
const json = await res.json();
const appToken = json.data.token;  // IMPORTANT: .data.token, NOT .token
```

**Response shape:**
```json
{ "data": { "token": "eyJ...", "expiresIn": 900 } }
```

The token is short-lived (15 minutes). Cache it and fetch a new one when it expires.

## Step 2: Check Auth State (Recommended)
`GET /api/<PROJECT_GUID>/auth/status` - returns auth state + URLs in one call.

Add `X-App-Token` header and `credentials: 'include'`.

**Response shapes:**
```jsonc
// Not logged in
{ "authenticated": false, "consented": false, "user": null, "loginUrl": "..." }

// Logged in, no consent
{ "authenticated": true, "consented": false, "user": null, "consentUrl": "..." }

// Fully authed
{ "authenticated": true, "consented": true, "user": { "guid", "displayName", "avatarUrl", "accountSlug" } }
```

Optional query param: `?permissions=N` (default `1` = IDENTITY). Use `5` for IDENTITY + AI.

## Lightweight Check
`GET /api/auth/me` (with `credentials: 'include'`) returns `{ user: { guid, displayName, avatarUrl, accountSlug } }` or `{ user: null }`. No app context - cannot return login/consent URLs.

## App players: sign-in without a Gipity account

Native games and other apps whose users shouldn't need a Gipity account can let players sign in to **this app only**: with Steam, or as a guest tied to a device. Turn it on with `gipity project auth app` (or `both` to keep Sign in with Gipity too). Players then call `POST /api/<PROJECT_GUID>/auth/steam` or `/auth/guest` and send the returned token as `Authorization: Bearer`. To functions they are ordinary signed-in users (`ctx.auth.userGuid`), plus `ctx.auth.identity` (`{ provider: 'steam', id }`). Full guide: the [steam-game](steam-game.md) skill.

## API keys for scripts and agents

Sessions are for people. A **script, cron job, or agent** authenticates with a **project API key** instead: a named, revocable secret the platform mints and hashes for you. Do **not** build an `api_keys` table, token minting, a masked preview, or a revoke endpoint in the app - it already exists.

```bash
# mint (returns the full key exactly once), list, revoke - Bearer = your CLI token (gipity token create)
curl -sX POST https://a.gipity.ai/projects/<PROJECT_GUID>/api-keys -H "Authorization: Bearer $GIPITY_TOKEN" \
  -H 'Content-Type: application/json' -d '{"name":"laptop-script","role":"editor"}'
curl -s  https://a.gipity.ai/projects/<PROJECT_GUID>/api-keys   -H "Authorization: Bearer $GIPITY_TOKEN"
curl -sX DELETE https://a.gipity.ai/projects/<PROJECT_GUID>/api-keys/<ak_guid> -H "Authorization: Bearer $GIPITY_TOKEN"
```

The script then calls any `auth: user` / `auth: member` function with the key in a **header** - never in the body:

```bash
curl -sX POST https://a.gipity.ai/api/<PROJECT_GUID>/fn/add-entry \
  -H "X-Api-Key: $MY_KEY" -H 'Content-Type: application/json' -d '{"title":"Dune"}'
```

Inside the handler, `ctx.auth` says who called:

| `ctx.auth.via` | who | `ctx.auth.apiKeyName` |
|---|---|---|
| `'user'` | signed-in person (session or app token) | `null` |
| `'api_key'` | script/agent holding a project API key | the key's name, e.g. `'laptop-script'` |
| `'anonymous'` | no credentials (`auth: public` only) | `null` |

An `api_key` call gets a **complete `ctx.auth`**, identical in shape to a signed-in caller: `ctx.auth.userId`, `userGuid`, `displayName`, and `role` are all populated, resolving to the key's creator (which is the project owner). So a script writing with a key lands in the **same `ctx.auth.userGuid` scope** as the owner adding rows by hand - key your rows to `ctx.auth.userGuid` exactly as for a signed-in user and the two paths share one per-user log automatically. No need to probe empirically: `via`/`apiKeyName` are the only fields that differ from a session call.

So provenance ("did I type this, or did my script add it?") is one stored column: `ctx.auth.via === 'api_key' ? ctx.auth.apiKeyName : 'me'`. The name is a snapshot - it stays readable on old rows after the key is revoked. The key itself never reaches your code (the platform consumes and strips the header), so it can't leak into logs.

## Error Codes (Function Calls)
The function API also returns auth-related error codes with redirect URLs:
- **LOGIN_REQUIRED** (401): User not logged into Gipity. Redirect to `error.loginUrl`.
- **CONSENT_REQUIRED** (403): User is logged in but hasn't granted permission. Redirect to `error.consentUrl`.

Append `&return=<app_url>` to redirect URLs so users return to the app after auth. The return URL must be on a Gipity-hosted domain (`app.gipity.ai`, `dev.gipity.ai`, `gipity.ai`).

## Customizing the login page (auth_branding)
The login and consent pages are **server-rendered by Gipity** - your app only sends the user there - so you can't style them with your own CSS. By default they show your project name, Gipity orange, and the Gipity wordmark. To match your app, add an `auth_branding:` block to `gipity.yaml` and redeploy; `gipity deploy` syncs it to your project:

```yaml
auth_branding:
  app_name: Lilyboxd
  primary_color: "#fea60b"      # quote it - a bare # is a YAML comment
  logo_url: https://media.gipity.ai/.../logo.png   # https; replaces the wordmark
  tagline: Your film diary
```

**Scope:** `auth_branding` styles only the Sign in with Gipity pages - it does **not** theme your app's own UI (that's your own CSS). All fields optional; omitted ones use Gipity defaults. Full field rules and validation are in [deploy](deploy.md) ("auth_branding"). It's stored on the project (not passed in the login URL) **on purpose**: the login page is a trust surface where the user decides whether to grant access, so its identity must come from the registered project, not from request params an attacker could spoof.

## Client Code - use the SDK (do this)
Load the Gipity client SDK and let it handle sign-in. **Do not hand-roll the popup, polling, or COOP workarounds** - `Gipity.auth.signIn()` opens the popup synchronously on click and drives login + consent for you. Hand-rolling it is the #1 source of broken auth.

```html
<script crossorigin="anonymous" src="https://media.gipity.ai/client/v1/gipity.js"></script>
<gipity-signin app-guid="<PROJECT_GUID>" permissions="1"></gipity-signin>
```

Or wire your own button:

```js
Gipity.auth.configure({ appGuid: '<PROJECT_GUID>' });   // permissions defaults to 1 (identity only)
signinBtn.addEventListener('click', async () => {
  const user = await Gipity.auth.signIn(); // opens popup, handles login + consent
  if (user) showApp(user); else showSignedOut();
});
// On load, reflect any existing session (null if not signed in / not consented):
const me = await Gipity.auth.user();
if (me) showApp(me);
```

**Consent scopes (`permissions`) - set this to 5 for any app that calls paid AI on the user's credits.** `permissions` is a bitmask of the scopes the sign-in/consent popup asks the user to grant; it defaults to `1` (identity only). If your app calls an AI service under **`user_pays`** billing (`Gipity.service('llm' | 'image' | 'tts' | ...)` charging the signed-in user), the user must also consent to the AI scope - pass `permissions: 5` (or `permissions="5"` on `<gipity-signin>`) so `signIn()` requests it up front. Without it the first AI call 403s with `CONSENT_REQUIRED` and the flow can't self-recover: a service call can't open a consent popup (browsers only allow that inside the sign-in click). The bits, OR'd together: `IDENTITY = 1` (display name / avatar / account slug / user guid - always needed) and `AI = 4` (call paid AI services on the user's credits), so `1 | 4 = 5`. (Higher bits - 8/16/32/64 - cover this app's agent and workflows; identity-only apps stay at `1`.)

**Account scope (`128`) - only for an app you own, like Monitor.** `ACCOUNT = 128` lets the app act as the signed-in user's whole Gipity account on the platform API (the same routes the CLI calls: `/account/logs/*`, `/projects/*`, `/workflows/*`, `/secrets`, `/users/me`, ...). It is **owner-only**: the consent screen refuses it for anyone who doesn't own the app, and every request is re-checked, so it only ever works when the project owner is the one signed in. Request it with `permissions=129` (identity + account), then send the app token as `X-App-Token` with `credentials: 'include'` on each platform API call. Use it for your own dashboards and admin tools, never for an app other people sign into.

Once signed in, `Gipity.fn(name, body)` (call a function), `Gipity.service(path, body)` (call an app service like `'llm'` / `'image'` - see [app-llm](app-llm.md)), and `Gipity.upload(file)` all auto-attach the token and session cookie for you - never hand-roll `fetch('/api/token')` + `X-App-Token`. Each throws on non-2xx with the server message and `err.status`/`err.code` (branch `402`/`401`). Call `signIn()` **directly in the click handler** - don't `await` anything before it or the browser blocks the popup.

**Reacting to auth changes from elsewhere in the app - `Gipity.auth.onChange`.** The simplest path is to re-render straight from the return values above (`signIn()` / `signOut()` resolve to the user-or-null; `user()` gives the initial state on load). When code that *didn't* trigger the change also needs to react - a header avatar, a sidebar, gated sections - subscribe once:

```js
const off = Gipity.auth.onChange((user) => {
  if (user) showApp(user); else showSignedOut();
});
// off();  // unsubscribe when you no longer care
```

The callback fires with the new user (or `null`) **only on a real transition** - signed-out → user, user → signed-out, or user → different user - at the three points auth actually moves: on load via `await Gipity.auth.user()`, and on `signIn()` / `signOut()` (including the `<gipity-signin>` button). It does **not** fire on subscribe (call `user()` yourself for the initial paint) and there is **no** background session-expiry signal - the SDK never polls, so nothing fires if the session is lost server-side without a `signIn`/`signOut` call in this tab. Don't write a polling loop waiting for one. There is also no `gipity:auth` DOM event on `document`/`window`; `onChange` is the subscription API. (The `<gipity-signin>` web component additionally emits a bubbling `gipity-signin` `CustomEvent` on the element itself, for that component's own button only.)

### Verifying sign-in behind the login wall (`--auth`)
Pass `--auth` to `gipity page inspect`/`screenshot`/`eval` to load the page **signed in as you** (your Gipity account, which for your own app auto-consents), so UI and data behind a Sign-in-with-Gipity gate are reachable headlessly:

```bash
gipity page inspect "https://dev.gipity.ai/<acct>/<app>/" --auth          # inspect the signed-in page
gipity page screenshot "https://dev.gipity.ai/<acct>/<app>/" --auth        # capture the members-only UI
gipity page eval "https://dev.gipity.ai/<acct>/<app>/" --auth "App.commands().length"   # drive signed-in app code
```

How it works: the server mints a short-lived session for you and injects the `gipity_sid` cookie into the headless browser before it navigates - the same session a normal login produces, so your app needs no changes. You can only ever browse as **yourself**.

**Without `--auth` the page loads as a genuinely anonymous, signed-out visitor** - the browser's cookie jar is wiped before every navigation, so nothing carries over from an earlier `--auth` run. Each command's identity is exactly its flags, which is what makes the two-state check on an app with a signed-in/signed-out split cheap and safe: run once with `--auth` (the signed-in path) and once without (the anonymous path - the gate, the nudge, the public view). Every run prints an `Auth:` line naming which identity the page ran as. No `Gipity.auth.signOut()` workaround inside the eval body, and no risk of an "anonymous" check silently writing rows as the owner.

Scope: `--auth` covers apps that use **Sign in with Gipity** and are hosted on `*.gipity.ai` (dev/app). It does **not** cover apps with their own custom login, nor (for a page that reads a same-host cookie) custom domains. For those, verify the wiring instead: the gate renders, the button is wired, `window.Gipity.auth` exists, and `Gipity.auth.status()` is reachable - not the full popup; final confirmation needs a real browser with a signed-in user. Without `--auth`, the sandbox is headless with no logged-in user, so a signed-in round-trip can't complete there.

### Signing out
Wire your sign-out button to `await Gipity.auth.signOut()` - it ends the Gipity session **server-side** (clears the session cookie) and locally, so the user stays signed out across reloads. Then refresh your UI / navigate home.

```js
signoutBtn.addEventListener('click', async () => {
  await Gipity.auth.signOut();
  showSignedOut();          // re-render header + view for the signed-out state
});
```

Don't expect a one-liner local flag flip to log anyone out: the session lives in a shared `.gipity.ai` cookie, so anything that only clears in-memory state leaves the cookie live and `Gipity.auth.user()` re-detects the session on the next load - the user "comes back" signed in. `signOut()` is the only call you need; `signOutEverywhere()` is a deprecated alias for it.

## Client Code - Popup Flow (low-level - prefer the SDK above)
Shown only to explain what happens under the hood; new apps should not hand-roll it.
```js
async function checkAuth(appGuid, appToken) {
  const res = await fetch(\`https://a.gipity.ai/api/\${appGuid}/auth/status\`, {
    credentials: 'include',
    headers: { 'X-App-Token': appToken }
  });
  const data = await res.json();

  if (data.authenticated && data.consented) {
    return data.user; // Already authed
  }

  // Open login or consent popup
  const authUrl = (data.loginUrl || data.consentUrl) + '&mode=popup';
  const popup = window.open(authUrl, 'gipity_auth', 'width=450,height=600');
  return new Promise(resolve => {
    window.addEventListener('message', function handler(e) {
      if (e.data?.type === 'gipity_auth') {
        window.removeEventListener('message', handler);
        if (e.data.status === 'success') {
          // Re-check to get user profile
          checkAuth(appGuid, appToken).then(resolve);
        } else {
          resolve(null); // User denied
        }
      }
    });
  });
}
```

## Client Code - Redirect Flow
Navigates away from the app. Use `&return=<app_url>` so users come back after auth.
```js
const res = await fetch(\`https://a.gipity.ai/api/\${appGuid}/auth/status\`, {
  credentials: 'include',
  headers: { 'X-App-Token': token }
});
const data = await res.json();
if (!data.authenticated) {
  location.href = data.loginUrl + '&return=' + encodeURIComponent(location.href);
} else if (!data.consented) {
  location.href = data.consentUrl + '&return=' + encodeURIComponent(location.href);
}
```

## Complete Example (low-level REST - the SDK is simpler)
This raw-REST flow is reference only; for real apps prefer the SDK section above. Note it does **not** include the popup gesture/COOP handling the SDK provides.
```js
const API = 'https://a.gipity.ai';
const APP_GUID = '<PROJECT_GUID>';

// Step 1: Get app token
const tokenRes = await fetch(\`\${API}/api/token\`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ app: APP_GUID })
});
const tokenData = await tokenRes.json();
const appToken = tokenData.data.token;  // NOTE: .data.token, not .token

// Step 2: Check auth status
const authRes = await fetch(\`\${API}/api/\${APP_GUID}/auth/status\`, {
  credentials: 'include',
  headers: { 'X-App-Token': appToken }
});
const auth = await authRes.json();

if (!auth.authenticated) {
  // Open login popup
  const popup = window.open(auth.loginUrl + '&mode=popup', 'gipity_auth', 'width=450,height=600');
} else if (!auth.consented) {
  // Open consent popup
  const popup = window.open(auth.consentUrl + '&mode=popup', 'gipity_auth', 'width=450,height=600');
} else {
  // Step 3: Call an authenticated function
  const res = await fetch(\`\${API}/api/\${APP_GUID}/fn/my_function\`, {
    method: 'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'application/json', 'X-App-Token': appToken },
    body: JSON.stringify({ param1: 'value' })
  });
  const result = await res.json();
}
```

## Never let a signed-out user lose work at an auth wall (UX)
The one rule: when an action requires sign-in (`auth_level: "user"`, `user_pays`, or "save your own X"), **a signed-out user must never type work into a form and then lose it at Save.** That dead-end - fill the form, click Save, get blocked, work gone - is the actual defect. *When* you ask them to sign in is a product call with two legitimate patterns; dropping their work is never one of them.

Decide what to render from the auth state you already fetched on load (`Gipity.auth.user()` / `auth/status`).

**Don't** - let them fill the form, then block at submit and drop the work:
```js
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const user = await Gipity.auth.user();
  if (!user) { alert('Please sign in to save'); return; } // work already typed, now blocked and lost
  await Gipity.fn('add_recipe', { title: titleInput.value });
});
```

Pick one of these instead:

**Pattern A - gate up front.** Simplest, and right when the form is cheap to defer (nothing lost by waiting). Show a "Sign in to add your own" CTA in place of the create form for signed-out users:
```js
const me = await Gipity.auth.user();
createForm.hidden = !me;     // signed in: show the form
signinGate.hidden = !!me;    // signed out: show the CTA instead (<gipity-signin> / "Sign in to add your own")
```

**Pattern B - let them work, then preserve it through sign-in.** Higher conversion - the user is invested by the time you ask (the "try it, then save" funnel Figma/Canva/CodePen use). The cost is you must *persist the in-progress work* across sign-in and replay the submit, so nothing is lost:
```js
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const draft = { title: titleInput.value };
  let user = await Gipity.auth.user();
  if (!user) {
    sessionStorage.setItem('pendingRecipe', JSON.stringify(draft)); // stash before sign-in
    user = await Gipity.auth.signIn();                              // popup; work is safe
    if (!user) return;                                             // cancelled - draft still on screen
  }
  await Gipity.fn('add_recipe', draft);
});
// On load, restore any stashed draft so a redirect-based sign-in doesn't lose it:
const pending = sessionStorage.getItem('pendingRecipe');
if (pending) { titleInput.value = JSON.parse(pending).title; sessionStorage.removeItem('pendingRecipe'); }
```

Choose A for low-investment forms or when you want a clean signed-out state; choose B when the work itself is the hook and you want the funnel. Either is fine - silently losing the user's work is not.

## Common Mistakes
- **Reading `response.token` instead of `response.data.token`** - the token endpoint wraps its response in a `data` object
- **Forgetting `credentials: 'include'`** - without this, the session cookie won't be sent on cross-origin requests to `a.gipity.ai`
- **Using the project GUID as the token** - the GUID identifies your app, but you still need to call `POST /api/token` to get an actual bearer token
- **Using relative URLs** - app code runs on `app.gipity.ai` or `dev.gipity.ai`, so API calls must use the full URL `https://a.gipity.ai/...`
