{
  "name": "app-auth",
  "title": "App API - User Authentication",
  "description": "Sign in with Gipity: user authentication, consent, and session handling for apps",
  "guid": "sk_plat_auth",
  "category": "App services",
  "requiredTools": [],
  "content": "# App API - User Authentication\n\nWhen 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.)\n\n**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.\n\n**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`).\n\n## How It Works\nUsers logged into Gipity already have a session cookie on `.gipity.ai`. Your app needs two things:\n1. An **app token** (identifies your app to the API)\n2. `credentials: 'include'` on all fetch calls (sends the user's session cookie cross-origin)\n\n## Step 1: Get an App Token\n`POST https://a.gipity.ai/api/token` with your project GUID.\n\n```js\nconst res = await fetch('https://a.gipity.ai/api/token', {\n  method: 'POST',\n  headers: { 'Content-Type': 'application/json' },\n  body: JSON.stringify({ app: '<PROJECT_GUID>' })\n});\nconst json = await res.json();\nconst appToken = json.data.token;  // IMPORTANT: .data.token, NOT .token\n```\n\n**Response shape:**\n```json\n{ \"data\": { \"token\": \"eyJ...\", \"expiresIn\": 900 } }\n```\n\nThe token is short-lived (15 minutes). Cache it and fetch a new one when it expires.\n\n## Step 2: Check Auth State (Recommended)\n`GET /api/<PROJECT_GUID>/auth/status` - returns auth state + URLs in one call.\n\nAdd `X-App-Token` header and `credentials: 'include'`.\n\n**Response shapes:**\n```jsonc\n// Not logged in\n{ \"authenticated\": false, \"consented\": false, \"user\": null, \"loginUrl\": \"...\" }\n\n// Logged in, no consent\n{ \"authenticated\": true, \"consented\": false, \"user\": null, \"consentUrl\": \"...\" }\n\n// Fully authed\n{ \"authenticated\": true, \"consented\": true, \"user\": { \"guid\", \"displayName\", \"avatarUrl\", \"accountSlug\" } }\n```\n\nOptional query param: `?permissions=N` (default `1` = IDENTITY). Use `5` for IDENTITY + AI.\n\n## Lightweight Check\n`GET /api/auth/me` (with `credentials: 'include'`) returns `{ user: { guid, displayName, avatarUrl, accountSlug } }` or `{ user: null }`. No app context - cannot return login/consent URLs.\n\n## App players: sign-in without a Gipity account\n\nNative 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.\n\n## API keys for scripts and agents\n\nSessions 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.\n\n```bash\n# mint (returns the full key exactly once), list, revoke - Bearer = your CLI token (gipity token create)\ncurl -sX POST https://a.gipity.ai/projects/<PROJECT_GUID>/api-keys -H \"Authorization: Bearer $GIPITY_TOKEN\" \\\n  -H 'Content-Type: application/json' -d '{\"name\":\"laptop-script\",\"role\":\"editor\"}'\ncurl -s  https://a.gipity.ai/projects/<PROJECT_GUID>/api-keys   -H \"Authorization: Bearer $GIPITY_TOKEN\"\ncurl -sX DELETE https://a.gipity.ai/projects/<PROJECT_GUID>/api-keys/<ak_guid> -H \"Authorization: Bearer $GIPITY_TOKEN\"\n```\n\nThe script then calls any `auth: user` / `auth: member` function with the key in a **header** - never in the body:\n\n```bash\ncurl -sX POST https://a.gipity.ai/api/<PROJECT_GUID>/fn/add-entry \\\n  -H \"X-Api-Key: $MY_KEY\" -H 'Content-Type: application/json' -d '{\"title\":\"Dune\"}'\n```\n\nInside the handler, `ctx.auth` says who called:\n\n| `ctx.auth.via` | who | `ctx.auth.apiKeyName` |\n|---|---|---|\n| `'user'` | signed-in person (session or app token) | `null` |\n| `'api_key'` | script/agent holding a project API key | the key's name, e.g. `'laptop-script'` |\n| `'anonymous'` | no credentials (`auth: public` only) | `null` |\n\nAn `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.\n\nSo 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.\n\n## Error Codes (Function Calls)\nThe function API also returns auth-related error codes with redirect URLs:\n- **LOGIN_REQUIRED** (401): User not logged into Gipity. Redirect to `error.loginUrl`.\n- **CONSENT_REQUIRED** (403): User is logged in but hasn't granted permission. Redirect to `error.consentUrl`.\n\nAppend `&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`).\n\n## Customizing the login page (auth_branding)\nThe 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:\n\n```yaml\nauth_branding:\n  app_name: Lilyboxd\n  primary_color: \"#fea60b\"      # quote it - a bare # is a YAML comment\n  logo_url: https://media.gipity.ai/.../logo.png   # https; replaces the wordmark\n  tagline: Your film diary\n```\n\n**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.\n\n## Client Code - use the SDK (do this)\nLoad 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.\n\n```html\n<script crossorigin=\"anonymous\" src=\"https://media.gipity.ai/client/v1/gipity.js\"></script>\n<gipity-signin app-guid=\"<PROJECT_GUID>\" permissions=\"1\"></gipity-signin>\n```\n\nOr wire your own button:\n\n```js\nGipity.auth.configure({ appGuid: '<PROJECT_GUID>' });   // permissions defaults to 1 (identity only)\nsigninBtn.addEventListener('click', async () => {\n  const user = await Gipity.auth.signIn(); // opens popup, handles login + consent\n  if (user) showApp(user); else showSignedOut();\n});\n// On load, reflect any existing session (null if not signed in / not consented):\nconst me = await Gipity.auth.user();\nif (me) showApp(me);\n```\n\n**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`.)\n\n**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.\n\nOnce 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.\n\n**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:\n\n```js\nconst off = Gipity.auth.onChange((user) => {\n  if (user) showApp(user); else showSignedOut();\n});\n// off();  // unsubscribe when you no longer care\n```\n\nThe 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.)\n\n### Verifying sign-in behind the login wall (`--auth`)\nPass `--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:\n\n```bash\ngipity page inspect \"https://dev.gipity.ai/<acct>/<app>/\" --auth          # inspect the signed-in page\ngipity page screenshot \"https://dev.gipity.ai/<acct>/<app>/\" --auth        # capture the members-only UI\ngipity page eval \"https://dev.gipity.ai/<acct>/<app>/\" --auth \"App.commands().length\"   # drive signed-in app code\n```\n\nHow 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**.\n\n**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.\n\nScope: `--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.\n\n### Signing out\nWire 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.\n\n```js\nsignoutBtn.addEventListener('click', async () => {\n  await Gipity.auth.signOut();\n  showSignedOut();          // re-render header + view for the signed-out state\n});\n```\n\nDon'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.\n\n## Client Code - Popup Flow (low-level - prefer the SDK above)\nShown only to explain what happens under the hood; new apps should not hand-roll it.\n```js\nasync function checkAuth(appGuid, appToken) {\n  const res = await fetch(\\`https://a.gipity.ai/api/\\${appGuid}/auth/status\\`, {\n    credentials: 'include',\n    headers: { 'X-App-Token': appToken }\n  });\n  const data = await res.json();\n\n  if (data.authenticated && data.consented) {\n    return data.user; // Already authed\n  }\n\n  // Open login or consent popup\n  const authUrl = (data.loginUrl || data.consentUrl) + '&mode=popup';\n  const popup = window.open(authUrl, 'gipity_auth', 'width=450,height=600');\n  return new Promise(resolve => {\n    window.addEventListener('message', function handler(e) {\n      if (e.data?.type === 'gipity_auth') {\n        window.removeEventListener('message', handler);\n        if (e.data.status === 'success') {\n          // Re-check to get user profile\n          checkAuth(appGuid, appToken).then(resolve);\n        } else {\n          resolve(null); // User denied\n        }\n      }\n    });\n  });\n}\n```\n\n## Client Code - Redirect Flow\nNavigates away from the app. Use `&return=<app_url>` so users come back after auth.\n```js\nconst res = await fetch(\\`https://a.gipity.ai/api/\\${appGuid}/auth/status\\`, {\n  credentials: 'include',\n  headers: { 'X-App-Token': token }\n});\nconst data = await res.json();\nif (!data.authenticated) {\n  location.href = data.loginUrl + '&return=' + encodeURIComponent(location.href);\n} else if (!data.consented) {\n  location.href = data.consentUrl + '&return=' + encodeURIComponent(location.href);\n}\n```\n\n## Complete Example (low-level REST - the SDK is simpler)\nThis 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.\n```js\nconst API = 'https://a.gipity.ai';\nconst APP_GUID = '<PROJECT_GUID>';\n\n// Step 1: Get app token\nconst tokenRes = await fetch(\\`\\${API}/api/token\\`, {\n  method: 'POST',\n  headers: { 'Content-Type': 'application/json' },\n  body: JSON.stringify({ app: APP_GUID })\n});\nconst tokenData = await tokenRes.json();\nconst appToken = tokenData.data.token;  // NOTE: .data.token, not .token\n\n// Step 2: Check auth status\nconst authRes = await fetch(\\`\\${API}/api/\\${APP_GUID}/auth/status\\`, {\n  credentials: 'include',\n  headers: { 'X-App-Token': appToken }\n});\nconst auth = await authRes.json();\n\nif (!auth.authenticated) {\n  // Open login popup\n  const popup = window.open(auth.loginUrl + '&mode=popup', 'gipity_auth', 'width=450,height=600');\n} else if (!auth.consented) {\n  // Open consent popup\n  const popup = window.open(auth.consentUrl + '&mode=popup', 'gipity_auth', 'width=450,height=600');\n} else {\n  // Step 3: Call an authenticated function\n  const res = await fetch(\\`\\${API}/api/\\${APP_GUID}/fn/my_function\\`, {\n    method: 'POST',\n    credentials: 'include',\n    headers: { 'Content-Type': 'application/json', 'X-App-Token': appToken },\n    body: JSON.stringify({ param1: 'value' })\n  });\n  const result = await res.json();\n}\n```\n\n## Never let a signed-out user lose work at an auth wall (UX)\nThe 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.\n\nDecide what to render from the auth state you already fetched on load (`Gipity.auth.user()` / `auth/status`).\n\n**Don't** - let them fill the form, then block at submit and drop the work:\n```js\nform.addEventListener('submit', async (e) => {\n  e.preventDefault();\n  const user = await Gipity.auth.user();\n  if (!user) { alert('Please sign in to save'); return; } // work already typed, now blocked and lost\n  await Gipity.fn('add_recipe', { title: titleInput.value });\n});\n```\n\nPick one of these instead:\n\n**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:\n```js\nconst me = await Gipity.auth.user();\ncreateForm.hidden = !me;     // signed in: show the form\nsigninGate.hidden = !!me;    // signed out: show the CTA instead (<gipity-signin> / \"Sign in to add your own\")\n```\n\n**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:\n```js\nform.addEventListener('submit', async (e) => {\n  e.preventDefault();\n  const draft = { title: titleInput.value };\n  let user = await Gipity.auth.user();\n  if (!user) {\n    sessionStorage.setItem('pendingRecipe', JSON.stringify(draft)); // stash before sign-in\n    user = await Gipity.auth.signIn();                              // popup; work is safe\n    if (!user) return;                                             // cancelled - draft still on screen\n  }\n  await Gipity.fn('add_recipe', draft);\n});\n// On load, restore any stashed draft so a redirect-based sign-in doesn't lose it:\nconst pending = sessionStorage.getItem('pendingRecipe');\nif (pending) { titleInput.value = JSON.parse(pending).title; sessionStorage.removeItem('pendingRecipe'); }\n```\n\nChoose 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.\n\n## Common Mistakes\n- **Reading `response.token` instead of `response.data.token`** - the token endpoint wraps its response in a `data` object\n- **Forgetting `credentials: 'include'`** - without this, the session cookie won't be sent on cross-origin requests to `a.gipity.ai`\n- **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\n- **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/...`"
}
