# Managed End-User Auth

Let the platform handle sign-in for your app's users. You set an **access policy**; the
platform serves the login/signup pages, stores the accounts and sessions, and **blocks
unauthenticated visitors at the edge — they never reach your app**, so bots and anonymous
traffic don't consume its resources or wake it up.

This is for the **end-users of your app** (the people who visit it), not for your onvibe
account. Don't build your own login tables for the common case — use this.

## Set the policy

The policy is **configuration**: it lives in `onvibe.json`, alongside the allowlist and the
public paths. Change it with the config cycle — `get_config` → edit → deploy (see
`onvibe://docs/config`):

```jsonc
{
  "version": "<keep as downloaded>",
  "access": { "policy": "signup" }
}
```

| policy | who can access |
|--------|----------------|
| `public` | anyone, no login (the default) |
| `password` | one **shared password** protects the whole app — no user accounts |
| `signup` | visitors must register and sign in; **open registration** |
| `allowlist` | only emails you pre-authorize can register and sign in |

**Password mode** is the one exception, because the password is a secret and secrets never go in
the config file: set it with `set_access_policy({ project_id, policy: "password", password: "..." })`
— the only tool that still writes access config. Visitors enter the shared password at
`/auth/login` and are let in; there are no accounts and no `currentUser()` identity. Changing the
password signs everyone out.

For `allowlist`, the authorized emails are part of the same file — the list is complete, so
deploying it also **revokes** anyone you removed:

```jsonc
{
  "version": "<keep as downloaded>",
  "access": { "policy": "allowlist", "allowlist": ["ana@example.com", "bo@example.com"] }
}
```

## The login pages are automatic

When the policy is `signup` or `allowlist`, the platform serves sign-in and registration at
`/auth/login` (and `/auth/signup`, `/auth/logout`). **You do not route these paths** — the
platform intercepts exactly those three, before the request reaches your app. Everything else
(including other `/auth/*` paths like `/auth/profile`) goes to your app as normal. This
reservation applies **only while the app has a policy other than `public`**: a public app can
use `/auth/login` for its own route freely. So: if you enable managed auth, don't build your
own `/auth/login`, `/auth/signup` or `/auth/logout`.

An unauthenticated request is redirected to the login page **without ever hitting your app**.
That's the whole point: no wasted resources on traffic that isn't allowed in.

### Branding: logo + title

The login, signup, password and reset pages show your app's **logo** and **title**, so they look
like part of your app rather than a generic gate:

- **Logo** — set it with `set_logo` (a full icon set is generated). Until you do, the pages show a
  neutral default (the app initial on a colored tile). The logo is served by the platform, so it
  never wakes your app.
- **Title** — the app's display name, set via `title` in `onvibe.json` (download → edit → redeploy;
  see the config doc). If you don't set one, it defaults to the subdomain (the project id).

## Read the signed-in user

The platform injects the identity as **signed** request headers. Use `currentUser()` from
`./.onvibe/helpers.ts`, which verifies the signature for you (a request that didn't come
through the platform gate is rejected):

```ts
import { currentUser } from "./.onvibe/helpers.ts";

export default async function handler(req: Request): Promise<Response> {
  const user = await currentUser(req);   // { id, email } | null
  if (!user) return new Response("Unauthorized", { status: 401 });

  // user.id is a stable, immutable key — use it (not the email) to key this
  // person's rows in your database.
  return new Response(`Hello ${user.email}`);
}
```

`currentUser()` returns `null` for anonymous requests, for cron triggers, and for any request
whose identity header isn't validly signed. Never trust a raw `X-Onvibe-User` header yourself
— always go through `currentUser()`.

> Note: under a plain `signup`/`allowlist` policy the gate is **all-or-nothing** — anonymous
> visitors are redirected to login before your handler runs, so `currentUser()` is never `null`
> and the `401` branch above is effectively dead. It becomes reachable only on **public paths**
> (below), where anonymous visitors are allowed through.

## Mixed public + private apps (public paths)

Many apps need a mix: a creator signs in to CREATE something, but anonymous visitors PARTICIPATE
via a shared link (Doodle, RSVP, public polls, feedback boards). Keep the app on a `signup` or
`allowlist` policy and open just the visitor-facing routes:

```jsonc
{
  "version": "<keep as downloaded>",
  "access": { "policy": "signup", "publicPaths": ["/", "/d/*", "/api/*/vote"] }
}
```

- Patterns: exact (`/vote`), subtree (`/d/*` = `/d` and everything under it), or per-segment glob
  (`/api/*/vote`, where `*` does not cross `/`). Omit the field (or use `[]`) to clear it — back to all-or-nothing.
- On a public path the edge does **not** block anonymous visitors — so `currentUser()` **can**
  return `null` there; branch on it (show a read-only/participate view vs. a full one). If the
  visitor does have a session, their identity is still injected, so `currentUser()` works too.
- Every route **not** listed still follows the policy and is blocked at the edge. Bots hitting
  private routes still never wake the app.
- Trade-off: serving a public route **does** wake the app (it has to render it). The resource
  saving is only on unauthorized hits to the gated routes — not on anonymous public traffic.
- Only meaningful when the policy isn't `public`. Keep the list tight: a pattern that's too broad
  (e.g. `/*`) makes the whole app public.

## Manage accounts

```
list_app_users({ project_id })              // email + signup date (never passwords)
create_app_user({ project_id, email, password? })   // create an account directly
update_app_user({ project_id, email, new_email?, password? })  // rename / reset password
remove_app_user({ project_id, email })      // deletes the account and signs it out everywhere
create_password_reset({ project_id, email })  // one-time reset link to share (valid 7 days)
```

## Importing users from another auth system

To migrate an existing user base into managed auth:

- **If the old system used bcrypt**, import the hashes directly so people keep their passwords:
  ```
  import_app_users({ project_id, users: [{ email, password_hash: "$2b$..." }, ...] })
  ```
  (`create_app_user` also takes `password_hash` for a single account.) Max 500 per call.
- **If the old hashes are NOT bcrypt** (PBKDF2, argon2, scrypt, …), you can't reuse them. Import
  the emails without a password — each account comes back with a one-time `reset_url`:
  ```
  import_app_users({ project_id, users: [{ email }, ...] })   // → results[].reset_url
  ```
  Share each `reset_url` with its user (valid 7 days); they open it, set a password, and are
  signed in. You can also mint one later with `create_password_reset`.
- A common flow: read the old users straight from the app's database with `query_sql`, then feed
  them to `import_app_users`.

Self-service "forgot password": when the platform's system email is configured, the login page
shows a **Forgot password?** link — the user enters their email and gets a reset link by email,
no action needed from you. You can still mint links yourself with `create_password_reset` (e.g.
for imports, or if email isn't configured).

## Notes

- Changing the policy takes effect as soon as the `onvibe.json` is deployed. Switching a `public`
  app to `signup` will send existing deep links to the login page.
- **Cron/scheduled tasks are unaffected.** The gate only applies to public web traffic; scheduled
  triggers reach your app directly, so a job keeps running with any policy. A cron request has no
  end-user, so `currentUser()` returns `null` for it (guard cron paths with `isCronRequest`).
- **Order of checks:** a paused app or one whose owner has no active subscription shows its own
  page (paused / subscription-required) even to signed-in users — those are checked before the
  auth gate, so you won't see a login page for an app that isn't serving anyway.
- With `allowlist`, removing an email from the file (and deploying) stops future logins; call
  `remove_app_user` too to close its current sessions right away.
- Sessions last 30 days. Passwords are stored hashed; the platform never exposes them.
- **Managed vs. self-managed:** this mode keeps auth out of your app entirely. If you truly
  need custom profile fields or your own flows you can build auth inside the app instead — but
  don't run both on the same app, and prefer managed for anything standard.
